|
|
@@ -15,7 +15,7 @@
|
|
|
|
|
|
<template v-else-if="article">
|
|
|
<!-- 文章内容区 -->
|
|
|
- <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom" v-if="!showQuiz && !showResult">
|
|
|
+ <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
|
|
|
<!-- 封面 -->
|
|
|
<image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
|
|
|
<!-- 标题 -->
|
|
|
@@ -27,6 +27,7 @@
|
|
|
<text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
|
|
|
<text class="meta-sep">|</text>
|
|
|
<text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
|
|
|
+ <text class="reading-time-badge">{{ formatReadingTime(readingSeconds) }}</text>
|
|
|
</view>
|
|
|
<!-- 分类 -->
|
|
|
<view class="detail-category-row">
|
|
|
@@ -49,70 +50,19 @@
|
|
|
<view style="height: 160rpx;"></view>
|
|
|
</scroll-view>
|
|
|
|
|
|
- <!-- 阅读完成 -->
|
|
|
- <view v-if="!showQuiz && !showResult" class="detail-footer">
|
|
|
+ <!-- 底部 -->
|
|
|
+ <view class="detail-footer">
|
|
|
<button class="share-btn" open-type="share" @click="onShareTap">
|
|
|
<text class="share-btn-icon">📤</text>
|
|
|
<text class="share-btn-text">分享</text>
|
|
|
</button>
|
|
|
- <button
|
|
|
- class="read-btn"
|
|
|
- @click="onReadComplete"
|
|
|
- >阅读完成</button>
|
|
|
- </view>
|
|
|
-
|
|
|
- <!-- AI 答题界面 -->
|
|
|
- <view v-if="showQuiz" class="quiz-container">
|
|
|
- <view class="quiz-header">
|
|
|
- <text class="quiz-title">阅读小测验</text>
|
|
|
- <text class="quiz-desc">回答以下问题,巩固阅读收获</text>
|
|
|
- </view>
|
|
|
- <view v-if="quizLoading" class="quiz-loading">
|
|
|
- <view class="loading-spinner"></view>
|
|
|
- <text class="quiz-loading-text">AI 正在根据你的情况出题...</text>
|
|
|
- </view>
|
|
|
- <view v-else-if="quizError" class="quiz-error">
|
|
|
- <text class="quiz-error-text">{{ quizErrorMsg }}</text>
|
|
|
- <button class="retry-btn" @click="loadQuiz">重新出题</button>
|
|
|
- </view>
|
|
|
- <template v-else-if="quizQuestions.length > 0">
|
|
|
- <view v-for="(q, idx) in quizQuestions" :key="idx" class="quiz-question">
|
|
|
- <text class="q-title">第{{ idx + 1 }}题</text>
|
|
|
- <text class="q-text">{{ q.question }}</text>
|
|
|
- <view
|
|
|
- v-for="(opt, optIdx) in q.options"
|
|
|
- :key="optIdx"
|
|
|
- :class="['q-option', selectedAnswers[idx] === getOptionLetter(optIdx) ? 'selected' : '']"
|
|
|
- @click="selectAnswer(idx, getOptionLetter(optIdx))"
|
|
|
- >
|
|
|
- <text class="q-option-letter">{{ getOptionLetter(optIdx) }}</text>
|
|
|
- <text class="q-option-text">{{ getOptionText(opt) }}</text>
|
|
|
- </view>
|
|
|
- </view>
|
|
|
- <button
|
|
|
- class="submit-btn"
|
|
|
- :disabled="!canSubmit"
|
|
|
- @click="onSubmitQuiz"
|
|
|
- >提交答案</button>
|
|
|
- </template>
|
|
|
- </view>
|
|
|
-
|
|
|
- <!-- 答题结果 -->
|
|
|
- <view v-if="showResult" class="result-container">
|
|
|
- <view class="result-card">
|
|
|
- <text class="result-icon">{{ resultScore === resultTotal ? '🎉' : '💪' }}</text>
|
|
|
- <text class="result-title">{{ resultScore === resultTotal ? '全部答对!' : '继续加油!' }}</text>
|
|
|
- <text class="result-score">{{ resultScore }} / {{ resultTotal }}</text>
|
|
|
- <text v-if="resultPoints > 0" class="result-points">+{{ resultPoints }} 积分</text>
|
|
|
- </view>
|
|
|
- <button class="back-btn" @click="goBack">返回文章列表</button>
|
|
|
</view>
|
|
|
</template>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import { getArticleDetail, getAiQuestions, submitAnswers, recordArticleRead } from '@/utils/api.js'
|
|
|
+import { getArticleDetail, reportReadingTime } from '@/utils/api.js'
|
|
|
import shareMixin from '../../components/share-mixin.js'
|
|
|
|
|
|
export default {
|
|
|
@@ -124,43 +74,33 @@ export default {
|
|
|
loading: true,
|
|
|
error: false,
|
|
|
errorMsg: '',
|
|
|
- readSeconds: 0,
|
|
|
- readTimer: null,
|
|
|
- showQuiz: false,
|
|
|
- quizLoading: false,
|
|
|
- quizError: false,
|
|
|
- quizErrorMsg: '',
|
|
|
- quizQuestions: [],
|
|
|
- quizRecordId: null,
|
|
|
- selectedAnswers: [],
|
|
|
- showResult: false,
|
|
|
- resultScore: 0,
|
|
|
- resultTotal: 0,
|
|
|
- resultPoints: 0
|
|
|
- }
|
|
|
- },
|
|
|
- computed: {
|
|
|
- canSubmit: function() {
|
|
|
- if (this.quizQuestions.length === 0) return false
|
|
|
- for (var i = 0; i < this.quizQuestions.length; i++) {
|
|
|
- if (!this.selectedAnswers[i]) return false
|
|
|
- }
|
|
|
- return true
|
|
|
+ readingSeconds: 0,
|
|
|
+ lastReportedSeconds: 0,
|
|
|
+ isTimerRunning: false,
|
|
|
+ timerHandle: null,
|
|
|
+ syncHandle: null
|
|
|
}
|
|
|
},
|
|
|
onLoad(options) {
|
|
|
if (options && options.id) {
|
|
|
this.articleId = options.id
|
|
|
this.loadDetail(options.id)
|
|
|
- this.startTimer()
|
|
|
} else {
|
|
|
this.error = true
|
|
|
this.errorMsg = '参数错误'
|
|
|
this.loading = false
|
|
|
}
|
|
|
},
|
|
|
+ onShow: function() {
|
|
|
+ if (this.article && !this.error) {
|
|
|
+ this.startReadingTimer()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onHide: function() {
|
|
|
+ this.pauseReadingTimer()
|
|
|
+ },
|
|
|
onUnload: function() {
|
|
|
- this.stopTimer()
|
|
|
+ this.pauseReadingTimer()
|
|
|
},
|
|
|
methods: {
|
|
|
async loadDetail(id) {
|
|
|
@@ -171,6 +111,7 @@ export default {
|
|
|
if (res.code === 200 && res.data) {
|
|
|
this.article = res.data
|
|
|
this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
|
|
|
+ this.startReadingTimer()
|
|
|
} else {
|
|
|
this.error = true
|
|
|
this.errorMsg = '文章不存在或无权限查看'
|
|
|
@@ -182,18 +123,6 @@ export default {
|
|
|
this.loading = false
|
|
|
}
|
|
|
},
|
|
|
- startTimer: function() {
|
|
|
- var self = this
|
|
|
- this.readTimer = setInterval(function() {
|
|
|
- self.readSeconds++
|
|
|
- }, 1000)
|
|
|
- },
|
|
|
- stopTimer: function() {
|
|
|
- if (this.readTimer) {
|
|
|
- clearInterval(this.readTimer)
|
|
|
- this.readTimer = null
|
|
|
- }
|
|
|
- },
|
|
|
formatDate(dateStr) {
|
|
|
if (!dateStr) return ''
|
|
|
return dateStr.slice(0, 10)
|
|
|
@@ -239,109 +168,52 @@ export default {
|
|
|
}
|
|
|
})
|
|
|
},
|
|
|
- getOptionLetter(idx) {
|
|
|
- return String.fromCharCode(65 + idx)
|
|
|
- },
|
|
|
- getOptionText(opt) {
|
|
|
- if (!opt) return ''
|
|
|
- // Remove "A. ", "B. " prefix for display
|
|
|
- var match = opt.match(/^[A-D][.、]\s*/);
|
|
|
- return match ? opt.substring(match[0].length) : opt
|
|
|
- },
|
|
|
- selectAnswer(qIdx, letter) {
|
|
|
- this.selectedAnswers[qIdx] = letter
|
|
|
- this.$forceUpdate()
|
|
|
- },
|
|
|
onScrollToBottom() {
|
|
|
// 滚动到底部
|
|
|
},
|
|
|
- async onReadComplete() {
|
|
|
- // Stop timer and record the reading
|
|
|
- this.stopTimer()
|
|
|
- // Record read with duration (only meaningful if childId present)
|
|
|
- var childId = uni.getStorageSync('currentChildId')
|
|
|
- var userId = uni.getStorageSync('userId')
|
|
|
- if (childId && userId && this.article && this.article.id) {
|
|
|
- try {
|
|
|
- var readRes = await recordArticleRead({
|
|
|
- id: this.article.id,
|
|
|
- userId: userId,
|
|
|
- childId: childId,
|
|
|
- durationSeconds: this.readSeconds
|
|
|
- })
|
|
|
- if (readRes && readRes.code === 200 && readRes.data) {
|
|
|
- var earned = readRes.data.pointsEarned || 0
|
|
|
- if (earned > 0) {
|
|
|
- uni.showToast({ title: '阅读完成,获得 ' + earned + ' 积分', icon: 'success' })
|
|
|
- } else if (readRes.data.alreadyCompleted) {
|
|
|
- uni.showToast({ title: '已计过积分啦', icon: 'none' })
|
|
|
- } else if (readRes.data.belowThreshold) {
|
|
|
- var minSec = readRes.data.minSeconds || 60
|
|
|
- uni.showToast({ title: '阅读需满' + minSec + '秒计入积分', icon: 'none' })
|
|
|
- }
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // silent fail - reading still counted
|
|
|
- }
|
|
|
- }
|
|
|
- this.showQuiz = true
|
|
|
- this.loadQuiz()
|
|
|
+ startReadingTimer: function() {
|
|
|
+ if (this.isTimerRunning) return
|
|
|
+ this.isTimerRunning = true
|
|
|
+ var self = this
|
|
|
+ this.timerHandle = setInterval(function() {
|
|
|
+ self.readingSeconds++
|
|
|
+ }, 1000)
|
|
|
+ this.syncHandle = setInterval(function() {
|
|
|
+ self.reportReadingTime()
|
|
|
+ }, 30000)
|
|
|
},
|
|
|
- async loadQuiz() {
|
|
|
- this.quizLoading = true
|
|
|
- this.quizError = false
|
|
|
- try {
|
|
|
- var childId = uni.getStorageSync('currentChildId')
|
|
|
- var res = await getAiQuestions({
|
|
|
- articleId: this.article.id,
|
|
|
- childId: childId || undefined
|
|
|
- })
|
|
|
- if (res.code === 200 && res.data) {
|
|
|
- this.quizRecordId = res.data.recordId
|
|
|
- var questions = res.data.questions
|
|
|
- if (typeof questions === 'string') {
|
|
|
- questions = JSON.parse(questions)
|
|
|
- }
|
|
|
- this.quizQuestions = Array.isArray(questions) ? questions : []
|
|
|
- this.selectedAnswers = new Array(this.quizQuestions.length).fill(null)
|
|
|
- } else {
|
|
|
- this.quizError = true
|
|
|
- this.quizErrorMsg = '出题失败,请重试'
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- this.quizError = true
|
|
|
- this.quizErrorMsg = '网络错误,请重试'
|
|
|
- } finally {
|
|
|
- this.quizLoading = false
|
|
|
+ pauseReadingTimer: function() {
|
|
|
+ if (!this.isTimerRunning) return
|
|
|
+ this.isTimerRunning = false
|
|
|
+ if (this.timerHandle) {
|
|
|
+ clearInterval(this.timerHandle)
|
|
|
+ this.timerHandle = null
|
|
|
+ }
|
|
|
+ if (this.syncHandle) {
|
|
|
+ clearInterval(this.syncHandle)
|
|
|
+ this.syncHandle = null
|
|
|
}
|
|
|
+ this.reportReadingTime()
|
|
|
},
|
|
|
- async onSubmitQuiz() {
|
|
|
- if (!this.canSubmit) return
|
|
|
- var answers = this.selectedAnswers.map(function(selected, idx) {
|
|
|
- return { questionIndex: idx, selected: selected }
|
|
|
+ reportReadingTime: function() {
|
|
|
+ var delta = this.readingSeconds - this.lastReportedSeconds
|
|
|
+ if (delta <= 0) return
|
|
|
+ this.lastReportedSeconds = this.readingSeconds
|
|
|
+ var childId = uni.getStorageSync('currentChildId')
|
|
|
+ if (!childId || !this.article) return
|
|
|
+ reportReadingTime({
|
|
|
+ articleId: this.article.id,
|
|
|
+ childId: parseInt(childId),
|
|
|
+ durationSeconds: delta
|
|
|
})
|
|
|
- try {
|
|
|
- var childId = uni.getStorageSync('currentChildId')
|
|
|
- var res = await submitAnswers({
|
|
|
- recordId: this.quizRecordId,
|
|
|
- answers: JSON.stringify(answers),
|
|
|
- childId: childId || undefined
|
|
|
- })
|
|
|
- if (res.code === 200 && res.data) {
|
|
|
- this.resultScore = res.data.score
|
|
|
- this.resultTotal = res.data.totalQuestions
|
|
|
- this.resultPoints = res.data.pointsEarned
|
|
|
- this.showResult = true
|
|
|
- this.showQuiz = false
|
|
|
- } else {
|
|
|
- uni.showToast({ title: res.message || '提交失败', icon: 'none' })
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- uni.showToast({ title: '提交失败', icon: 'none' })
|
|
|
- }
|
|
|
},
|
|
|
- goBack() {
|
|
|
- uni.navigateBack()
|
|
|
+ formatReadingTime: function(seconds) {
|
|
|
+ if (seconds < 60) {
|
|
|
+ return '已读 ' + seconds + '秒'
|
|
|
+ }
|
|
|
+ var min = Math.floor(seconds / 60)
|
|
|
+ var sec = seconds % 60
|
|
|
+ return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -375,39 +247,9 @@ export default {
|
|
|
.detail-body rich-text { word-break: break-word; }
|
|
|
/* 底部按钮 */
|
|
|
.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; }
|
|
|
-.read-btn { flex: 1; height: 80rpx; line-height: 80rpx; background: linear-gradient(135deg, #5B9BD5, #3A7CC4); color: #fff; font-size: 30rpx; font-weight: bold; border-radius: 40rpx; text-align: center; border: none; }
|
|
|
-.read-btn::after { border: none; }
|
|
|
-.share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; margin-right: 16rpx; display: flex; align-items: center; border: none; flex-shrink: 0; }
|
|
|
+.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; }
|
|
|
.share-btn::after { border: none; }
|
|
|
.share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
|
|
|
.share-btn-text { font-size: 24rpx; }
|
|
|
-/* 答题区 */
|
|
|
-.quiz-container { padding: 30rpx; }
|
|
|
-.quiz-header { text-align: center; margin-bottom: 40rpx; }
|
|
|
-.quiz-title { font-size: 34rpx; font-weight: bold; color: #333; display: block; }
|
|
|
-.quiz-desc { font-size: 24rpx; color: #999; margin-top: 10rpx; display: block; }
|
|
|
-.quiz-loading { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
|
|
|
-.quiz-loading-text { font-size: 26rpx; color: #999; margin-top: 20rpx; }
|
|
|
-.quiz-error { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
|
|
|
-.quiz-error-text { font-size: 26rpx; color: #999; margin-bottom: 20rpx; }
|
|
|
-.quiz-question { margin-bottom: 40rpx; }
|
|
|
-.q-title { font-size: 24rpx; color: #5B9BD5; font-weight: bold; display: block; margin-bottom: 12rpx; }
|
|
|
-.q-text { font-size: 30rpx; color: #333; line-height: 1.5; display: block; margin-bottom: 20rpx; }
|
|
|
-.q-option { display: flex; align-items: center; padding: 20rpx; background: #f5f7fa; border-radius: 12rpx; margin-bottom: 12rpx; border: 2rpx solid transparent; }
|
|
|
-.q-option.selected { background: #E8F4FD; border-color: #5B9BD5; }
|
|
|
-.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; }
|
|
|
-.q-option.selected .q-option-letter { background: #5B9BD5; }
|
|
|
-.q-option-text { font-size: 26rpx; color: #333; }
|
|
|
-.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; }
|
|
|
-.submit-btn[disabled] { opacity: 0.4; }
|
|
|
-.submit-btn::after { border: none; }
|
|
|
-/* 结果页 */
|
|
|
-.result-container { display: flex; flex-direction: column; align-items: center; padding: 120rpx 30rpx; }
|
|
|
-.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; }
|
|
|
-.result-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
|
|
|
-.result-title { font-size: 32rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
|
|
|
-.result-score { font-size: 48rpx; font-weight: bold; color: #5B9BD5; display: block; margin-bottom: 12rpx; }
|
|
|
-.result-points { font-size: 36rpx; color: #F97316; font-weight: bold; display: block; }
|
|
|
-.back-btn { width: 60%; height: 80rpx; line-height: 80rpx; background: #5B9BD5; color: #fff; font-size: 30rpx; border-radius: 40rpx; text-align: center; border: none; }
|
|
|
-.back-btn::after { border: none; }
|
|
|
+.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; }
|
|
|
</style>
|