article-detail.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. <template>
  2. <view class="detail-container">
  3. <!-- loading -->
  4. <view v-if="loading" class="loading-wrap">
  5. <view class="loading-spinner"></view>
  6. <text class="loading-text">加载中...</text>
  7. </view>
  8. <!-- 错误状态 -->
  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. <!-- 文章内容区 -->
  16. <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
  17. <!-- 封面 -->
  18. <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
  19. <!-- 标题 -->
  20. <text class="detail-title">{{ article.title }}</text>
  21. <!-- 元信息 -->
  22. <view class="detail-meta">
  23. <text class="meta-author">{{ article.author || '浠艾福' }}</text>
  24. <text class="meta-sep">|</text>
  25. <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
  26. <text class="meta-sep">|</text>
  27. <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
  28. <text class="reading-time-badge">{{ formatReadingTime(readingSeconds) }}</text>
  29. </view>
  30. <!-- 分类 -->
  31. <view class="detail-category-row">
  32. <text class="detail-category">{{ article.categoryName || '' }}</text>
  33. </view>
  34. <!-- 五维权重彩条 -->
  35. <view v-if="article.relatedDimensions" class="detail-dimensions">
  36. <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
  37. <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
  38. <text class="dim-label">{{ dim.name }}</text>
  39. </view>
  40. </view>
  41. <!-- 分割线 -->
  42. <view class="divider"></view>
  43. <!-- 正文 -->
  44. <view class="detail-body">
  45. <rich-text :nodes="article.content"></rich-text>
  46. </view>
  47. <!-- 底部占位 -->
  48. <view style="height: 160rpx;"></view>
  49. </scroll-view>
  50. <!-- 底部 -->
  51. <view class="detail-footer">
  52. <button class="share-btn" open-type="share" @click="onShareTap">
  53. <text class="share-btn-icon">📤</text>
  54. <text class="share-btn-text">分享</text>
  55. </button>
  56. </view>
  57. </template>
  58. </view>
  59. </template>
  60. <script>
  61. import { getArticleDetail, reportReadingTime } from '@/utils/api.js'
  62. import shareMixin from '../../components/share-mixin.js'
  63. export default {
  64. mixins: [shareMixin],
  65. data() {
  66. return {
  67. articleId: '',
  68. article: null,
  69. loading: true,
  70. error: false,
  71. errorMsg: '',
  72. readingSeconds: 0,
  73. lastReportedSeconds: 0,
  74. isTimerRunning: false,
  75. timerHandle: null,
  76. syncHandle: null
  77. }
  78. },
  79. onLoad(options) {
  80. if (options && options.id) {
  81. this.articleId = options.id
  82. this.loadDetail(options.id)
  83. } else {
  84. this.error = true
  85. this.errorMsg = '参数错误'
  86. this.loading = false
  87. }
  88. },
  89. onShow: function() {
  90. if (this.article && !this.error) {
  91. this.startReadingTimer()
  92. }
  93. },
  94. onHide: function() {
  95. this.pauseReadingTimer()
  96. },
  97. onUnload: function() {
  98. this.pauseReadingTimer()
  99. },
  100. methods: {
  101. async loadDetail(id) {
  102. this.loading = true
  103. this.error = false
  104. try {
  105. var res = await getArticleDetail({ id: id })
  106. if (res.code === 200 && res.data) {
  107. this.article = res.data
  108. this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
  109. this.startReadingTimer()
  110. } else {
  111. this.error = true
  112. this.errorMsg = '文章不存在或无权限查看'
  113. }
  114. } catch (e) {
  115. this.error = true
  116. this.errorMsg = '加载失败,请稍后重试'
  117. } finally {
  118. this.loading = false
  119. }
  120. },
  121. formatDate(dateStr) {
  122. if (!dateStr) return ''
  123. return dateStr.slice(0, 10)
  124. },
  125. parseDimensions: function(article) {
  126. var dimMap = {
  127. body: { code: 'body', color: '#FF8C42', name: '身' },
  128. mind: { code: 'mind', color: '#FF6B9D', name: '心' },
  129. wisdom: { code: 'wisdom', color: '#6366F1', name: '智' },
  130. action: { code: 'action', color: '#10B981', name: '行' },
  131. wealth: { code: 'wealth', color: '#F59E0B', name: '富' }
  132. }
  133. var raw = article && article.relatedDimensions
  134. ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() })
  135. : []
  136. var weights = null
  137. if (article && article.dimensionWeights) {
  138. try {
  139. var w = JSON.parse(article.dimensionWeights)
  140. if (w && typeof w === 'object') weights = w
  141. } catch (e) {}
  142. }
  143. if (!weights) {
  144. var n = raw.filter(function(c) { return dimMap[c] }).length
  145. if (n > 0) {
  146. var base = Math.floor(100 / n)
  147. var rem = 100 - base * n
  148. weights = {}
  149. var i = 0
  150. raw.forEach(function(c) {
  151. if (!dimMap[c]) return
  152. weights[c] = i < rem ? base + 1 : base
  153. i++
  154. })
  155. }
  156. }
  157. return raw.filter(function(c) { return dimMap[c] }).map(function(c) {
  158. return {
  159. code: dimMap[c].code,
  160. name: dimMap[c].name,
  161. color: dimMap[c].color,
  162. weight: (weights && weights[c]) ? weights[c] : 20
  163. }
  164. })
  165. },
  166. onScrollToBottom() {
  167. // 滚动到底部
  168. },
  169. startReadingTimer: function() {
  170. if (this.isTimerRunning) return
  171. this.isTimerRunning = true
  172. var self = this
  173. this.timerHandle = setInterval(function() {
  174. self.readingSeconds++
  175. }, 1000)
  176. this.syncHandle = setInterval(function() {
  177. self.reportReadingTime()
  178. }, 30000)
  179. },
  180. pauseReadingTimer: function() {
  181. if (!this.isTimerRunning) return
  182. this.isTimerRunning = false
  183. if (this.timerHandle) {
  184. clearInterval(this.timerHandle)
  185. this.timerHandle = null
  186. }
  187. if (this.syncHandle) {
  188. clearInterval(this.syncHandle)
  189. this.syncHandle = null
  190. }
  191. this.reportReadingTime()
  192. },
  193. reportReadingTime: function() {
  194. var delta = this.readingSeconds - this.lastReportedSeconds
  195. if (delta <= 0) return
  196. this.lastReportedSeconds = this.readingSeconds
  197. var childId = uni.getStorageSync('currentChildId')
  198. if (!childId || !this.article) return
  199. reportReadingTime({
  200. articleId: this.article.id,
  201. childId: parseInt(childId),
  202. durationSeconds: delta
  203. })
  204. },
  205. formatReadingTime: function(seconds) {
  206. if (seconds < 60) {
  207. return '已读 ' + seconds + '秒'
  208. }
  209. var min = Math.floor(seconds / 60)
  210. var sec = seconds % 60
  211. return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
  212. }
  213. }
  214. }
  215. </script>
  216. <style scoped>
  217. .detail-container { min-height: 100vh; background: #fff; }
  218. .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  219. .loading-spinner { width: 60rpx; height: 60rpx; border: 4rpx solid #e0e0e0; border-top-color: #5B9BD5; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 20rpx; }
  220. @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  221. .loading-text { font-size: 26rpx; color: #999; }
  222. .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  223. .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
  224. .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
  225. .retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
  226. .retry-btn::after { border: none; }
  227. .content-scroll { height: calc(100vh - 120rpx); }
  228. .detail-cover { width: 100%; display: block; }
  229. .detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
  230. .detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; }
  231. .meta-author { color: #5B9BD5; }
  232. .meta-sep { margin: 0 12rpx; color: #ddd; }
  233. .detail-category-row { padding: 16rpx 30rpx 0; }
  234. .detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
  235. .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
  236. .dim-bar-item { flex: 1; }
  237. .dim-bar { height: 8rpx; border-radius: 4rpx; }
  238. .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
  239. .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
  240. .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
  241. .detail-body rich-text { word-break: break-word; }
  242. /* 底部按钮 */
  243. .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
  244. .share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
  245. .share-btn::after { border: none; }
  246. .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
  247. .share-btn-text { font-size: 24rpx; }
  248. .reading-time-badge { margin-left: auto; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.08); padding: 4rpx 12rpx; border-radius: 20rpx; white-space: nowrap; }
  249. </style>