article-detail.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. <template>
  2. <view class="detail-container">
  3. <!-- 加载状态 -->
  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. <!-- 文章内容 -->
  15. <view v-else-if="article" class="detail-content">
  16. <!-- 封面图 -->
  17. <image
  18. v-if="article.coverImage"
  19. class="detail-cover"
  20. :src="article.coverImage"
  21. mode="widthFix"
  22. />
  23. <!-- 文章标题 -->
  24. <text class="detail-title">{{ article.title }}</text>
  25. <!-- 元信息 -->
  26. <view class="detail-meta">
  27. <text class="meta-author">{{ article.author || '浠艾福' }}</text>
  28. <text class="meta-separator">|</text>
  29. <text class="meta-date">{{ getFormattedDate() }}</text>
  30. <text class="meta-separator">|</text>
  31. <text class="meta-read">{{ article.readTime || 3 }} 分钟阅读</text>
  32. </view>
  33. <!-- 分类标签 -->
  34. <view class="detail-category-row">
  35. <text class="detail-category">{{ article.categoryName || '' }}</text>
  36. </view>
  37. <!-- 文章标签 -->
  38. <view v-if="tagList.length" class="detail-tags">
  39. <text v-for="(tag, idx) in tagList" :key="idx" class="tag-item">{{ tag }}</text>
  40. </view>
  41. <!-- 分割线 -->
  42. <view class="divider"></view>
  43. <!-- 文章正文(富文本) -->
  44. <view class="detail-body">
  45. <rich-text :nodes="article.content" />
  46. </view>
  47. <!-- 分享按钮 -->
  48. <view class="share-btn-wrap">
  49. <button class="share-btn" @click="handleShare">分享文章</button>
  50. </view>
  51. <!-- 底部完成按钮 -->
  52. <view class="detail-footer">
  53. <!-- 定时器显示 -->
  54. <view class="reading-timer" v-if="readingSeconds >= 0">
  55. <text class="timer-icon">⏱</text>
  56. <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
  57. <text class="timer-hint" v-if="readingSeconds < 60">阅读{{ 60 - readingSeconds }}秒可获取积分</text>
  58. <text class="timer-hint timer-ready" v-else>已达标,可领取积分</text>
  59. </view>
  60. <!-- 完成按钮 -->
  61. <button
  62. class="read-btn"
  63. :class="{ 'read-btn-active': readingSeconds >= 60 }"
  64. @click="onReadComplete"
  65. >阅读完成</button>
  66. </view>
  67. </view>
  68. <!-- 空状态 -->
  69. <view v-else class="error-wrap">
  70. <text class="error-icon">📄</text>
  71. <text class="error-text">文章不存在</text>
  72. </view>
  73. <!-- 分享弹窗 -->
  74. <view class="modal-mask" v-if="showShareModal" @click="showShareModal = false">
  75. <view class="share-modal" @click.stop="">
  76. <text class="share-modal-title">分享到</text>
  77. <view class="share-modal-items">
  78. <view class="share-modal-item" @click="shareToWechat">
  79. <text class="share-modal-icon">💬</text>
  80. <text class="share-modal-label">微信好友</text>
  81. </view>
  82. <view class="share-modal-item" @click="shareToTimeline">
  83. <text class="share-modal-icon">📱</text>
  84. <text class="share-modal-label">朋友圈</text>
  85. </view>
  86. </view>
  87. </view>
  88. </view>
  89. </view>
  90. </template>
  91. <script>
  92. import { getArticleDetail, recordArticleRead } from '../../utils/api.js'
  93. export default {
  94. data() {
  95. return {
  96. articleId: '',
  97. article: null,
  98. loading: true,
  99. error: false,
  100. errorMsg: '',
  101. tagList: [],
  102. readingSeconds: 0,
  103. readingTimer: null,
  104. readRecorded: false,
  105. showShareModal: false
  106. }
  107. },
  108. onLoad(options) {
  109. if (options && options.id) {
  110. this.articleId = options.id
  111. this.loadDetail(options.id)
  112. this.startReadingTimer()
  113. } else {
  114. this.error = true
  115. this.errorMsg = '参数错误'
  116. this.loading = false
  117. }
  118. },
  119. onHide() {
  120. this.stopReadingTimer()
  121. },
  122. onUnload() {
  123. this.stopReadingTimer()
  124. },
  125. methods: {
  126. async loadDetail(id) {
  127. this.loading = true
  128. this.error = false
  129. try {
  130. const res = await getArticleDetail({ id: id })
  131. if (res.code === 200 && res.data) {
  132. this.article = res.data
  133. // 解析标签 JSON 字符串
  134. if (res.data.tags) {
  135. try {
  136. let parsed = JSON.parse(res.data.tags)
  137. this.tagList = Array.isArray(parsed) ? parsed : []
  138. } catch (e) {
  139. this.tagList = []
  140. }
  141. }
  142. } else {
  143. this.error = true
  144. this.errorMsg = '文章不存在或无权限查看'
  145. }
  146. } catch (e) {
  147. this.error = true
  148. this.errorMsg = '加载失败,请稍后重试'
  149. } finally {
  150. this.loading = false
  151. }
  152. },
  153. getFormattedDate() {
  154. if (this.article && this.article.publishedAt) {
  155. return this.article.publishedAt.slice(0, 10)
  156. }
  157. return ''
  158. },
  159. startReadingTimer() {
  160. var self = this
  161. this.readingTimer = setInterval(function() {
  162. self.readingSeconds = self.readingSeconds + 1
  163. // Auto-submit when reaching 60 seconds (threshold)
  164. // But don't auto-submit - just indicate readiness
  165. }, 1000)
  166. },
  167. stopReadingTimer() {
  168. if (this.readingTimer) {
  169. clearInterval(this.readingTimer)
  170. this.readingTimer = null
  171. }
  172. },
  173. formatTime(seconds) {
  174. var m = Math.floor(seconds / 60)
  175. var s = seconds % 60
  176. return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
  177. },
  178. handleShare: function() {
  179. var self = this
  180. uni.showActionSheet({
  181. itemList: ['微信好友', '朋友圈', '保存图片'],
  182. success: function(res) {
  183. var channel = 'wechat'
  184. if (res.tapIndex === 1) channel = 'friend'
  185. uni.request({
  186. url: self.baseUrl + '/api/share/create',
  187. method: 'POST',
  188. header: { 'Authorization': 'Bearer ' + self.token },
  189. data: {
  190. targetType: 'article',
  191. targetId: self.articleId,
  192. shareChannel: channel,
  193. title: self.article && self.article.title ? self.article.title : ''
  194. },
  195. success: function(createRes) {
  196. if (createRes.data && createRes.data.code === 200) {
  197. var shareEvent = createRes.data.data
  198. uni.showToast({ title: '分享成功', icon: 'success' })
  199. uni.request({
  200. url: self.baseUrl + '/api/share/click',
  201. method: 'POST',
  202. header: { 'Authorization': 'Bearer ' + self.token },
  203. data: { shareEventId: shareEvent.id }
  204. })
  205. self.showShareModal = true
  206. }
  207. }
  208. })
  209. }
  210. })
  211. },
  212. shareToWechat: function() {
  213. this.showShareModal = false
  214. uni.showToast({ title: '请使用微信分享', icon: 'none' })
  215. },
  216. shareToTimeline: function() {
  217. this.showShareModal = false
  218. uni.showToast({ title: '请使用微信朋友圈', icon: 'none' })
  219. },
  220. async onReadComplete() {
  221. if (this.readRecorded) {
  222. uni.navigateBack()
  223. return
  224. }
  225. this.stopReadingTimer()
  226. try {
  227. var childId = uni.getStorageSync('currentChildId')
  228. var res = await recordArticleRead({
  229. id: this.article.id,
  230. durationSeconds: this.readingSeconds,
  231. childId: childId || undefined
  232. })
  233. this.readRecorded = true
  234. if (res.data && res.data.pointsEarned > 0) {
  235. uni.showToast({ title: '获得' + res.data.pointsEarned + '积分!', icon: 'success' })
  236. } else if (res.data && res.data.belowThreshold) {
  237. uni.showToast({ title: '阅读时间未达标', icon: 'none' })
  238. } else if (res.data && res.data.alreadyCompleted) {
  239. uni.showToast({ title: '已读过此文', icon: 'none' })
  240. } else {
  241. uni.showToast({ title: '阅读记录已保存', icon: 'success' })
  242. }
  243. } catch (e) {
  244. uni.showToast({ title: '记录保存失败', icon: 'none' })
  245. }
  246. var self = this
  247. setTimeout(function() {
  248. uni.navigateBack()
  249. }, 1500)
  250. }
  251. }
  252. }
  253. </script>
  254. <style scoped>
  255. .detail-container {
  256. min-height: 100vh;
  257. background: #fff;
  258. padding-bottom: 120rpx;
  259. }
  260. /* ===== 加载状态 ===== */
  261. .loading-wrap {
  262. display: flex;
  263. flex-direction: column;
  264. align-items: center;
  265. justify-content: center;
  266. padding-top: 300rpx;
  267. }
  268. .loading-spinner {
  269. width: 60rpx;
  270. height: 60rpx;
  271. border: 4rpx solid #e0e0e0;
  272. border-top-color: #5B9BD5;
  273. border-radius: 50%;
  274. animation: spin 0.8s linear infinite;
  275. margin-bottom: 20rpx;
  276. }
  277. @keyframes spin {
  278. 0% { transform: rotate(0deg); }
  279. 100% { transform: rotate(360deg); }
  280. }
  281. .loading-text {
  282. font-size: 26rpx;
  283. color: #999;
  284. }
  285. /* ===== 错误状态 ===== */
  286. .error-wrap {
  287. display: flex;
  288. flex-direction: column;
  289. align-items: center;
  290. padding-top: 300rpx;
  291. }
  292. .error-icon {
  293. font-size: 100rpx;
  294. margin-bottom: 24rpx;
  295. }
  296. .error-text {
  297. font-size: 28rpx;
  298. color: #999;
  299. margin-bottom: 30rpx;
  300. }
  301. .retry-btn {
  302. width: 240rpx;
  303. height: 72rpx;
  304. line-height: 72rpx;
  305. background: #5B9BD5;
  306. color: #fff;
  307. font-size: 28rpx;
  308. border-radius: 36rpx;
  309. text-align: center;
  310. border: none;
  311. }
  312. .retry-btn::after {
  313. border: none;
  314. }
  315. /* ===== 内容区 ===== */
  316. .detail-cover {
  317. width: 100%;
  318. max-height: 500rpx;
  319. display: block;
  320. }
  321. .detail-title {
  322. display: block;
  323. font-size: 36rpx;
  324. font-weight: bold;
  325. color: #333;
  326. line-height: 1.4;
  327. padding: 30rpx 30rpx 0;
  328. }
  329. /* ===== 元信息 ===== */
  330. .detail-meta {
  331. display: flex;
  332. flex-direction: row;
  333. align-items: center;
  334. padding: 16rpx 30rpx 0;
  335. font-size: 22rpx;
  336. color: #999;
  337. }
  338. .meta-author {
  339. color: #5B9BD5;
  340. }
  341. .meta-separator {
  342. margin: 0 12rpx;
  343. color: #ddd;
  344. }
  345. .meta-date,
  346. .meta-read {
  347. color: #999;
  348. }
  349. /* ===== 分类标签 ===== */
  350. .detail-category-row {
  351. padding: 16rpx 30rpx 0;
  352. }
  353. .detail-category {
  354. display: inline-block;
  355. font-size: 20rpx;
  356. color: #5B9BD5;
  357. background: rgba(91, 155, 213, 0.1);
  358. padding: 4rpx 16rpx;
  359. border-radius: 8rpx;
  360. }
  361. /* ===== 标签 ===== */
  362. .detail-tags {
  363. display: flex;
  364. flex-direction: row;
  365. flex-wrap: wrap;
  366. padding: 12rpx 30rpx 0;
  367. gap: 12rpx;
  368. }
  369. .tag-item {
  370. font-size: 20rpx;
  371. color: #8B5CF6;
  372. background: rgba(139, 92, 246, 0.08);
  373. padding: 4rpx 14rpx;
  374. border-radius: 8rpx;
  375. }
  376. /* ===== 分割线 ===== */
  377. .divider {
  378. height: 1rpx;
  379. background: #eee;
  380. margin: 24rpx 30rpx;
  381. }
  382. /* ===== 正文 ===== */
  383. .detail-body {
  384. padding: 0 30rpx;
  385. font-size: 28rpx;
  386. color: #444;
  387. line-height: 1.8;
  388. }
  389. .detail-body rich-text {
  390. word-break: break-word;
  391. }
  392. /* ===== 定时器 ===== */
  393. .reading-timer {
  394. display: flex;
  395. flex-direction: row;
  396. align-items: center;
  397. justify-content: center;
  398. padding: 16rpx 0 20rpx;
  399. }
  400. .timer-icon {
  401. font-size: 32rpx;
  402. margin-right: 8rpx;
  403. }
  404. .timer-text {
  405. font-size: 32rpx;
  406. font-weight: bold;
  407. color: #333;
  408. margin-right: 16rpx;
  409. }
  410. .timer-hint {
  411. font-size: 22rpx;
  412. color: #999;
  413. }
  414. .timer-ready {
  415. color: #22c55e;
  416. font-weight: bold;
  417. }
  418. /* ===== 底部按钮 ===== */
  419. .detail-footer {
  420. padding: 40rpx 60rpx;
  421. }
  422. .read-btn {
  423. width: 100%;
  424. height: 88rpx;
  425. line-height: 88rpx;
  426. background: #ddd;
  427. color: #fff;
  428. font-size: 32rpx;
  429. font-weight: bold;
  430. border-radius: 44rpx;
  431. text-align: center;
  432. border: none;
  433. transition: background 0.3s;
  434. }
  435. .read-btn-active {
  436. background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
  437. }
  438. .read-btn::after {
  439. border: none;
  440. }
  441. /* ===== 分享按钮 ===== */
  442. .share-btn-wrap {
  443. padding: 24rpx 30rpx;
  444. display: flex;
  445. justify-content: flex-end;
  446. }
  447. .share-btn {
  448. padding: 16rpx 32rpx;
  449. background: #F97316;
  450. color: #fff;
  451. border-radius: 32rpx;
  452. font-size: 26rpx;
  453. border: none;
  454. }
  455. .share-btn::after {
  456. border: none;
  457. }
  458. /* ===== 分享弹窗 ===== */
  459. .modal-mask {
  460. position: fixed;
  461. top: 0;
  462. left: 0;
  463. right: 0;
  464. bottom: 0;
  465. background: rgba(0, 0, 0, 0.5);
  466. display: flex;
  467. flex-direction: column;
  468. justify-content: flex-end;
  469. z-index: 1000;
  470. }
  471. .share-modal {
  472. background: #fff;
  473. border-radius: 24rpx 24rpx 0 0;
  474. padding: 40rpx;
  475. }
  476. .share-modal-title {
  477. display: block;
  478. text-align: center;
  479. font-size: 32rpx;
  480. font-weight: bold;
  481. margin-bottom: 32rpx;
  482. }
  483. .share-modal-items {
  484. display: flex;
  485. justify-content: space-around;
  486. }
  487. .share-modal-item {
  488. display: flex;
  489. flex-direction: column;
  490. align-items: center;
  491. }
  492. .share-modal-icon {
  493. font-size: 48rpx;
  494. margin-bottom: 12rpx;
  495. }
  496. .share-modal-label {
  497. font-size: 24rpx;
  498. color: #64748B;
  499. }
  500. </style>