article-detail.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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" v-if="!showQuiz && !showResult">
  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. </view>
  29. <!-- 分类 -->
  30. <view class="detail-category-row">
  31. <text class="detail-category">{{ article.categoryName || '' }}</text>
  32. </view>
  33. <!-- 五维权重彩条 -->
  34. <view v-if="article.relatedDimensions" class="detail-dimensions">
  35. <view v-for="dim in parseDimensions(article.relatedDimensions)" :key="dim.code" class="dim-bar-item">
  36. <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
  37. <text class="dim-label">{{ dim.name }}</text>
  38. </view>
  39. </view>
  40. <!-- 分割线 -->
  41. <view class="divider"></view>
  42. <!-- 正文 -->
  43. <view class="detail-body">
  44. <rich-text :nodes="article.content"></rich-text>
  45. </view>
  46. <!-- 底部占位 -->
  47. <view style="height: 160rpx;"></view>
  48. </scroll-view>
  49. <!-- 阅读完成浮动按钮 -->
  50. <view v-if="!showQuiz && !showResult" class="detail-footer">
  51. <view class="reading-timer">
  52. <text class="timer-icon">⏱</text>
  53. <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
  54. </view>
  55. <button
  56. class="read-btn"
  57. :class="{ 'read-btn-ready': readingSeconds >= 10 }"
  58. :disabled="readingSeconds < 10"
  59. @click="onReadComplete"
  60. >阅读完成</button>
  61. </view>
  62. <!-- AI 答题界面 -->
  63. <view v-if="showQuiz" class="quiz-container">
  64. <view class="quiz-header">
  65. <text class="quiz-title">阅读小测验</text>
  66. <text class="quiz-desc">回答以下问题,巩固阅读收获</text>
  67. </view>
  68. <view v-if="quizLoading" class="quiz-loading">
  69. <view class="loading-spinner"></view>
  70. <text class="quiz-loading-text">AI 正在根据你的情况出题...</text>
  71. </view>
  72. <view v-else-if="quizError" class="quiz-error">
  73. <text class="quiz-error-text">{{ quizErrorMsg }}</text>
  74. <button class="retry-btn" @click="loadQuiz">重新出题</button>
  75. </view>
  76. <template v-else-if="quizQuestions.length > 0">
  77. <view v-for="(q, idx) in quizQuestions" :key="idx" class="quiz-question">
  78. <text class="q-title">第{{ idx + 1 }}题</text>
  79. <text class="q-text">{{ q.question }}</text>
  80. <view
  81. v-for="(opt, optIdx) in q.options"
  82. :key="optIdx"
  83. :class="['q-option', selectedAnswers[idx] === getOptionLetter(optIdx) ? 'selected' : '']"
  84. @click="selectAnswer(idx, getOptionLetter(optIdx))"
  85. >
  86. <text class="q-option-letter">{{ getOptionLetter(optIdx) }}</text>
  87. <text class="q-option-text">{{ getOptionText(opt) }}</text>
  88. </view>
  89. </view>
  90. <button
  91. class="submit-btn"
  92. :disabled="!canSubmit"
  93. @click="onSubmitQuiz"
  94. >提交答案</button>
  95. </template>
  96. </view>
  97. <!-- 答题结果 -->
  98. <view v-if="showResult" class="result-container">
  99. <view class="result-card">
  100. <text class="result-icon">{{ resultScore === resultTotal ? '🎉' : '💪' }}</text>
  101. <text class="result-title">{{ resultScore === resultTotal ? '全部答对!' : '继续加油!' }}</text>
  102. <text class="result-score">{{ resultScore }} / {{ resultTotal }}</text>
  103. <text v-if="resultPoints > 0" class="result-points">+{{ resultPoints }} 积分</text>
  104. </view>
  105. <button class="back-btn" @click="goBack">返回文章列表</button>
  106. </view>
  107. </template>
  108. </view>
  109. </template>
  110. <script>
  111. import { getArticleDetail, getAiQuestions, submitAnswers, recordArticleRead } from '@/utils/api.js'
  112. export default {
  113. data() {
  114. return {
  115. articleId: '',
  116. article: null,
  117. loading: true,
  118. error: false,
  119. errorMsg: '',
  120. readingSeconds: 0,
  121. readingTimer: null,
  122. showQuiz: false,
  123. quizLoading: false,
  124. quizError: false,
  125. quizErrorMsg: '',
  126. quizQuestions: [],
  127. quizRecordId: null,
  128. selectedAnswers: [],
  129. showResult: false,
  130. resultScore: 0,
  131. resultTotal: 0,
  132. resultPoints: 0
  133. }
  134. },
  135. computed: {
  136. canSubmit: function() {
  137. if (this.quizQuestions.length === 0) return false
  138. for (var i = 0; i < this.quizQuestions.length; i++) {
  139. if (!this.selectedAnswers[i]) return false
  140. }
  141. return true
  142. }
  143. },
  144. onLoad(options) {
  145. if (options && options.id) {
  146. this.articleId = options.id
  147. this.loadDetail(options.id)
  148. this.startTimer()
  149. } else {
  150. this.error = true
  151. this.errorMsg = '参数错误'
  152. this.loading = false
  153. }
  154. },
  155. onUnload() {
  156. this.stopTimer()
  157. },
  158. methods: {
  159. async loadDetail(id) {
  160. this.loading = true
  161. this.error = false
  162. try {
  163. var res = await getArticleDetail({ id: id })
  164. if (res.code === 200 && res.data) {
  165. this.article = res.data
  166. } else {
  167. this.error = true
  168. this.errorMsg = '文章不存在或无权限查看'
  169. }
  170. } catch (e) {
  171. this.error = true
  172. this.errorMsg = '加载失败,请稍后重试'
  173. } finally {
  174. this.loading = false
  175. }
  176. },
  177. startTimer() {
  178. var self = this
  179. this.readingTimer = setInterval(function() {
  180. self.readingSeconds++
  181. }, 1000)
  182. },
  183. stopTimer() {
  184. if (this.readingTimer) {
  185. clearInterval(this.readingTimer)
  186. this.readingTimer = null
  187. }
  188. },
  189. formatTime(seconds) {
  190. var m = Math.floor(seconds / 60)
  191. var s = seconds % 60
  192. return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
  193. },
  194. formatDate(dateStr) {
  195. if (!dateStr) return ''
  196. return dateStr.slice(0, 10)
  197. },
  198. parseDimensions(str) {
  199. if (!str) return []
  200. var dimMap = {
  201. body: { code: 'body', color: '#FF8C42', name: '身', weight: 20 },
  202. mind: { code: 'mind', color: '#6366F1', name: '智', weight: 20 },
  203. wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心', weight: 20 },
  204. action: { code: 'action', color: '#10B981', name: '行', weight: 20 },
  205. wealth: { code: 'wealth', color: '#F59E0B', name: '富', weight: 20 }
  206. }
  207. var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
  208. return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
  209. },
  210. getOptionLetter(idx) {
  211. return String.fromCharCode(65 + idx)
  212. },
  213. getOptionText(opt) {
  214. if (!opt) return ''
  215. // Remove "A. ", "B. " prefix for display
  216. var match = opt.match(/^[A-D][.、]\s*/);
  217. return match ? opt.substring(match[0].length) : opt
  218. },
  219. selectAnswer(qIdx, letter) {
  220. this.selectedAnswers[qIdx] = letter
  221. this.$forceUpdate()
  222. },
  223. onScrollToBottom() {
  224. // 滚动到底部
  225. },
  226. async onReadComplete() {
  227. this.stopTimer()
  228. try {
  229. var childId = uni.getStorageSync('currentChildId')
  230. await recordArticleRead({
  231. id: this.article.id,
  232. durationSeconds: this.readingSeconds,
  233. childId: childId || undefined
  234. })
  235. } catch (e) {
  236. // 阅读记录失败不影响出题
  237. }
  238. this.showQuiz = true
  239. this.loadQuiz()
  240. },
  241. async loadQuiz() {
  242. this.quizLoading = true
  243. this.quizError = false
  244. try {
  245. var childId = uni.getStorageSync('currentChildId')
  246. var res = await getAiQuestions({
  247. articleId: this.article.id,
  248. childId: childId || undefined
  249. })
  250. if (res.code === 200 && res.data) {
  251. this.quizRecordId = res.data.recordId
  252. var questions = res.data.questions
  253. if (typeof questions === 'string') {
  254. questions = JSON.parse(questions)
  255. }
  256. this.quizQuestions = Array.isArray(questions) ? questions : []
  257. this.selectedAnswers = new Array(this.quizQuestions.length).fill(null)
  258. } else {
  259. this.quizError = true
  260. this.quizErrorMsg = '出题失败,请重试'
  261. }
  262. } catch (e) {
  263. this.quizError = true
  264. this.quizErrorMsg = '网络错误,请重试'
  265. } finally {
  266. this.quizLoading = false
  267. }
  268. },
  269. async onSubmitQuiz() {
  270. if (!this.canSubmit) return
  271. var answers = this.selectedAnswers.map(function(selected, idx) {
  272. return { questionIndex: idx, selected: selected }
  273. })
  274. try {
  275. var childId = uni.getStorageSync('currentChildId')
  276. var res = await submitAnswers({
  277. recordId: this.quizRecordId,
  278. answers: JSON.stringify(answers),
  279. childId: childId || undefined
  280. })
  281. if (res.code === 200 && res.data) {
  282. this.resultScore = res.data.score
  283. this.resultTotal = res.data.totalQuestions
  284. this.resultPoints = res.data.pointsEarned
  285. this.showResult = true
  286. this.showQuiz = false
  287. } else {
  288. uni.showToast({ title: res.message || '提交失败', icon: 'none' })
  289. }
  290. } catch (e) {
  291. uni.showToast({ title: '提交失败', icon: 'none' })
  292. }
  293. },
  294. goBack() {
  295. uni.navigateBack()
  296. }
  297. }
  298. }
  299. </script>
  300. <style scoped>
  301. .detail-container { min-height: 100vh; background: #fff; }
  302. .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  303. .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; }
  304. @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  305. .loading-text { font-size: 26rpx; color: #999; }
  306. .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  307. .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
  308. .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
  309. .retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
  310. .retry-btn::after { border: none; }
  311. .content-scroll { height: calc(100vh - 120rpx); }
  312. .detail-cover { width: 100%; display: block; }
  313. .detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
  314. .detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; }
  315. .meta-author { color: #5B9BD5; }
  316. .meta-sep { margin: 0 12rpx; color: #ddd; }
  317. .detail-category-row { padding: 16rpx 30rpx 0; }
  318. .detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
  319. .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
  320. .dim-bar-item { flex: 1; }
  321. .dim-bar { height: 8rpx; border-radius: 4rpx; }
  322. .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
  323. .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
  324. .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
  325. .detail-body rich-text { word-break: break-word; }
  326. /* 底部按钮 */
  327. .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; }
  328. .reading-timer { display: flex; align-items: center; margin-right: 20rpx; }
  329. .timer-icon { font-size: 32rpx; margin-right: 6rpx; }
  330. .timer-text { font-size: 28rpx; font-weight: bold; color: #333; }
  331. .read-btn { flex: 1; height: 80rpx; line-height: 80rpx; background: #ddd; color: #fff; font-size: 30rpx; font-weight: bold; border-radius: 40rpx; text-align: center; border: none; }
  332. .read-btn-ready { background: linear-gradient(135deg, #5B9BD5, #3A7CC4); }
  333. .read-btn[disabled] { opacity: 0.5; }
  334. .read-btn::after { border: none; }
  335. /* 答题区 */
  336. .quiz-container { padding: 30rpx; }
  337. .quiz-header { text-align: center; margin-bottom: 40rpx; }
  338. .quiz-title { font-size: 34rpx; font-weight: bold; color: #333; display: block; }
  339. .quiz-desc { font-size: 24rpx; color: #999; margin-top: 10rpx; display: block; }
  340. .quiz-loading { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
  341. .quiz-loading-text { font-size: 26rpx; color: #999; margin-top: 20rpx; }
  342. .quiz-error { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
  343. .quiz-error-text { font-size: 26rpx; color: #999; margin-bottom: 20rpx; }
  344. .quiz-question { margin-bottom: 40rpx; }
  345. .q-title { font-size: 24rpx; color: #5B9BD5; font-weight: bold; display: block; margin-bottom: 12rpx; }
  346. .q-text { font-size: 30rpx; color: #333; line-height: 1.5; display: block; margin-bottom: 20rpx; }
  347. .q-option { display: flex; align-items: center; padding: 20rpx; background: #f5f7fa; border-radius: 12rpx; margin-bottom: 12rpx; border: 2rpx solid transparent; }
  348. .q-option.selected { background: #E8F4FD; border-color: #5B9BD5; }
  349. .q-option-letter { width: 40rpx; height: 40rpx; line-height: 40rpx; text-align: center; background: #ddd; color: #fff; border-radius: 50%; font-size: 22rpx; font-weight: bold; margin-right: 16rpx; flex-shrink: 0; }
  350. .q-option.selected .q-option-letter { background: #5B9BD5; }
  351. .q-option-text { font-size: 26rpx; color: #333; }
  352. .submit-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, #F97316, #EA580C); color: #fff; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; text-align: center; border: none; margin-top: 20rpx; }
  353. .submit-btn[disabled] { opacity: 0.4; }
  354. .submit-btn::after { border: none; }
  355. /* 结果页 */
  356. .result-container { display: flex; flex-direction: column; align-items: center; padding: 120rpx 30rpx; }
  357. .result-card { background: #fff; border-radius: 24rpx; padding: 60rpx 80rpx; text-align: center; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08); margin-bottom: 60rpx; }
  358. .result-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
  359. .result-title { font-size: 32rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
  360. .result-score { font-size: 48rpx; font-weight: bold; color: #5B9BD5; display: block; margin-bottom: 12rpx; }
  361. .result-points { font-size: 36rpx; color: #F97316; font-weight: bold; display: block; }
  362. .back-btn { width: 60%; height: 80rpx; line-height: 80rpx; background: #5B9BD5; color: #fff; font-size: 30rpx; border-radius: 40rpx; text-align: center; border: none; }
  363. .back-btn::after { border: none; }
  364. </style>