child-index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <template>
  2. <view class="container">
  3. <!-- 空状态:没有孩子 -->
  4. <view class="empty-state" v-if="!hasChildren">
  5. <view class="empty-icon">👶</view>
  6. <text class="empty-title">还没有孩子信息</text>
  7. <text class="empty-desc">请先添加孩子,开始任务管理之旅</text>
  8. <button class="btn-add-child" @click="goToAddChild">添加孩子</button>
  9. <button class="btn-switch" @click="switchMode">切换回家长模式</button>
  10. </view>
  11. <!-- 有孩子时显示正常内容 -->
  12. <PlayfulCard v-else elevation="1">
  13. <!-- 欢迎区域 -->
  14. <view class="welcome-section">
  15. <view class="avatar-container">
  16. <view class="avatar-glow"></view>
  17. <image class="avatar" :src="avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
  18. <view class="level-badge" v-if="level">{{ level }}</view>
  19. </view>
  20. <view class="welcome-text">
  21. <text class="hello">你好,{{ nickname || '小朋友' }} 👋</text>
  22. <text class="subtitle">{{ getGreeting() }}</text>
  23. </view>
  24. <view class="streak-badge" v-if="streakDays > 0" @click="showStreakDetail">
  25. <text class="fire">🔥</text>
  26. <text class="days">{{ streakDays }}天</text>
  27. <text class="streak-label">连续打卡</text>
  28. </view>
  29. </view>
  30. <!-- 打卡里程碑进度条 -->
  31. <view class="milestone-bar" v-if="streakProgress && streakProgress.nextMilestone">
  32. <view class="milestone-info">
  33. <text class="milestone-label">距{{ streakProgress.nextMilestone }}天里程碑</text>
  34. <text class="milestone-progress">{{ streakProgress.daysToNext }}天后达标</text>
  35. </view>
  36. <view class="milestone-progress-bar">
  37. <view class="milestone-fill" :style="{ width: getMilestoneProgress() + '%' }"></view>
  38. </view>
  39. </view>
  40. <!-- 积分进度环 -->
  41. <view class="ring-section">
  42. <PointsProgressRing :points="totalPoints" :currentMilestone="totalPoints % 100" :nextMilestone="100" />
  43. </view>
  44. <!-- 积分卡片 -->
  45. <view class="points-card">
  46. <view class="points-main">
  47. <view class="points-label">我的积分</view>
  48. <view class="points-value">{{ totalPoints }} <text class="unit">分</text></view>
  49. </view>
  50. <view class="points-actions">
  51. <view class="action-btn" @click="goToRewards">
  52. <text class="action-icon">🎁</text>
  53. <text class="action-text">兑换</text>
  54. </view>
  55. <view class="action-btn" @click="showPointsHistory">
  56. <text class="action-icon">📊</text>
  57. <text class="action-text">明细</text>
  58. </view>
  59. </view>
  60. <view class="points-expire" v-if="expireDate">📅 有效期至 {{ expireDate }}</view>
  61. </view>
  62. <!-- 今日任务概览 -->
  63. <view class="section">
  64. <view class="section-header">
  65. <view class="section-title">📝 今日任务</view>
  66. <view class="progress-bar">
  67. <view class="progress-fill" :style="{ width: taskProgress + '%' }"></view>
  68. </view>
  69. <text class="progress-text">{{ completedCount }}/{{ totalTaskCount }}</text>
  70. </view>
  71. <view class="task-summary">
  72. <view class="summary-item completed">
  73. <text class="emoji">✅</text>
  74. <text class="num">{{ completedCount }}</text>
  75. <text class="label">已完成</text>
  76. </view>
  77. <view class="summary-item pending">
  78. <text class="emoji">⏳</text>
  79. <text class="num">{{ pendingCount }}</text>
  80. <text class="label">待完成</text>
  81. </view>
  82. <view class="summary-item points-today">
  83. <text class="emoji">⭐</text>
  84. <text class="num">+{{ todayPoints }}</text>
  85. <text class="label">今日获得</text>
  86. </view>
  87. </view>
  88. <button class="btn-primary view-all-btn" @click="goToTasks">
  89. <text>🚀</text> 查看全部任务
  90. </button>
  91. </view>
  92. <!-- 快捷操作 -->
  93. <view class="section">
  94. <view class="section-title">⚡ 快捷操作</view>
  95. <view class="quick-actions">
  96. <view class="action-item" @click="goToRewards">
  97. <view class="action-icon-wrapper reward">🎁</view>
  98. <text class="label">心愿单</text>
  99. </view>
  100. <view class="action-item" @click="goToFocus">
  101. <view class="action-icon-wrapper focus">⏱️</view>
  102. <text class="label">专注训练</text>
  103. </view>
  104. <view class="action-item" @click="showAchievements">
  105. <view class="action-icon-wrapper trophy">🏆</view>
  106. <text class="label">成就</text>
  107. </view>
  108. <view class="action-item" @click="switchMode">
  109. <view class="action-icon-wrapper switch">🔄</view>
  110. <text class="label">切换</text>
  111. </view>
  112. </view>
  113. </view>
  114. <!-- 趣味提示 -->
  115. <view class="tip-card" v-if="tip">
  116. <text class="tip-icon">💡</text>
  117. <text class="tip-text">{{ tip }}</text>
  118. </view>
  119. <ConfettiCelebration :show="showConfetti" :duration="3000" />
  120. </PlayfulCard>
  121. </view>
  122. </template>
  123. <script>
  124. import PlayfulCard from '@/components/PlayfulCard.vue'
  125. import PlayfulButton from '@/components/PlayfulButton.vue'
  126. import ConfettiCelebration from '@/components/ConfettiCelebration.vue'
  127. import PointsProgressRing from '@/components/PointsProgressRing.vue'
  128. import { getTodayTasks, getPointsBalance, getChildren, switchBackToParent, verifyPassword, getStreakProgress } from '../../utils/api.js'
  129. export default {
  130. components: { PlayfulCard, PlayfulButton, ConfettiCelebration, PointsProgressRing },
  131. data() {
  132. return {
  133. hasChildren: true, // 默认有孩子,加载后更新
  134. nickname: '',
  135. avatar: '',
  136. totalPoints: 0,
  137. todayPoints: 0,
  138. streakDays: 0,
  139. streakProgress: null,
  140. expireDate: '',
  141. completedCount: 0,
  142. pendingCount: 0,
  143. currentChildId: '',
  144. showConfetti: false,
  145. level: '',
  146. tip: ''
  147. }
  148. },
  149. computed: {
  150. totalTaskCount() { return this.completedCount + this.pendingCount },
  151. taskProgress() {
  152. if (this.totalTaskCount === 0) return 0
  153. return (this.completedCount / this.totalTaskCount) * 100
  154. }
  155. },
  156. onLoad() { this.checkLogin() },
  157. onShow() {
  158. if (uni.getStorageSync('token')) this.loadData()
  159. },
  160. methods: {
  161. checkLogin() {
  162. if (!uni.getStorageSync('token')) {
  163. uni.reLaunch({ url: '/pages/login/login' })
  164. }
  165. },
  166. async loadData() {
  167. try {
  168. const childrenRes = await getChildren()
  169. const children = childrenRes.data
  170. if (children && children.length > 0) {
  171. this.hasChildren = true
  172. const child = children[0]
  173. this.currentChildId = child.id
  174. this.nickname = child.nickname
  175. this.avatar = child.avatar
  176. this.totalPoints = child.totalPoints || 0
  177. this.streakDays = child.streakDays || 0
  178. const balanceRes = await getPointsBalance(child.id)
  179. this.totalPoints = balanceRes.data.balance
  180. this.expireDate = this.formatDate(balanceRes.data.expireDate)
  181. const tasksRes = await getTodayTasks(child.id)
  182. const tasks = tasksRes.data
  183. this.completedCount = tasks.filter(t => t.status === 'completed').length
  184. this.pendingCount = tasks.filter(t => t.status === 'pending').length
  185. this.todayPoints = tasks.filter(t => t.status === 'completed').reduce((sum, t) => sum + (t.points || 0), 0)
  186. this.calculateLevel()
  187. this.setRandomTip()
  188. // 加载打卡进度
  189. this.loadStreakProgress(child.id)
  190. } else {
  191. // 没有孩子,显示空状态
  192. this.hasChildren = false
  193. }
  194. } catch (e) {
  195. console.error('加载数据失败', e)
  196. this.hasChildren = false
  197. }
  198. },
  199. async loadStreakProgress(childId) {
  200. try {
  201. const res = await getStreakProgress(childId)
  202. if (res.data) {
  203. this.streakProgress = res.data
  204. }
  205. } catch (e) { console.error('加载打卡进度失败', e) }
  206. },
  207. getMilestoneProgress() {
  208. if (!this.streakProgress || !this.streakProgress.nextMilestone) return 0
  209. const current = this.streakDays || 0
  210. const next = this.streakProgress.nextMilestone
  211. // 计算距离下一个里程碑的进度
  212. // 找到上一个已完成的里程碑
  213. const milestones = this.streakProgress.milestones || []
  214. let prevAchieved = 0
  215. for (let m of milestones) {
  216. if (m.achieved) prevAchieved = m.days
  217. }
  218. const total = next - prevAchieved
  219. const progress = current - prevAchieved
  220. return Math.min(100, Math.max(0, (progress / total) * 100))
  221. },
  222. calculateLevel() {
  223. const levels = ['🌱', '🌿', '🌳', '⭐', '🌟', '💫', '🏆', '👑']
  224. this.level = levels[Math.min(Math.floor(this.totalPoints / 100), levels.length - 1)]
  225. },
  226. setRandomTip() {
  227. const tips = ['坚持就是胜利!今天也要加油哦 💪', '完成任务可以获得更多积分 🎯', '连续打卡可以获得额外奖励 🌟', '今天也要做一个自律的小朋友!', '加油!你是最棒的! 🚀']
  228. this.tip = tips[Math.floor(Math.random() * tips.length)]
  229. },
  230. getGreeting() {
  231. const hour = new Date().getHours()
  232. if (hour < 12) return '早上好!今天也要元气满满 🌞'
  233. if (hour < 18) return '下午好!继续加油哦 ⛅'
  234. return '晚上好!辛苦啦 🎉'
  235. },
  236. formatDate(dateStr) {
  237. if (!dateStr) return ''
  238. const date = new Date(dateStr)
  239. return `${date.getMonth() + 1}月${date.getDate()}日`
  240. },
  241. goToTasks() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
  242. goToRewards() { uni.switchTab({ url: '/pages/rewards/rewards' }) },
  243. goToFocus() { uni.navigateTo({ url: '/pages/focus/focus' }) },
  244. showPointsHistory() { uni.showToast({ title: '积分明细功能开发中', icon: 'none' }) },
  245. showAchievements() { uni.showToast({ title: '成就功能开发中', icon: 'none' }) },
  246. showStreakDetail() {
  247. if (!this.streakProgress) {
  248. uni.showModal({ title: '连续打卡', content: `你已经连续打卡 ${this.streakDays} 天了!太棒了!🎉`, showCancel: false })
  249. return
  250. }
  251. const milestones = this.streakProgress.milestones || []
  252. let content = `连续打卡 ${this.streakDays} 天\n\n📋 里程碑进度:\n`
  253. milestones.forEach(m => {
  254. const status = m.achieved ? '✅' : '⭕'
  255. const reward = m.rewardPoints > 0 ? ` (+${m.rewardPoints}分)` : ''
  256. content += `${status} ${m.days}天${reward}\n`
  257. })
  258. if (this.streakProgress.nextMilestone) {
  259. content += `\n🎯 距下一里程碑: ${this.streakProgress.daysToNext} 天`
  260. }
  261. uni.showModal({ title: '🔥 连续打卡', content: content, showCancel: false })
  262. },
  263. switchMode() {
  264. // 弹出密码输入框
  265. uni.showModal({
  266. title: '切换模式',
  267. content: '请输入家长密码以确认切换',
  268. editable: true,
  269. success: async (res) => {
  270. if (res.confirm && res.content) {
  271. // 验证密码
  272. try {
  273. const verifyResult = await verifyPassword(res.content)
  274. if (verifyResult.code === 200) {
  275. await this.switchBackToParentMode()
  276. } else {
  277. uni.showToast({ title: '密码错误', icon: 'none' })
  278. }
  279. } catch (e) {
  280. uni.showToast({ title: '密码验证失败', icon: 'none' })
  281. }
  282. }
  283. }
  284. })
  285. },
  286. async switchBackToParentMode() {
  287. try {
  288. const result = await switchBackToParent()
  289. if (result.data) {
  290. uni.showToast({ title: '已切换回家长模式', icon: 'success' })
  291. uni.reLaunch({ url: '/pages/index/parent-index' })
  292. }
  293. } catch (e) { uni.showToast({ title: '切换失败', icon: 'none' }) }
  294. },
  295. triggerConfetti() {
  296. this.showConfetti = true
  297. setTimeout(() => { this.showConfetti = false }, 3000)
  298. },
  299. goToAddChild() {
  300. uni.navigateTo({ url: '/pages/profile/create-child' })
  301. }
  302. }
  303. }
  304. </script>
  305. <style scoped>
  306. .container { padding: 30rpx; }
  307. .welcome-section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30rpx; flex-wrap: wrap; }
  308. .avatar-container { position: relative; width: 120rpx; height: 120rpx; }
  309. .avatar-glow { position: absolute; width: 120rpx; height: 120rpx; border-radius: 50%; background: linear-gradient(135deg, #FFD700, #FFA500); animation: pulse 2s infinite; }
  310. .avatar { width: 120rpx; height: 120rpx; border-radius: 50%; border: 4rpx solid #fff; position: relative; z-index: 1; }
  311. .level-badge { position: absolute; bottom: -10rpx; left: 50%; transform: translateX(-50%); font-size: 24rpx; }
  312. @keyframes pulse { 0%, 100% { opacity: 0.5; transform: scale(1); } 50% { opacity: 0.8; transform: scale(1.1); } }
  313. .welcome-text { flex: 1; margin-left: 20rpx; }
  314. .hello { font-size: 36rpx; font-weight: bold; color: #333; display: block; }
  315. .subtitle { font-size: 26rpx; color: #666; }
  316. .streak-badge { display: flex; align-items: center; background: linear-gradient(135deg, #FF6B6B, #FF8E53); padding: 10rpx 20rpx; border-radius: 30rpx; }
  317. .streak-label { color: #fff; font-size: 20rpx; margin-left: 8rpx; }
  318. .ring-section { display: flex; justify-content: center; margin: 30rpx 0; }
  319. .points-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20rpx; padding: 40rpx; text-align: center; margin-bottom: 30rpx; }
  320. .points-main { margin-bottom: 20rpx; }
  321. .points-label { color: rgba(255, 255, 255, 0.8); font-size: 26rpx; }
  322. .points-value { color: #fff; font-size: 72rpx; font-weight: bold; }
  323. .unit { font-size: 32rpx; }
  324. .points-actions { display: flex; justify-content: center; gap: 40rpx; margin-bottom: 20rpx; }
  325. .action-btn { display: flex; align-items: center; background: rgba(255,255,255,0.2); padding: 10rpx 30rpx; border-radius: 30rpx; }
  326. .action-icon { font-size: 32rpx; margin-right: 10rpx; }
  327. .action-text { color: #fff; font-size: 24rpx; }
  328. .points-expire { color: rgba(255, 255, 255, 0.6); font-size: 22rpx; }
  329. .section { margin-bottom: 30rpx; }
  330. .section-header { display: flex; align-items: center; margin-bottom: 20rpx; flex-wrap: wrap; }
  331. .section-title { font-size: 32rpx; font-weight: bold; color: #333; margin-right: 20rpx; }
  332. .progress-bar { flex: 1; height: 16rpx; background: #E8F4FD; border-radius: 8rpx; overflow: hidden; max-width: 200rpx; }
  333. .progress-fill { height: 100%; background: linear-gradient(90deg, #0EA5E9, #10B981); border-radius: 8rpx; transition: width 0.3s; }
  334. .progress-text { font-size: 24rpx; color: #666; margin-left: 20rpx; }
  335. .task-summary { display: flex; justify-content: space-around; background: #fff; border-radius: 20rpx; padding: 30rpx; margin-bottom: 20rpx; }
  336. .summary-item { text-align: center; }
  337. .emoji { font-size: 40rpx; display: block; margin-bottom: 10rpx; }
  338. .summary-item .num { font-size: 40rpx; font-weight: bold; display: block; }
  339. .summary-item.completed .num { color: #10B981; }
  340. .summary-item.pending .num { color: #F59E0B; }
  341. .summary-item.points-today .num { color: #0EA5E9; }
  342. .summary-item .label { font-size: 24rpx; color: #666; }
  343. .view-all-btn { width: 100%; }
  344. .quick-actions { display: flex; justify-content: space-around; background: #fff; border-radius: 20rpx; padding: 30rpx; }
  345. .action-item { display: flex; flex-direction: column; align-items: center; }
  346. .action-icon-wrapper { width: 100rpx; height: 100rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; margin-bottom: 10rpx; }
  347. .action-icon-wrapper.reward { background: linear-gradient(135deg, #FFD700, #FFA500); }
  348. .action-icon-wrapper.focus { background: linear-gradient(135deg, #0EA5E9, #06B6D4); }
  349. .action-icon-wrapper.trophy { background: linear-gradient(135deg, #8B5CF6, #A855F7); }
  350. .action-icon-wrapper.switch { background: linear-gradient(135deg, #10B981, #34D399); }
  351. .action-item .label { font-size: 24rpx; color: #666; }
  352. .tip-card { background: linear-gradient(135deg, #FEF3C7, #FDE68A); border-radius: 20rpx; padding: 20rpx; display: flex; align-items: center; }
  353. .tip-icon { font-size: 32rpx; margin-right: 16rpx; }
  354. .tip-text { font-size: 26rpx; color: #92400E; flex: 1; }
  355. .milestone-bar { background: linear-gradient(135deg, #FF6B6B, #FF8E53); border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; }
  356. .milestone-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
  357. .milestone-label { color: #fff; font-size: 24rpx; }
  358. .milestone-progress { color: rgba(255,255,255,0.8); font-size: 22rpx; }
  359. .milestone-progress-bar { height: 12rpx; background: rgba(255,255,255,0.3); border-radius: 6rpx; overflow: hidden; }
  360. .milestone-fill { height: 100%; background: #fff; border-radius: 6rpx; transition: width 0.3s; }
  361. /* 空状态样式 */
  362. .empty-state {
  363. display: flex;
  364. flex-direction: column;
  365. align-items: center;
  366. justify-content: center;
  367. padding: 100rpx 60rpx;
  368. text-align: center;
  369. }
  370. .empty-icon { font-size: 120rpx; margin-bottom: 30rpx; }
  371. .empty-title { font-size: 36rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
  372. .empty-desc { font-size: 28rpx; color: #666; margin-bottom: 40rpx; }
  373. .btn-add-child {
  374. background: linear-gradient(135deg, #FF6B6B, #FF8E53);
  375. color: #fff;
  376. border-radius: 40rpx;
  377. padding: 20rpx 60rpx;
  378. font-size: 30rpx;
  379. margin-bottom: 20rpx;
  380. }
  381. .btn-switch {
  382. background: #fff;
  383. color: #666;
  384. border: 1rpx solid #ddd;
  385. border-radius: 40rpx;
  386. padding: 20rpx 60rpx;
  387. font-size: 28rpx;
  388. }
  389. </style>