瀏覽代碼

feat(article-center): add article detail page with AI quiz

Sisyphus 2 月之前
父節點
當前提交
6ba35b9602
共有 1 個文件被更改,包括 372 次插入0 次删除
  1. 372 0
      cfc-frontend/pages/article-center/article-detail.vue

+ 372 - 0
cfc-frontend/pages/article-center/article-detail.vue

@@ -0,0 +1,372 @@
+<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.relatedDimensions)" :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">
+        <view class="reading-timer">
+          <text class="timer-icon">⏱</text>
+          <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
+        </view>
+        <button
+          class="read-btn"
+          :class="{ 'read-btn-ready': readingSeconds >= 10 }"
+          :disabled="readingSeconds < 10"
+          @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'
+
+export default {
+  data() {
+    return {
+      articleId: '',
+      article: null,
+      loading: true,
+      error: false,
+      errorMsg: '',
+      readingSeconds: 0,
+      readingTimer: 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() {
+    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
+        } else {
+          this.error = true
+          this.errorMsg = '文章不存在或无权限查看'
+        }
+      } catch (e) {
+        this.error = true
+        this.errorMsg = '加载失败,请稍后重试'
+      } finally {
+        this.loading = false
+      }
+    },
+    startTimer() {
+      var self = this
+      this.readingTimer = setInterval(function() {
+        self.readingSeconds++
+      }, 1000)
+    },
+    stopTimer() {
+      if (this.readingTimer) {
+        clearInterval(this.readingTimer)
+        this.readingTimer = null
+      }
+    },
+    formatTime(seconds) {
+      var m = Math.floor(seconds / 60)
+      var s = seconds % 60
+      return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.slice(0, 10)
+    },
+    parseDimensions(str) {
+      if (!str) return []
+      var dimMap = {
+        body: { code: 'body', color: '#FF8C42', name: '身', weight: 20 },
+        mind: { code: 'mind', color: '#6366F1', name: '智', weight: 20 },
+        wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心', weight: 20 },
+        action: { code: 'action', color: '#10B981', name: '行', weight: 20 },
+        wealth: { code: 'wealth', color: '#F59E0B', name: '富', weight: 20 }
+      }
+      var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
+      return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
+    },
+    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() {
+      this.stopTimer()
+      try {
+        var childId = uni.getStorageSync('currentChildId')
+        await recordArticleRead({
+          id: this.article.id,
+          durationSeconds: this.readingSeconds,
+          childId: childId || undefined
+        })
+      } catch (e) {
+        // 阅读记录失败不影响出题
+      }
+      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; }
+.reading-timer { display: flex; align-items: center; margin-right: 20rpx; }
+.timer-icon { font-size: 32rpx; margin-right: 6rpx; }
+.timer-text { font-size: 28rpx; font-weight: bold; color: #333; }
+.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; }
+.read-btn-ready { background: linear-gradient(135deg, #5B9BD5, #3A7CC4); }
+.read-btn[disabled] { opacity: 0.5; }
+.read-btn::after { border: none; }
+/* 答题区 */
+.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>