article-detail.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. <template>
  2. <view class="detail-container" :style="{ '--font-scale': fontScale }">
  3. <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
  4. <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
  5. <view v-if="loading" class="loading-wrap">
  6. <view class="loading-spinner"></view>
  7. <text class="loading-text">加载中...</text>
  8. </view>
  9. <view v-else-if="error" class="error-wrap">
  10. <text class="error-icon">📄</text>
  11. <text class="error-text">{{ errorMsg }}</text>
  12. <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
  13. </view>
  14. <template v-else-if="article">
  15. <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
  16. <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
  17. <text class="detail-title">{{ article.title }}</text>
  18. <view class="detail-meta">
  19. <text class="meta-author">{{ article.author || '浠艾福' }}</text>
  20. <text class="meta-sep">|</text>
  21. <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
  22. <text class="meta-sep">|</text>
  23. <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
  24. <text class="reading-time-badge" v-if="isLogin && !readingCompleted">{{ formatReadingTime(readingSeconds) }}</text>
  25. <text class="reading-time-badge completed" v-else-if="isLogin && readingCompleted">✅ 阅读完成</text>
  26. <text class="meta-sep" v-if="isLogin">|</text>
  27. <text class="meta-readcount">{{ article.viewCount || 0 }}人阅读</text>
  28. </view>
  29. <view class="detail-category-row">
  30. <text class="detail-category">{{ article.categoryName || '' }}</text>
  31. </view>
  32. <view v-if="article.relatedDimensions" class="detail-dimensions">
  33. <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
  34. <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
  35. <text class="dim-label">{{ dim.name }}</text>
  36. </view>
  37. </view>
  38. <view class="detail-summary" v-if="article.summary">
  39. <text class="detail-summary-text">{{ article.summary }}</text>
  40. </view>
  41. <view class="divider"></view>
  42. <view class="detail-body"><mp-html :content="article.content" :domain="config.API_BASE_URL" :tag-style="contentTagStyle" /></view>
  43. <!-- 相关文章(按标签关联,无匹配整块隐藏;触底分页加载) -->
  44. <view v-if="relatedArticles.length > 0" class="related-section">
  45. <view class="related-header">
  46. <text class="related-title">相关文章</text>
  47. <text class="related-subtitle">根据标签推荐</text>
  48. </view>
  49. <view class="ra-list">
  50. <view
  51. v-for="item in relatedArticles"
  52. :key="item.id"
  53. class="ra-item"
  54. hover-class="ra-item-pressed"
  55. @click="goRelatedArticle(item)"
  56. >
  57. <image class="ra-cover" :src="getImageUrl(item.coverImage) || '/static/default-article.png'" mode="aspectFill" />
  58. <view class="ra-body">
  59. <text class="ra-title">{{ item.title }}</text>
  60. <text class="ra-summary" v-if="item.summary">{{ item.summary }}</text>
  61. </view>
  62. </view>
  63. </view>
  64. <view v-if="relatedArticlesLoading" class="related-loading-hint">加载中...</view>
  65. </view>
  66. <!-- 关联商品推荐(文章标签 ↔ 商品标签匹配,登录/匿名均可返回;商城两列大卡片样式,触底分页加载) -->
  67. <view v-if="relatedProducts.length > 0" class="related-section">
  68. <view class="related-header">
  69. <text class="related-title">相关商品</text>
  70. <text class="related-subtitle">根据文章内容推荐</text>
  71. </view>
  72. <dimension-products :products="relatedProducts" layout="mall" @productClick="goRelatedProduct" />
  73. <view v-if="relatedProductsLoading" class="related-loading-hint">加载中...</view>
  74. <view v-else-if="relatedProductsNoMore" class="related-loading-hint dim">没有更多了</view>
  75. </view>
  76. <!-- 评论区(已屏蔽) -->
  77. <view style="height: 200rpx;"></view>
  78. </scroll-view>
  79. <!-- 底部 -->
  80. <view class="detail-footer">
  81. <view v-if="isLogin" class="listen-btn" :class="{ playing: audioPlaying }" @click="toggleArticleAudio">
  82. <text class="listen-btn-icon">{{ audioLoading ? '⏳' : (audioPlaying ? '⏸' : '🔊') }}</text>
  83. <text class="listen-btn-text">{{ audioLoading ? '生成中' : (audioPlaying ? '停止' : '听文章') }}</text>
  84. </view>
  85. <view class="share-btn" @click="generatePoster">
  86. <text class="share-btn-icon">🖼</text>
  87. <text class="share-btn-text">生成海报</text>
  88. </view>
  89. </view>
  90. </template>
  91. <ContentSharePoster :show="showPoster" :title="(article && article.title) || ''" :coverImage="(article && article.coverImage) || ''" :qrCodeBase64="posterQrCode || ''" typeLabel="好文推荐" :dimensionCode="posterDimension || ''" :summary="posterSummary || ''" :inviteText="posterInviteText || ''" @close="closePoster" />
  92. <!-- 浠宝答题弹窗(一题一页) -->
  93. <view class="quiz-mask" v-if="showQuiz" @click="closeQuiz">
  94. <view class="quiz-dialog" @click.stop>
  95. <view class="quiz-mascot">
  96. <text class="mascot-icon">{{ mascotIcon }}</text>
  97. <text class="mascot-name">{{ mascotName }}</text>
  98. </view>
  99. <template v-if="currentQuestion">
  100. <view class="quiz-step-bar">
  101. <text class="quiz-step-text">第 {{ currentQuizIndex + 1 }} / {{ quizQuestions.length }} 题</text>
  102. <view class="quiz-dots">
  103. <view v-for="(q, qi) in quizQuestions" :key="qi" class="quiz-dot" :class="{ active: qi === currentQuizIndex, done: quizAnswers[qi] }"></view>
  104. </view>
  105. </view>
  106. <text class="q-title">{{ currentQuestion.question }}</text>
  107. <view class="q-options">
  108. <view v-for="(opt, oi) in currentQuestionOptions" :key="oi" class="q-option-row" :class="opt.selected ? 'selected' : ''" @click="selectQuizAnswer(currentQuizIndex, oi)">
  109. <text class="q-option-letter">{{ opt.letter }}.</text>
  110. <text class="q-option-text">{{ opt.text }}</text>
  111. <text class="q-option-check">✓</text>
  112. </view>
  113. </view>
  114. <view class="quiz-nav">
  115. <button v-if="currentQuizIndex > 0" class="quiz-prev" @click="goPrevQuestion">上一题</button>
  116. <button v-if="currentQuizIndex < quizQuestions.length - 1" class="quiz-submit" @click="goNextQuestion">下一题</button>
  117. <button v-else class="quiz-submit" @click="submitQuiz">提交答案</button>
  118. </view>
  119. </template>
  120. </view>
  121. </view>
  122. <!-- 答题结果 -->
  123. <view class="result-mask" v-if="showResult">
  124. <view class="result-dialog">
  125. <text class="result-icon">{{ resultIcon }}</text>
  126. <text class="result-text">答对 {{ quizResult.correctCount }}/{{ quizResult.totalQuestions }} 题</text>
  127. <text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
  128. <button class="result-btn" @click="closeResult">知道了</button>
  129. </view>
  130. </view>
  131. </view>
  132. </template>
  133. <script>
  134. import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, getRelatedArticles, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, completeTask } from '@/utils/api.js'
  135. import shareMixin from '../../components/share-mixin.js'
  136. import ContentSharePoster from '@/components/ContentSharePoster.vue'
  137. import DimensionProducts from '@/components/DimensionProducts.vue'
  138. import MpHtml from '@/components/mp-html/mp-html.vue'
  139. import config from '@/config.js'
  140. import { getFontScale } from '@/utils/fontScale.js'
  141. export default {
  142. mixins: [shareMixin],
  143. components: { ContentSharePoster, DimensionProducts, MpHtml },
  144. data() {
  145. return {
  146. articleId: '', article: null, loading: true, error: false, errorMsg: '',
  147. relatedProducts: [],
  148. relatedArticles: [],
  149. // 关联区块分页状态
  150. relatedProductsPage: 1, relatedProductsLoading: false, relatedProductsNoMore: false,
  151. relatedArticlesPage: 1, relatedArticlesLoading: false, relatedArticlesNoMore: false,
  152. readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
  153. isTimerRunning: false, timerHandle: null, syncHandle: null,
  154. currentChildId: null, isLogin: false,
  155. // 听文章(AI 朗读)
  156. audioUrls: [], audioPlaying: false, audioLoading: false, audioContext: null, audioChunkIndex: 0,
  157. showPoster: false, posterQrCode: '',
  158. posterDimension: '', posterSummary: '', posterInviteText: '',
  159. fontScale: 1,
  160. // 答题
  161. showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {}, quizRecordId: null, currentQuizIndex: 0,
  162. mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
  163. mascotIcon: '🐶', mascotName: '浠宝',
  164. taskId: null
  165. }
  166. },
  167. computed: {
  168. contentTagStyle() {
  169. var scale = this.fontScale || 1
  170. var fontSize = 32 * scale
  171. return { p: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', div: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', section: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', li: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', blockquote: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;' }
  172. },
  173. // 当前展示的一题(一题一页)
  174. currentQuestion: function() {
  175. if (!this.quizQuestions || !this.quizQuestions.length) return null
  176. return this.quizQuestions[this.currentQuizIndex] || null
  177. },
  178. // 当前题的选项展示数据:统一派生字母、剥离后端自带前缀、标记选中态。
  179. // 避免在模板 :class / 插值中调用方法(小程序渲染层不支持)且数组索引访问不响应。
  180. currentQuestionOptions: function() {
  181. var q = this.currentQuestion
  182. if (!q || !q.options) return []
  183. var selected = this.quizAnswers[this.currentQuizIndex] || ''
  184. return q.options.map(function(opt, oi) {
  185. var letter = String.fromCharCode(65 + oi)
  186. var s = opt ? opt.toString().trim() : ''
  187. // 剥离 "A." / "A)" / "(A)" 等常见前缀,字母由下方单独渲染避免重复
  188. var cleaned = s.replace(/^[A-Za-z][\.\)\::]\s*/i, '').trim()
  189. return { letter: letter, text: cleaned, selected: selected === letter }
  190. })
  191. }
  192. },
  193. onLoad(options) {
  194. this.mascotIcon = this.mascotList[Math.floor(Math.random() * 2)].icon
  195. this.mascotName = this.mascotList[Math.floor(Math.random() * 2)].name
  196. // 兼容旧分享链接:refCode / inviteCode 参数(shareMixin onLoad 也会处理,此处显式兜底)
  197. if (options && (options.refCode || options.inviteCode)) {
  198. this.autoBindInviteCode(options.refCode || options.inviteCode)
  199. }
  200. var aid = ''
  201. if (options && options.id) {
  202. aid = options.id
  203. } else if (options && options.scene) {
  204. try {
  205. var sceneDecoded = decodeURIComponent(options.scene)
  206. var idMatch = sceneDecoded.match(/id=(\d+)/)
  207. if (idMatch && idMatch[1]) aid = idMatch[1]
  208. var refMatch = sceneDecoded.match(/ref=([^&]+)/)
  209. if (refMatch && refMatch[1]) this.autoBindInviteCode(refMatch[1])
  210. } catch (e) {}
  211. }
  212. if (aid) { this.articleId = aid; this.loadDetail(aid) }
  213. else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
  214. this.taskId = options && options.taskId ? parseInt(options.taskId) : null
  215. this.currentChildId = uni.getStorageSync('currentChildId') || null
  216. this.isLogin = !!uni.getStorageSync('token')
  217. },
  218. onShow() {
  219. this.fontScale = getFontScale()
  220. var memberId = uni.getStorageSync('currentChildId')
  221. if (!this.article || this.error) return
  222. // 已登录用户(家长/成员/规划师)均可计时,匿名用户不计时
  223. if (this.isLogin) this.startNewReadingSession()
  224. },
  225. onHide() { this.pauseReadingTimer(); this.stopArticleAudio() },
  226. onUnload() { this.pauseReadingTimer(); this.stopArticleAudio() },
  227. methods: {
  228. async loadDetail(id) {
  229. this.loading = true; this.error = false
  230. try {
  231. var res = await getArticleDetail({ id: id })
  232. if (res.code === 200 && res.data) {
  233. this.article = res.data
  234. this.article.coverImage = this.getImageUrl(this.article.coverImage)
  235. this.article.content = this.resolveContentUrls(this.article.content)
  236. this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
  237. this.startReadingTimer()
  238. // 关联区块:放在 loadDetail 内只走一次(onShow 不重复请求,避免双发);先重置分页状态再加载第 1 页
  239. this.resetRelatedState()
  240. this.loadRelatedProducts(id, 1, false)
  241. this.loadRelatedArticles(id, 1, false)
  242. } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
  243. } catch (e) { this.error = true; this.errorMsg = '加载失败' }
  244. finally { this.loading = false }
  245. },
  246. // 重置关联区块分页状态(loadDetail 每次加载前调用,避免切文章后旧状态残留)
  247. resetRelatedState: function() {
  248. this.relatedProductsPage = 1
  249. this.relatedProductsLoading = false
  250. this.relatedProductsNoMore = false
  251. this.relatedArticlesPage = 1
  252. this.relatedArticlesLoading = false
  253. this.relatedArticlesNoMore = false
  254. },
  255. // 加载文章关联商品(后端按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序),每页 4 个,触底翻页
  256. loadRelatedProducts: function(articleId, page, append) {
  257. var self = this
  258. if (!articleId) return
  259. var pg = page || 1
  260. if (pg === 1) {
  261. this.relatedProducts = []
  262. this.relatedProductsNoMore = false
  263. }
  264. if (this.relatedProductsLoading) return
  265. this.relatedProductsLoading = true
  266. getArticleRelatedProducts({ articleId: articleId, size: 4, page: pg }).then(function(res) {
  267. self.relatedProductsLoading = false
  268. if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
  269. if (append && pg > 1) {
  270. self.relatedProducts = self.relatedProducts.concat(res.data)
  271. } else {
  272. self.relatedProducts = res.data
  273. }
  274. if (res.data.length < 4) self.relatedProductsNoMore = true
  275. } else {
  276. self.relatedProductsNoMore = true
  277. }
  278. }).catch(function() {
  279. self.relatedProductsLoading = false
  280. self.relatedProductsNoMore = true
  281. })
  282. },
  283. // 加载相关文章(后端按共享标签数降序),每页 3 篇,触底翻页;无匹配返回空数组 → 前端整块隐藏
  284. loadRelatedArticles: function(articleId, page, append) {
  285. var self = this
  286. if (!articleId) return
  287. var pg = page || 1
  288. if (pg === 1) {
  289. this.relatedArticles = []
  290. this.relatedArticlesNoMore = false
  291. }
  292. if (this.relatedArticlesLoading) return
  293. this.relatedArticlesLoading = true
  294. getRelatedArticles({ articleId: articleId, size: 3, page: pg }).then(function(res) {
  295. self.relatedArticlesLoading = false
  296. if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
  297. if (append && pg > 1) {
  298. self.relatedArticles = self.relatedArticles.concat(res.data)
  299. } else {
  300. self.relatedArticles = res.data
  301. }
  302. if (res.data.length < 3) self.relatedArticlesNoMore = true
  303. } else {
  304. self.relatedArticlesNoMore = true
  305. }
  306. }).catch(function() {
  307. self.relatedArticlesLoading = false
  308. self.relatedArticlesNoMore = true
  309. })
  310. },
  311. // 跳转相关文章详情
  312. goRelatedArticle: function(item) {
  313. if (!item || !item.id) return
  314. uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + item.id })
  315. },
  316. // 跳转商品详情(优先使用后端返回的 url)
  317. goRelatedProduct: function(prod) {
  318. if (!prod) return
  319. var pid = prod.productId || prod.id
  320. var url = prod.url || ('/pages/discover-detail/product-detail/product-detail?id=' + (pid || ''))
  321. if (url.indexOf('/pages/') !== 0) return
  322. uni.navigateTo({ url: url })
  323. },
  324. formatDate(d) { return d ? d.slice(0, 10) : '' },
  325. getImageUrl: function(path) {
  326. if (!path) return ''
  327. if (path.indexOf('http://') === 0 || path.indexOf('https://') === 0) return path
  328. return config.API_BASE_URL + path
  329. },
  330. resolveContentUrls: function(html) {
  331. if (!html) return html
  332. // 将富文本内容中的相对图片路径(/uploads/...)拼接为绝对 URL
  333. return html.replace(/(src|href)=["']\/(uploads\/[^"']+)/g, function(match, attr, path) {
  334. return attr + '="' + config.API_BASE_URL + '/' + path + '"'
  335. })
  336. },
  337. parseDimensions: function(article) {
  338. var dimMap = { body: { code: 'body', color: '#FF8C42', name: '身' }, mind: { code: 'mind', color: '#FF6B9D', name: '心' }, wisdom: { code: 'wisdom', color: '#6366F1', name: '智' }, action: { code: 'action', color: '#10B981', name: '行' }, wealth: { code: 'wealth', color: '#F59E0B', name: '富' } }
  339. var raw = article && article.relatedDimensions ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() }) : []
  340. var weights = null
  341. if (article && article.dimensionWeights) try { var w = JSON.parse(article.dimensionWeights); if (w && typeof w === 'object') weights = w } catch (e) {}
  342. if (!weights) {
  343. var n = raw.filter(function(c) { return dimMap[c] }).length
  344. if (n > 0) { var base = Math.floor(100 / n); var rem = 100 - base * n; weights = {}; var i = 0; raw.forEach(function(c) { if (!dimMap[c]) return; weights[c] = i < rem ? base + 1 : base; i++ }) }
  345. }
  346. return raw.filter(function(c) { return dimMap[c] }).map(function(c) { return { code: dimMap[c].code, name: dimMap[c].name, color: dimMap[c].color, weight: (weights && weights[c]) ? weights[c] : 20 } })
  347. },
  348. getMainDimension: function() {
  349. var dims = this.article ? this.parseDimensions(this.article) : []
  350. var best = null
  351. var i
  352. for (i = 0; i < dims.length; i++) {
  353. if (!best || dims[i].weight > best.weight) best = dims[i]
  354. }
  355. return best ? best.code : ''
  356. },
  357. onScrollToBottom: function() {
  358. // 触底优先翻页「相关商品」(页面下方区块),商品加载完再翻「相关文章」
  359. if (this.relatedProducts.length > 0 && !this.relatedProductsNoMore && !this.relatedProductsLoading) {
  360. this.relatedProductsPage++
  361. this.loadRelatedProducts(this.articleId, this.relatedProductsPage, true)
  362. return
  363. }
  364. if (this.relatedArticles.length > 0 && !this.relatedArticlesNoMore && !this.relatedArticlesLoading) {
  365. this.relatedArticlesPage++
  366. this.loadRelatedArticles(this.articleId, this.relatedArticlesPage, true)
  367. }
  368. },
  369. startNewReadingSession: function() {
  370. // 每次进入文章详情页都从 0 开始新会话,时长不延续上一次
  371. this.readingSeconds = 0
  372. this.lastReportedSeconds = 0
  373. this.startReadingTimer()
  374. },
  375. // 获取当前操作对象的 memberId:优先 currentChildId(切换视角),否则用 userId
  376. getActiveMemberId: function() {
  377. var mid = uni.getStorageSync('currentChildId')
  378. if (mid) return parseInt(mid)
  379. var uid = uni.getStorageSync('userId')
  380. return uid ? parseInt(uid) : null
  381. },
  382. startReadingTimer: function() {
  383. // 无登录态时不计时(匿名浏览只显示时长要求,不计时)
  384. if (!this.isLogin) return
  385. if (this.isTimerRunning || this.readingCompleted) return
  386. this.isTimerRunning = true
  387. var self = this
  388. this.timerHandle = setInterval(function() { self.readingSeconds++ }, 1000)
  389. // 每 10 秒自动记录一次该用户已读时长
  390. this.syncHandle = setInterval(function() { self.reportReadingTime() }, 10000)
  391. },
  392. pauseReadingTimer: function() {
  393. if (!this.isTimerRunning) return
  394. this.isTimerRunning = false
  395. if (this.timerHandle) { clearInterval(this.timerHandle); this.timerHandle = null }
  396. if (this.syncHandle) { clearInterval(this.syncHandle); this.syncHandle = null }
  397. this.reportReadingTime()
  398. },
  399. async reportReadingTime() {
  400. var delta = this.readingSeconds - this.lastReportedSeconds
  401. if (delta <= 0) return
  402. this.lastReportedSeconds = this.readingSeconds
  403. var memberId = this.getActiveMemberId()
  404. if (!this.isLogin || !this.article) return
  405. reportReadingTime({ articleId: this.article.id, memberId: memberId || 0, durationSeconds: delta })
  406. // 达到阅读时长自动完成
  407. var targetSeconds = (this.article.readTime || 3) * 60
  408. if (!this.readingCompleted && this.readingSeconds >= targetSeconds) {
  409. this.readingCompleted = true
  410. this.pauseReadingTimer()
  411. try { await completeArticleRead({ articleId: this.article.id, memberId: memberId || 0, durationSeconds: this.readingSeconds }) } catch (e) {}
  412. uni.showToast({ title: '阅读完成 +5能量', icon: 'success' })
  413. // 弹出答题(登录用户)
  414. this.startQuiz()
  415. }
  416. },
  417. formatReadingTime: function(seconds) {
  418. if (seconds < 60) return '已读 ' + seconds + '秒'
  419. var min = Math.floor(seconds / 60); var sec = seconds % 60
  420. return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
  421. },
  422. // 听文章:首次点击拉取(或复用已生成)音频,多段顺序播放;播放期间保证阅读计时运行
  423. async toggleArticleAudio() {
  424. if (this.audioPlaying) { this.stopArticleAudio(); return }
  425. if (this.audioLoading || !this.article) return
  426. if (this.audioUrls && this.audioUrls.length > 0) { this.playAudioChunk(0); return }
  427. this.audioLoading = true
  428. try {
  429. var res = await getArticleAudio({ articleId: this.article.id })
  430. if (res.code === 200 && res.data && res.data.audioUrls && res.data.audioUrls.length > 0) {
  431. this.audioUrls = res.data.audioUrls
  432. this.playAudioChunk(0)
  433. } else {
  434. uni.showToast({ title: (res && res.message) || '抱歉,音频暂不可用', icon: 'none' })
  435. }
  436. } catch (e) {
  437. uni.showToast({ title: '音频生成失败,请稍后重试', icon: 'none' })
  438. } finally {
  439. this.audioLoading = false
  440. }
  441. },
  442. playAudioChunk: function(index) {
  443. var self = this
  444. // 播放期间保证阅读计时运行(未登录不计时)
  445. if (this.isLogin && !this.isTimerRunning && !this.readingCompleted) this.startReadingTimer()
  446. this.audioChunkIndex = index
  447. var ctx = uni.createInnerAudioContext()
  448. this.audioContext = ctx
  449. ctx.src = this.audioUrls[index]
  450. ctx.onEnded(function() {
  451. if (index + 1 < self.audioUrls.length) { self.playAudioChunk(index + 1) }
  452. else { self.stopArticleAudio() }
  453. })
  454. ctx.onError(function() {
  455. uni.showToast({ title: '播放失败', icon: 'none' })
  456. self.stopArticleAudio()
  457. })
  458. ctx.play()
  459. this.audioPlaying = true
  460. },
  461. stopArticleAudio: function() {
  462. if (this.audioContext) {
  463. try { this.audioContext.stop() } catch (e) {}
  464. try { this.audioContext.destroy() } catch (e) {}
  465. this.audioContext = null
  466. }
  467. this.audioPlaying = false
  468. },
  469. async generatePoster() {
  470. if (!this.articleId) return
  471. uni.showLoading({ title: '生成海报中...' })
  472. try {
  473. if (!this.referralCode) {
  474. await this.loadReferralCode()
  475. }
  476. var scene = 'id=' + this.articleId + (this.referralCode ? '&ref=' + this.referralCode : '')
  477. if (scene.length > 32) {
  478. scene = scene.substring(0, 32)
  479. }
  480. var res = await getShareQrCode('pages/article-center/article-detail', scene)
  481. if (res && res.data) {
  482. this.posterQrCode = res.data.qrCodeBase64 || ''
  483. this.posterDimension = this.getMainDimension()
  484. this.posterSummary = (this.article && this.article.summary) || ''
  485. this.posterInviteText = '「' + (uni.getStorageSync('nickname') || '好友') + '」邀请你一起阅读这篇文章'
  486. this.showPoster = true
  487. }
  488. } catch (e) { uni.showToast({ title: '生成失败', icon: 'none' }) }
  489. finally { uni.hideLoading() }
  490. },
  491. // 答题
  492. async startQuiz() {
  493. if (!this.isLogin) {
  494. uni.showToast({ title: '登录后即可完成知识测评', icon: 'none' })
  495. setTimeout(() => { this.openLoginSheet() }, 1500)
  496. return
  497. }
  498. var memberId = this.getActiveMemberId()
  499. try {
  500. var res = await generateQuiz({ articleId: this.articleId, memberId: memberId || 0 })
  501. if (res.code === 200 && res.data && res.data.questions && res.data.questions.length > 0) {
  502. this.quizRecordId = res.data.recordId
  503. this.quizQuestions = res.data.questions
  504. this.quizAnswers = []
  505. this.currentQuizIndex = 0
  506. this.showQuiz = true
  507. }
  508. } catch (e) {}
  509. },
  510. async submitQuiz() {
  511. var memberId = this.getActiveMemberId()
  512. var answers = []
  513. for (var i = 0; i < this.quizQuestions.length; i++) {
  514. answers.push(this.quizAnswers[i] || '')
  515. }
  516. try {
  517. var res = await submitQuiz({ recordId: this.quizRecordId, memberId: parseInt(memberId || 0), answers: answers })
  518. if (res.code === 200) {
  519. this.quizResult = res.data || {}
  520. this.showQuiz = false
  521. this.showResult = true
  522. // 有关联任务:答完题即完成打卡任务
  523. if (this.taskId && memberId) {
  524. try {
  525. var cr = await completeTask(this.taskId, parseInt(memberId), '', '阅读+答题完成', 'text')
  526. if (cr && cr.data) {
  527. uni.showToast({ title: '任务已完成', icon: 'success' })
  528. }
  529. } catch (e) { /* ignore */ }
  530. }
  531. }
  532. } catch (e) {}
  533. },
  534. closePoster: function() {
  535. this.showPoster = false
  536. },
  537. closeQuiz: function() {
  538. this.showQuiz = false
  539. },
  540. closeResult: function() {
  541. this.showResult = false
  542. },
  543. selectQuizAnswer: function(qi, oi) {
  544. // Vue 2 中数组索引直接赋值(quizAnswers[qi] = x)不触发响应式更新,
  545. // 必须用 $set 才能驱动选中态/圆点高亮刷新
  546. this.$set(this.quizAnswers, qi, String.fromCharCode(65 + oi))
  547. },
  548. // 一题一页:上一题 / 下一题
  549. goPrevQuestion: function() {
  550. if (this.currentQuizIndex > 0) this.currentQuizIndex--
  551. },
  552. goNextQuestion: function() {
  553. if (this.currentQuizIndex < this.quizQuestions.length - 1) this.currentQuizIndex++
  554. }
  555. }
  556. }
  557. </script>
  558. <style scoped lang="scss">
  559. .detail-container { min-height: 100vh; background: #fff; }
  560. .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  561. .loading-spinner { width: 60rpx; height: 60rpx; border: 4rpx solid #e0e0e0; border-top-color: $uni-info; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 20rpx; }
  562. @keyframes spin { 0% { transform: rotate(0deg); } 360% { transform: rotate(360deg); } }
  563. .loading-text { font-size: 26rpx; color: #999; }
  564. .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  565. .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
  566. .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
  567. .retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: $brand-primary; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
  568. .content-scroll { height: calc(100vh - 120rpx); }
  569. .detail-cover { width: 100%; display: block; }
  570. .detail-title { display: block; font-size: calc(36rpx * var(--font-scale, 1)); font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
  571. .detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; flex-wrap: wrap; }
  572. .meta-author { color: $uni-info; }
  573. .meta-sep { margin: 0 12rpx; color: #ddd; }
  574. .reading-time-badge { margin-left: auto; font-size: 20rpx; color: $uni-info; background: rgba(14, 165, 233, 0.08); padding: 4rpx 12rpx; border-radius: 20rpx; white-space: nowrap; }
  575. .reading-time-badge.completed { color: #10B981; background: rgba(16,185,129,0.1); }
  576. .meta-readcount {
  577. font-size: 20rpx;
  578. color: #bbb;
  579. }
  580. .detail-category-row { padding: 16rpx 30rpx 0; }
  581. .detail-category { display: inline-block; font-size: 20rpx; color: $uni-info; background: rgba(14, 165, 233, 0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
  582. .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
  583. .dim-bar-item { flex: 1; }
  584. .dim-bar { height: 8rpx; border-radius: 4rpx; }
  585. .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
  586. .detail-summary { padding: 20rpx 30rpx; }
  587. .detail-summary-text { font-size: calc(26rpx * var(--font-scale, 1)); color: #888; line-height: 1.7; display: block; border-left: 4rpx solid #F97316; padding-left: 20rpx; }
  588. .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
  589. .detail-body { padding: 0 30rpx; font-size: calc(32rpx * var(--font-scale, 1)); color: #444; line-height: 1.8; }
  590. /* 关联商品推荐 */
  591. .related-section { margin-top: 20rpx; padding: 0 30rpx 8rpx; }
  592. .related-header { display: flex; align-items: baseline; margin-bottom: 16rpx; }
  593. .related-title { font-size: 30rpx; font-weight: bold; color: #333; }
  594. .related-subtitle { font-size: 20rpx; color: #999; margin-left: 12rpx; }
  595. .related-loading-hint { text-align: center; font-size: 22rpx; color: #F97316; padding: 16rpx 0 8rpx; }
  596. .related-loading-hint.dim { color: #bbb; }
  597. /* 相关文章 */
  598. .ra-list { display: flex; flex-direction: column; gap: 16rpx; }
  599. .ra-item { display: flex; align-items: center; background: #fafafa; border-radius: 12rpx; padding: 16rpx; }
  600. .ra-item-pressed { opacity: 0.7; }
  601. .ra-cover { width: 140rpx; height: 100rpx; border-radius: 8rpx; background: #eee; flex-shrink: 0; }
  602. .ra-body { margin-left: 20rpx; flex: 1; min-width: 0; }
  603. .ra-title { display: block; font-size: 28rpx; font-weight: 600; color: #333; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  604. .ra-summary { display: block; margin-top: 8rpx; font-size: 22rpx; color: #999; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  605. .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
  606. .share-btn { height: 72rpx; padding: 0 30rpx; background: linear-gradient(135deg, #F97316, #FB923C); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; border: none; }
  607. .share-btn::after { border: none; }
  608. .share-btn-icon { font-size: 30rpx; margin-right: 8rpx; }
  609. .share-btn-text { font-size: 26rpx; color: #fff; font-weight: 600; }
  610. .listen-btn { height: 72rpx; padding: 0 30rpx; background: rgba(14, 165, 233, 0.1); border: 2rpx solid $uni-info; border-radius: 36rpx; display: flex; align-items: center; justify-content: center; }
  611. .listen-btn.playing { background: rgba(14, 165, 233, 0.2); }
  612. .listen-btn-icon { font-size: 30rpx; margin-right: 8rpx; }
  613. .listen-btn-text { font-size: 26rpx; color: $uni-info; font-weight: 600; }
  614. /* 答题弹窗 */
  615. .quiz-mask, .result-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; display: flex; align-items: center; justify-content: center; }
  616. .quiz-dialog, .result-dialog { background: #fff; border-radius: 24rpx; width: 650rpx; padding: 32rpx; }
  617. .quiz-mascot { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 16rpx; }
  618. .mascot-icon { font-size: 60rpx; }
  619. .mascot-name { font-size: 32rpx; font-weight: 700; color: #F97316; }
  620. .quiz-step-bar { display: flex; align-items: center; justify-content: center; margin-bottom: 24rpx; }
  621. .quiz-step-text { font-size: 24rpx; color: #999; margin-right: 12rpx; }
  622. .quiz-dots { display: flex; align-items: center; gap: 8rpx; }
  623. .quiz-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #E5E7EB; }
  624. .quiz-dot.active { background: #F97316; }
  625. .quiz-dot.done { background: #FDBA74; }
  626. .q-title { font-size: 30rpx; font-weight: 600; color: #333; line-height: 1.5; margin-bottom: 20rpx; }
  627. .q-options { display: flex; flex-direction: column; gap: 12rpx; }
  628. .q-option-row { display: flex; align-items: center; padding: 16rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; }
  629. .q-option-row.selected { border-color: #F97316; background: #FFF7ED; }
  630. .q-option-letter { width: 44rpx; font-size: 26rpx; font-weight: 600; color: #666; flex-shrink: 0; }
  631. .q-option-row.selected .q-option-letter { color: #F97316; }
  632. .q-option-text { flex: 1; min-width: 0; font-size: 26rpx; color: #333; }
  633. .q-option-row.selected .q-option-text { color: #92400E; }
  634. .q-option-check { width: 32rpx; height: 32rpx; border-radius: 50%; border: 3rpx solid #D1D5DB; color: transparent; font-size: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
  635. .q-option-row.selected .q-option-check { border-color: #F97316; background: #F97316; color: #fff; }
  636. .quiz-nav { display: flex; gap: 16rpx; margin-top: 28rpx; }
  637. .quiz-prev { flex: 1; padding: 20rpx; background: #fff; color: #F97316; border: 2rpx solid #F97316; font-size: 28rpx; border-radius: 12rpx; }
  638. .quiz-prev::after { border: none; }
  639. .quiz-submit { flex: 1; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; }
  640. .quiz-submit::after { border: none; }
  641. .result-btn { width: 100%; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; margin-top: 20rpx; }
  642. .result-dialog { text-align: center; }
  643. .result-icon { font-size: 80rpx; }
  644. .result-text { font-size: 32rpx; font-weight: 700; color: #333; margin: 16rpx 0; }
  645. .result-energy { font-size: 28rpx; color: #F97316; }
  646. </style>