| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- <template>
- <view class="detail-container">
- <!-- loading -->
- <view v-if="loading" class="loading-wrap">
- <view class="loading-spinner"></view>
- <text class="loading-text">加载中...</text>
- </view>
- <!-- 错误状态 -->
- <view v-else-if="error" class="error-wrap">
- <text class="error-icon">📄</text>
- <text class="error-text">{{ errorMsg }}</text>
- <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
- </view>
- <template v-else-if="article">
- <!-- 文章内容区 -->
- <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom" v-if="!showQuiz && !showResult">
- <!-- 封面 -->
- <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
- <!-- 标题 -->
- <text class="detail-title">{{ article.title }}</text>
- <!-- 元信息 -->
- <view class="detail-meta">
- <text class="meta-author">{{ article.author || '浠艾福' }}</text>
- <text class="meta-sep">|</text>
- <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
- <text class="meta-sep">|</text>
- <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
- </view>
- <!-- 分类 -->
- <view class="detail-category-row">
- <text class="detail-category">{{ article.categoryName || '' }}</text>
- </view>
- <!-- 五维权重彩条 -->
- <view v-if="article.relatedDimensions" class="detail-dimensions">
- <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
- <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
- <text class="dim-label">{{ dim.name }}</text>
- </view>
- </view>
- <!-- 分割线 -->
- <view class="divider"></view>
- <!-- 正文 -->
- <view class="detail-body">
- <rich-text :nodes="article.content"></rich-text>
- </view>
- <!-- 底部占位 -->
- <view style="height: 160rpx;"></view>
- </scroll-view>
- <!-- 阅读完成 -->
- <view v-if="!showQuiz && !showResult" 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 shareMixin from '../../components/share-mixin.js'
- export default {
- mixins: [shareMixin],
- data() {
- return {
- articleId: '',
- article: null,
- 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
- }
- },
- 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
- }
- },
- onUnload: function() {
- this.stopTimer()
- },
- methods: {
- async loadDetail(id) {
- this.loading = true
- this.error = false
- try {
- var res = await getArticleDetail({ id: id })
- if (res.code === 200 && res.data) {
- this.article = res.data
- this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
- } else {
- this.error = true
- this.errorMsg = '文章不存在或无权限查看'
- }
- } catch (e) {
- this.error = true
- this.errorMsg = '加载失败,请稍后重试'
- } finally {
- 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)
- },
- parseDimensions: function(article) {
- 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: '富' }
- }
- var raw = article && article.relatedDimensions
- ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() })
- : []
- var weights = null
- if (article && article.dimensionWeights) {
- try {
- var w = JSON.parse(article.dimensionWeights)
- if (w && typeof w === 'object') weights = w
- } catch (e) {}
- }
- if (!weights) {
- var n = raw.filter(function(c) { return dimMap[c] }).length
- 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++
- })
- }
- }
- 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
- }
- })
- },
- 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()
- },
- 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
- }
- },
- async onSubmitQuiz() {
- if (!this.canSubmit) return
- var answers = this.selectedAnswers.map(function(selected, idx) {
- return { questionIndex: idx, selected: selected }
- })
- 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()
- }
- }
- }
- </script>
- <style scoped>
- .detail-container { min-height: 100vh; background: #fff; }
- .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
- .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; }
- @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
- .loading-text { font-size: 26rpx; color: #999; }
- .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
- .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
- .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
- .retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
- .retry-btn::after { border: none; }
- .content-scroll { height: calc(100vh - 120rpx); }
- .detail-cover { width: 100%; display: block; }
- .detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
- .detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; }
- .meta-author { color: #5B9BD5; }
- .meta-sep { margin: 0 12rpx; color: #ddd; }
- .detail-category-row { padding: 16rpx 30rpx 0; }
- .detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
- .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
- .dim-bar-item { flex: 1; }
- .dim-bar { height: 8rpx; border-radius: 4rpx; }
- .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
- .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
- .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
- .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::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; }
- </style>
|