article-detail.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. <template>
  2. <view class="detail-container">
  3. <view v-if="loading" class="loading-wrap">
  4. <view class="loading-spinner"></view>
  5. <text class="loading-text">加载中...</text>
  6. </view>
  7. <view v-else-if="error" class="error-wrap">
  8. <text class="error-icon">📄</text>
  9. <text class="error-text">{{ errorMsg }}</text>
  10. <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
  11. </view>
  12. <template v-else-if="article">
  13. <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
  14. <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
  15. <text class="detail-title">{{ article.title }}</text>
  16. <view class="detail-meta">
  17. <text class="meta-author">{{ article.author || '浠艾福' }}</text>
  18. <text class="meta-sep">|</text>
  19. <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
  20. <text class="meta-sep">|</text>
  21. <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
  22. <text class="reading-time-badge" v-if="!readingCompleted">{{ formatReadingTime(readingSeconds) }}</text>
  23. <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
  24. </view>
  25. <view class="detail-category-row">
  26. <text class="detail-category">{{ article.categoryName || '' }}</text>
  27. </view>
  28. <view v-if="article.relatedDimensions" class="detail-dimensions">
  29. <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
  30. <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
  31. <text class="dim-label">{{ dim.name }}</text>
  32. </view>
  33. </view>
  34. <view class="divider"></view>
  35. <view class="detail-body"><rich-text :nodes="article.content"></rich-text></view>
  36. <!-- 评论区 -->
  37. <view class="comment-section">
  38. <view class="comment-header">
  39. <text class="comment-title">评论 ({{ comments.length }})</text>
  40. </view>
  41. <view class="comment-input-row">
  42. <input class="comment-input" v-model="commentText" :placeholder="commentPlaceholder" confirm-type="send" @confirm="submitComment" />
  43. <button class="comment-submit" @click="submitComment">发送</button>
  44. </view>
  45. <view v-if="comments.length === 0" class="comment-empty">暂无评论,来写第一条吧</view>
  46. <view v-for="(c, i) in comments" :key="getCommentKey(c, i)" class="comment-item">
  47. <view class="comment-user">
  48. <text class="comment-avatar">{{ (c.userName || '?').charAt(0) }}</text>
  49. <text class="comment-name">{{ c.userName || '匿名' }}</text>
  50. <text class="comment-time">{{ formatDate(c.createdAt) }}</text>
  51. </view>
  52. <view class="comment-content">{{ c.content }}</view>
  53. <view class="comment-actions">
  54. <text class="comment-reply-btn" @click="startReply(c)">回复</text>
  55. </view>
  56. <!-- 回复列表 -->
  57. <view class="reply-list" v-if="c.replies && c.replies.length > 0">
  58. <view v-for="(r, ri) in c.replies" :key="ri" class="reply-item">
  59. <view class="reply-user">
  60. <text class="reply-avatar">{{ (r.userName || '?').charAt(0) }}</text>
  61. <text class="reply-name">{{ r.userName || '匿名' }}</text>
  62. <text class="reply-time">{{ formatDate(r.createdAt) }}</text>
  63. <text class="reply-to" v-if="r.replyToId">回复</text>
  64. </view>
  65. <view class="reply-content">{{ r.content }}</view>
  66. </view>
  67. </view>
  68. </view>
  69. </view>
  70. <!-- 作者审核区 -->
  71. <view class="review-section" v-if="isArticleAuthor && hasPendingComments">
  72. <view class="review-header">
  73. <text class="review-title">待审核评论 ({{ pendingComments.length }})</text>
  74. </view>
  75. <view v-for="(c, i) in pendingComments" :key="getPendingKey(i)" class="review-item">
  76. <view class="review-user">
  77. <text class="review-avatar">{{ (c.userName || '?').charAt(0) }}</text>
  78. <text class="review-name">{{ c.userName || '匿名' }}</text>
  79. <text class="review-time">{{ formatDate(c.createdAt) }}</text>
  80. </view>
  81. <view class="review-content">{{ c.content }}</view>
  82. <view class="review-actions">
  83. <button class="review-btn approve" @click="approveComment(c.id)">通过</button>
  84. <button class="review-btn reject" @click="rejectComment(c.id)">驳回</button>
  85. </view>
  86. </view>
  87. </view>
  88. <view style="height: 200rpx;"></view>
  89. </scroll-view>
  90. <!-- 底部 -->
  91. <view class="detail-footer">
  92. <button class="share-btn" open-type="share" @click="onShareTap">
  93. <text class="share-btn-icon">📤</text>
  94. <text class="share-btn-text">分享</text>
  95. </button>
  96. <view class="share-btn" @click="generatePoster">
  97. <text class="share-btn-icon">🖼</text>
  98. <text class="share-btn-text">海报</text>
  99. </view>
  100. </view>
  101. </template>
  102. <ContentSharePoster :show="showPoster" :title="article && article.title" :coverImage="article && article.coverImage" :qrCodeBase64="posterQrCode" typeLabel="好文推荐" @close="closePoster" />
  103. <!-- 浠宝答题弹窗 -->
  104. <view class="quiz-mask" v-if="showQuiz" @click="closeQuiz">
  105. <view class="quiz-dialog" @click.stop>
  106. <view class="quiz-mascot">
  107. <text class="mascot-icon">{{ mascotIcon }}</text>
  108. <text class="mascot-name">{{ mascotName }}</text>
  109. </view>
  110. <text class="quiz-intro">阅读完成!考考你三个问题~</text>
  111. <view class="quiz-question" v-for="(q, qi) in quizQuestions" :key="qi">
  112. <text class="q-title">问题{{ qi+1 }}: {{ q.question }}</text>
  113. <view class="q-options">
  114. <text v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ selected: quizAnswers[qi] === String.fromCharCode(65 + oi) }" @click="selectQuizAnswer(qi, oi)">{{ opt }}</text>
  115. </view>
  116. </view>
  117. <button class="quiz-submit" @click="submitQuiz">提交答案</button>
  118. </view>
  119. </view>
  120. <!-- 答题结果 -->
  121. <view class="result-mask" v-if="showResult">
  122. <view class="result-dialog">
  123. <text class="result-icon">{{ resultIcon }}</text>
  124. <text class="result-text">答对 {{ quizResult.correctCount }}/{{ quizResult.totalQuestions }} 题</text>
  125. <text class="result-energy">获得 {{ quizResult.earnedEnergy }} 能量 ⚡</text>
  126. <button class="result-btn" @click="closeResult">知道了</button>
  127. </view>
  128. </view>
  129. </view>
  130. </template>
  131. <script>
  132. import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, getArticleComments, createArticleComment, generateQuiz, submitQuiz, getPendingComments, auditArticleComment } from '@/utils/api.js'
  133. import shareMixin from '../../components/share-mixin.js'
  134. import ContentSharePoster from '@/components/ContentSharePoster.vue'
  135. export default {
  136. mixins: [shareMixin],
  137. components: { ContentSharePoster },
  138. data() {
  139. return {
  140. articleId: '', article: null, loading: true, error: false, errorMsg: '',
  141. readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
  142. isTimerRunning: false, timerHandle: null, syncHandle: null,
  143. showPoster: false, posterQrCode: '',
  144. // 评论
  145. comments: [], commentText: '', replyTarget: null,
  146. // 作者审核
  147. pendingComments: [], currentUserId: null, currentRole: null,
  148. // 作者审核
  149. async loadPendingComments() {
  150. try {
  151. var res = await getPendingComments({ articleId: this.articleId })
  152. if (res.code === 200) { this.pendingComments = res.data || [] }
  153. } catch (e) {}
  154. },
  155. async approveComment(commentId) {
  156. try {
  157. var res = await auditArticleComment({ commentId: commentId, status: 'approved' })
  158. if (res.code === 200) {
  159. uni.showToast({ title: '已通过', icon: 'success' })
  160. this.pendingComments = this.pendingComments.filter(function(c) { return c.id !== commentId })
  161. } else {
  162. uni.showToast({ title: res.message || '操作失败', icon: 'none' })
  163. }
  164. } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
  165. },
  166. async rejectComment(commentId) {
  167. try {
  168. var res = await auditArticleComment({ commentId: commentId, status: 'rejected' })
  169. if (res.code === 200) {
  170. uni.showToast({ title: '已驳回', icon: 'success' })
  171. this.pendingComments = this.pendingComments.filter(function(c) { return c.id !== commentId })
  172. } else {
  173. uni.showToast({ title: res.message || '操作失败', icon: 'none' })
  174. }
  175. } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
  176. },
  177. // 答题
  178. showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {},
  179. mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
  180. mascotIcon: '🐶', mascotName: '浠宝'
  181. }
  182. },
  183. computed: {
  184. commentPlaceholder: function() {
  185. return this.replyTarget ? '回复 ' + (this.replyTarget.userName || '') + ':' : '写下你的评论...'
  186. },
  187. isArticleAuthor: function() {
  188. return this.article && this.currentUserId && this.article.createdBy === this.currentUserId
  189. },
  190. hasPendingComments: function() {
  191. return this.pendingComments && this.pendingComments.length > 0
  192. }
  193. },
  194. onLoad(options) {
  195. this.mascotIcon = this.mascotList[Math.floor(Math.random() * 2)].icon
  196. this.mascotName = this.mascotList[Math.floor(Math.random() * 2)].name
  197. this.currentUserId = parseInt(uni.getStorageSync('userId') || 0)
  198. this.currentRole = uni.getStorageSync('currentRole') || ''
  199. if (options && options.id) { this.articleId = options.id; this.loadDetail(options.id); this.loadComments() }
  200. else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
  201. },
  202. onShow() { if (this.article && !this.error) this.startReadingTimer() },
  203. onHide() { this.pauseReadingTimer() },
  204. onUnload() { this.pauseReadingTimer() },
  205. methods: {
  206. async loadDetail(id) {
  207. this.loading = true; this.error = false
  208. try {
  209. var res = await getArticleDetail({ id: id })
  210. if (res.code === 200 && res.data) {
  211. this.article = res.data
  212. this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
  213. this.startReadingTimer()
  214. // 如果当前用户是文章作者,加载待审核评论
  215. if (this.currentUserId && res.data.createdBy === this.currentUserId) {
  216. this.loadPendingComments()
  217. }
  218. } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
  219. } catch (e) { this.error = true; this.errorMsg = '加载失败' }
  220. finally { this.loading = false }
  221. },
  222. async loadComments() {
  223. try { var res = await getArticleComments({ articleId: this.articleId }); if (res.code === 200) this.comments = res.data || [] }
  224. catch (e) {}
  225. },
  226. formatDate(d) { return d ? d.slice(0, 10) : '' },
  227. parseDimensions: function(article) {
  228. 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: '富' } }
  229. var raw = article && article.relatedDimensions ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() }) : []
  230. var weights = null
  231. if (article && article.dimensionWeights) try { var w = JSON.parse(article.dimensionWeights); if (w && typeof w === 'object') weights = w } catch (e) {}
  232. if (!weights) {
  233. var n = raw.filter(function(c) { return dimMap[c] }).length
  234. 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++ }) }
  235. }
  236. 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 } })
  237. },
  238. onScrollToBottom() {},
  239. startReadingTimer: function() {
  240. if (this.isTimerRunning || this.readingCompleted) return
  241. this.isTimerRunning = true
  242. var self = this
  243. this.timerHandle = setInterval(function() { self.readingSeconds++ }, 1000)
  244. this.syncHandle = setInterval(function() { self.reportReadingTime() }, 30000)
  245. },
  246. pauseReadingTimer: function() {
  247. if (!this.isTimerRunning) return
  248. this.isTimerRunning = false
  249. if (this.timerHandle) { clearInterval(this.timerHandle); this.timerHandle = null }
  250. if (this.syncHandle) { clearInterval(this.syncHandle); this.syncHandle = null }
  251. this.reportReadingTime()
  252. },
  253. async reportReadingTime() {
  254. var delta = this.readingSeconds - this.lastReportedSeconds
  255. if (delta <= 0) return
  256. this.lastReportedSeconds = this.readingSeconds
  257. var childId = uni.getStorageSync('currentChildId')
  258. if (!childId || !this.article) return
  259. reportReadingTime({ articleId: this.article.id, childId: parseInt(childId), durationSeconds: delta })
  260. // 达到阅读时长自动完成
  261. var targetSeconds = (this.article.readTime || 3) * 60
  262. if (!this.readingCompleted && this.readingSeconds >= targetSeconds) {
  263. this.readingCompleted = true
  264. this.pauseReadingTimer()
  265. try { await completeArticleRead({ articleId: this.article.id, childId: parseInt(childId), durationSeconds: this.readingSeconds }) } catch (e) {}
  266. uni.showToast({ title: '阅读完成 +5能量', icon: 'success' })
  267. // 弹出答题
  268. this.startQuiz()
  269. }
  270. },
  271. formatReadingTime: function(seconds) {
  272. if (seconds < 60) return '已读 ' + seconds + '秒'
  273. var min = Math.floor(seconds / 60); var sec = seconds % 60
  274. return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
  275. },
  276. async generatePoster() {
  277. if (!this.articleId) return
  278. uni.showLoading({ title: '生成海报中...' })
  279. try {
  280. var res = await getShareQrCode('pages/article-center/article-detail', 'id=' + this.articleId)
  281. if (res && res.data) { this.posterQrCode = res.data.qrCodeBase64 || ''; this.showPoster = true }
  282. } catch (e) { uni.showToast({ title: '生成失败', icon: 'none' }) }
  283. finally { uni.hideLoading() }
  284. },
  285. // 评论
  286. startReply: function(comment) {
  287. this.replyTarget = comment
  288. this.commentText = ''
  289. uni.pageScrollTo({ selector: '.comment-input-row', duration: 300 })
  290. },
  291. async submitComment() {
  292. var text = this.commentText.trim()
  293. if (!text) return
  294. try {
  295. var params = { articleId: this.articleId, content: text }
  296. if (this.replyTarget) { params.parentId = this.replyTarget.id; params.replyToId = this.replyTarget.id }
  297. var res = await createArticleComment(params)
  298. if (res.code === 200) {
  299. uni.showToast({ title: '评论已提交,等待审核', icon: 'success' })
  300. this.commentText = ''; this.replyTarget = null
  301. } else { uni.showToast({ title: res.message || '评论失败', icon: 'none' }) }
  302. } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
  303. },
  304. // 答题
  305. async startQuiz() {
  306. try {
  307. var res = await generateQuiz({ articleId: this.articleId })
  308. if (res.code === 200 && res.data && res.data.length > 0) {
  309. this.quizQuestions = res.data
  310. this.quizAnswers = []
  311. this.showQuiz = true
  312. }
  313. } catch (e) {}
  314. },
  315. async submitQuiz() {
  316. var correct = 0
  317. for (var i = 0; i < this.quizQuestions.length; i++) {
  318. if (this.quizAnswers[i] === this.quizQuestions[i].answer) correct++
  319. }
  320. var childId = uni.getStorageSync('currentChildId')
  321. try {
  322. var res = await submitQuiz({ correctCount: correct, childId: parseInt(childId || 0) })
  323. if (res.code === 200) {
  324. this.quizResult = res.data || { correctCount: correct, totalQuestions: 3, earnedEnergy: correct * 5 }
  325. this.showQuiz = false
  326. this.showResult = true
  327. }
  328. } catch (e) {}
  329. },
  330. getCommentKey: function(c, i) {
  331. return c.id || i
  332. },
  333. getPendingKey: function(i) {
  334. return 'p' + i
  335. },
  336. closePoster: function() {
  337. this.showPoster = false
  338. },
  339. closeQuiz: function() {
  340. this.showQuiz = false
  341. },
  342. closeResult: function() {
  343. this.showResult = false
  344. },
  345. selectQuizAnswer: function(qi, oi) {
  346. this.quizAnswers[qi] = String.fromCharCode(65 + oi)
  347. }
  348. }
  349. }
  350. </script>
  351. <style scoped>
  352. .detail-container { min-height: 100vh; background: #fff; }
  353. .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  354. .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; }
  355. @keyframes spin { 0% { transform: rotate(0deg); } 360% { transform: rotate(360deg); } }
  356. .loading-text { font-size: 26rpx; color: #999; }
  357. .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
  358. .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
  359. .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
  360. .retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
  361. .content-scroll { height: calc(100vh - 120rpx); }
  362. .detail-cover { width: 100%; display: block; }
  363. .detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
  364. .detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; flex-wrap: wrap; }
  365. .meta-author { color: #5B9BD5; }
  366. .meta-sep { margin: 0 12rpx; color: #ddd; }
  367. .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; }
  368. .reading-time-badge.completed { color: #10B981; background: rgba(16,185,129,0.1); }
  369. .detail-category-row { padding: 16rpx 30rpx 0; }
  370. .detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
  371. .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
  372. .dim-bar-item { flex: 1; }
  373. .dim-bar { height: 8rpx; border-radius: 4rpx; }
  374. .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
  375. .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
  376. .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
  377. /* 评论区 */
  378. .comment-section { padding: 30rpx; border-top: 16rpx solid #F5F7FA; }
  379. .comment-header { margin-bottom: 20rpx; }
  380. .comment-title { font-size: 30rpx; font-weight: 700; color: #333; }
  381. .comment-input-row { display: flex; gap: 12rpx; margin-bottom: 24rpx; }
  382. .comment-input { flex: 1; border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 16rpx 20rpx; font-size: 26rpx; height: 72rpx; box-sizing: border-box; }
  383. .comment-submit { width: 120rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 26rpx; border-radius: 12rpx; text-align: center; border: none; }
  384. .comment-empty { text-align: center; padding: 40rpx 0; color: #999; font-size: 26rpx; }
  385. .comment-item { padding: 20rpx 0; border-bottom: 2rpx solid #F5F5F5; }
  386. .comment-user { display: flex; align-items: center; margin-bottom: 8rpx; }
  387. .comment-avatar { width: 40rpx; height: 40rpx; border-radius: 50%; background: #5B9BD5; color: #fff; font-size: 20rpx; text-align: center; line-height: 40rpx; margin-right: 12rpx; }
  388. .comment-name { font-size: 26rpx; color: #333; font-weight: 500; }
  389. .comment-time { font-size: 20rpx; color: #999; margin-left: 12rpx; }
  390. .comment-content { font-size: 26rpx; color: #444; line-height: 1.5; padding-left: 52rpx; }
  391. .comment-actions { padding-left: 52rpx; margin-top: 8rpx; }
  392. .comment-reply-btn { font-size: 22rpx; color: #5B9BD5; }
  393. .reply-list { padding-left: 52rpx; margin-top: 12rpx; background: #FAFAFA; border-radius: 8rpx; padding: 12rpx; }
  394. .reply-item { padding: 8rpx 0; }
  395. .reply-user { display: flex; align-items: center; }
  396. .reply-avatar { width: 32rpx; height: 32rpx; border-radius: 50%; background: #94A3B8; color: #fff; font-size: 16rpx; text-align: center; line-height: 32rpx; margin-right: 8rpx; }
  397. .reply-name { font-size: 24rpx; color: #333; }
  398. .reply-time { font-size: 18rpx; color: #999; margin-left: 8rpx; }
  399. .reply-to { font-size: 18rpx; color: #5B9BD5; margin-left: 8rpx; }
  400. .reply-content { font-size: 24rpx; color: #444; padding-left: 40rpx; margin-top: 4rpx; }
  401. /* 作者审核区 */
  402. .review-section { padding: 30rpx; border-top: 16rpx solid #FFF3E0; background: #FFFBF5; }
  403. .review-header { margin-bottom: 16rpx; }
  404. .review-title { font-size: 28rpx; font-weight: 700; color: #E65100; }
  405. .review-item { padding: 16rpx 0; border-bottom: 2rpx solid #F5F5F5; }
  406. .review-user { display: flex; align-items: center; margin-bottom: 8rpx; }
  407. .review-avatar { width: 40rpx; height: 40rpx; border-radius: 50%; background: #E65100; color: #fff; font-size: 20rpx; text-align: center; line-height: 40rpx; margin-right: 12rpx; }
  408. .review-name { font-size: 26rpx; color: #333; font-weight: 500; }
  409. .review-time { font-size: 20rpx; color: #999; margin-left: 12rpx; }
  410. .review-content { font-size: 26rpx; color: #444; line-height: 1.5; padding-left: 52rpx; }
  411. .review-actions { display: flex; gap: 16rpx; padding-left: 52rpx; margin-top: 8rpx; }
  412. .review-btn { height: 56rpx; line-height: 56rpx; font-size: 24rpx; border-radius: 28rpx; padding: 0 24rpx; border: none; min-width: 100rpx; text-align: center; }
  413. .review-btn.approve { background: #10B981; color: #fff; }
  414. .review-btn.reject { background: #EF4444; color: #fff; }
  415. .review-btn::after { border: none; }
  416. .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
  417. .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; }
  418. .share-btn::after { border: none; }
  419. .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
  420. .share-btn-text { font-size: 24rpx; }
  421. /* 答题弹窗 */
  422. .quiz-mask, .result-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; display: flex; align-items: center; justify-content: center; }
  423. .quiz-dialog, .result-dialog { background: #fff; border-radius: 24rpx; width: 650rpx; padding: 32rpx; }
  424. .quiz-mascot { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 16rpx; }
  425. .mascot-icon { font-size: 60rpx; }
  426. .mascot-name { font-size: 32rpx; font-weight: 700; color: #F97316; }
  427. .quiz-intro { text-align: center; font-size: 28rpx; color: #666; margin-bottom: 24rpx; }
  428. .quiz-question { margin-bottom: 20rpx; }
  429. .q-title { font-size: 26rpx; font-weight: 600; color: #333; margin-bottom: 12rpx; }
  430. .q-options { display: flex; flex-direction: column; gap: 8rpx; }
  431. .q-option { padding: 14rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; font-size: 24rpx; color: #333; }
  432. .q-option.selected { border-color: #F97316; background: #FFF7ED; color: #92400E; }
  433. .quiz-submit, .result-btn { width: 100%; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; margin-top: 20rpx; }
  434. .result-dialog { text-align: center; }
  435. .result-icon { font-size: 80rpx; }
  436. .result-text { font-size: 32rpx; font-weight: 700; color: #333; margin: 16rpx 0; }
  437. .result-energy { font-size: 28rpx; color: #F97316; }
  438. </style>