tasks.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. <template>
  2. <view class="page-bg">
  3. <!-- 退出切换(家长切换到孩子状态时显示) -->
  4. <view class="exit-switch" v-if="isSwitchedChild" @click="exitSwitch">
  5. <text class="exit-switch__icon">🔄</text>
  6. <text class="exit-switch__text">退出切换</text>
  7. </view>
  8. <!-- 顶部标签栏 -->
  9. <view class="tab-bar clay-card">
  10. <view
  11. v-for="tab in tabs"
  12. :key="tab.key"
  13. class="tab-item"
  14. :class="{ 'tab-item--active': activeTab === tab.key }"
  15. hover-class="tab-item--hover"
  16. @click="switchTab(tab.key)"
  17. >
  18. <text class="tab-item__label">{{ tab.label }}</text>
  19. <text class="tab-item__count" v-if="tab.count > 0">{{ tab.count }}</text>
  20. </view>
  21. </view>
  22. <!-- 加载状态 -->
  23. <BaseLoading :loading="loading" text="小助理正在准备任务..." />
  24. <!-- 任务列表 -->
  25. <view class="task-list" v-if="!loading && filteredTasks.length > 0">
  26. <view
  27. v-for="(task, index) in filteredTasks"
  28. :key="task.id"
  29. class="animate-slide-up"
  30. :class="'animate-stagger-' + ((index % 20) + 1)"
  31. >
  32. <view
  33. class="task-card clay-card"
  34. :class="{
  35. 'task-card--completed': task.status === 'completed',
  36. 'task-card--overdue': getIsOverdue(task),
  37. 'task-card--completing': completingTaskId === task.id
  38. }"
  39. >
  40. <!-- 完成时庆祝遮罩 -->
  41. <view class="task-card__celebrate" v-if="completingTaskId === task.id">
  42. <text class="task-card__celebrate-icon">🎉</text>
  43. </view>
  44. <view class="task-card__inner">
  45. <!-- 左侧图标 -->
  46. <view class="task-icon">
  47. <text class="task-icon__emoji">{{ getCategoryIcon(task.category) }}</text>
  48. </view>
  49. <!-- 中间内容 -->
  50. <view class="task-content">
  51. <view class="task-title">{{ task.title }}</view>
  52. <view class="task-meta">
  53. <BaseBadge variant="warning" size="xs">
  54. <text class="points-text">+{{ task.points }} ⭐</text>
  55. </BaseBadge>
  56. <text
  57. class="task-time"
  58. v-if="task.status === 'pending' && !getIsOverdue(task)"
  59. >
  60. ⏱ {{ getTimeLeft(task) }}
  61. </text>
  62. <text
  63. class="task-time task-time--overdue"
  64. v-if="getIsOverdue(task)"
  65. >
  66. ⏰ 已超时{{ getOverdueDays(task) }}
  67. </text>
  68. </view>
  69. </view>
  70. <!-- 右侧操作 -->
  71. <view class="task-action">
  72. <!-- 待完成任务 -->
  73. <template v-if="task.status === 'pending' && !getIsOverdue(task)">
  74. <PlayfulButton
  75. variant="clay"
  76. size="sm"
  77. :disabled="completingTaskId === task.id"
  78. @click="handleTaskClick(task)"
  79. >
  80. <text v-if="task.category === '小游戏类'">🎮 开始游戏</text>
  81. <text v-else>✅ 完成了!</text>
  82. </PlayfulButton>
  83. </template>
  84. <!-- 已超时且未完成的任务,显示已超时状态 -->
  85. <view
  86. class="status-badge status-badge--overdue"
  87. v-if="getIsOverdue(task) && task.status !== 'completed'"
  88. >
  89. <text class="status-badge__text">⏰ 已超时</text>
  90. </view>
  91. <!-- 已完成任务 -->
  92. <view
  93. class="status-badge status-badge--completed"
  94. v-if="task.status === 'completed'"
  95. >
  96. <text class="status-badge__text">✅ 已完成</text>
  97. </view>
  98. </view>
  99. </view>
  100. </view>
  101. </view>
  102. </view>
  103. <!-- 空状态 -->
  104. <view class="empty-wrapper" v-if="!loading && filteredTasks.length === 0">
  105. <BaseEmpty
  106. :icon="emptyIcon"
  107. :title="emptyTitle"
  108. :description="emptyDescription"
  109. >
  110. </BaseEmpty>
  111. </view>
  112. </view>
  113. </template>
  114. <script>
  115. import PlayfulButton from '@/components/PlayfulButton.vue'
  116. import BaseBadge from '@/components/BaseBadge.vue'
  117. import BaseEmpty from '@/components/BaseEmpty.vue'
  118. import BaseLoading from '@/components/BaseLoading.vue'
  119. import { getTodayTasks, completeTask as completeTaskApi, getChildren, switchBackToParent, verifyPassword } from '../../utils/api.js'
  120. export default {
  121. components: { PlayfulButton, BaseBadge, BaseEmpty, BaseLoading },
  122. data() {
  123. return {
  124. tasks: [],
  125. currentChildId: '',
  126. isSwitchedChild: false,
  127. loading: false,
  128. activeTab: 'pending',
  129. completingTaskId: null
  130. }
  131. },
  132. computed: {
  133. tabs() {
  134. var pending = 0
  135. var completed = 0
  136. var overdue = 0
  137. var task, i
  138. for (i = 0; i < this.tasks.length; i++) {
  139. task = this.tasks[i]
  140. if (task.status === 'completed') {
  141. completed++
  142. } else if (this.checkIsOverdue(task)) {
  143. overdue++
  144. } else {
  145. pending++
  146. }
  147. }
  148. return [
  149. { key: 'pending', label: '📋 待完成', count: pending },
  150. { key: 'completed', label: '✅ 已完成', count: completed },
  151. { key: 'overdue', label: '⏰ 已超时', count: overdue }
  152. ]
  153. },
  154. filteredTasks() {
  155. var result = []
  156. var task, i
  157. for (i = 0; i < this.tasks.length; i++) {
  158. task = this.tasks[i]
  159. if (this.activeTab === 'pending') {
  160. if (task.status === 'pending' && !this.checkIsOverdue(task)) {
  161. result.push(task)
  162. }
  163. } else if (this.activeTab === 'completed') {
  164. if (task.status === 'completed') {
  165. result.push(task)
  166. }
  167. } else if (this.activeTab === 'overdue') {
  168. if (this.checkIsOverdue(task)) {
  169. result.push(task)
  170. }
  171. }
  172. }
  173. return result
  174. },
  175. emptyIcon() {
  176. var icons = {
  177. pending: '📝',
  178. completed: '🎉',
  179. overdue: '😴'
  180. }
  181. return icons[this.activeTab] || '📋'
  182. },
  183. emptyTitle() {
  184. var titles = {
  185. pending: '暂无待完成任务',
  186. completed: '还没有已完成的任务',
  187. overdue: '太棒了,没有超时任务!'
  188. }
  189. return titles[this.activeTab] || ''
  190. },
  191. emptyDescription() {
  192. var descs = {
  193. pending: '让家长布置一些任务吧~\n完成后可以获得积分奖励哦',
  194. completed: '完成的任务都会出现在这里,\n继续保持好习惯!',
  195. overdue: '继续保持良好的完成习惯,\n按时完成任务吧!'
  196. }
  197. return descs[this.activeTab] || ''
  198. }
  199. },
  200. onShow() {
  201. this.isSwitchedChild = uni.getStorageSync('isSwitchedChild') || false
  202. this.loadData()
  203. },
  204. methods: {
  205. async loadData() {
  206. var childrenRes, children, storedChildId, currentChild, i, res
  207. this.loading = true
  208. try {
  209. childrenRes = await getChildren()
  210. children = childrenRes.data || []
  211. storedChildId = uni.getStorageSync('currentChildId')
  212. currentChild = null
  213. for (i = 0; i < children.length; i++) {
  214. if (String(children[i].id) === String(storedChildId)) {
  215. currentChild = children[i]
  216. break
  217. }
  218. }
  219. if (!currentChild) {
  220. currentChild = children[0]
  221. }
  222. if (!currentChild) {
  223. this.tasks = []
  224. this.currentChildId = ''
  225. return
  226. }
  227. this.currentChildId = currentChild.id
  228. res = await getTodayTasks(currentChild.id)
  229. this.tasks = res.data || []
  230. } catch (e) {
  231. console.error('加载孩子任务失败', e)
  232. } finally {
  233. this.loading = false
  234. }
  235. },
  236. async completeChildTask(taskId) {
  237. var res, points
  238. this.completingTaskId = taskId
  239. try {
  240. res = await completeTaskApi(taskId, this.currentChildId, '')
  241. points = res.data.pointsEarned
  242. uni.showToast({ title: '获得 ' + points + ' 积分', icon: 'success' })
  243. setTimeout(() => {
  244. this.completingTaskId = null
  245. this.loadData()
  246. }, 600)
  247. } catch (e) {
  248. this.completingTaskId = null
  249. console.error('完成任务失败', e)
  250. }
  251. },
  252. handleTaskClick(task) {
  253. var gamePages, gamePage
  254. if (this.completingTaskId === task.id) return
  255. if (task.category === '小游戏类' && task.minigameCode) {
  256. gamePages = {
  257. 'schulte': '/pages/games/schulte',
  258. '1a2b': '/pages/games/1a2b',
  259. 'sudoku': '/pages/games/sudoku'
  260. }
  261. gamePage = gamePages[task.minigameCode]
  262. if (gamePage) {
  263. uni.navigateTo({
  264. url: gamePage + '?taskId=' + task.id + '&childId=' + this.currentChildId
  265. })
  266. }
  267. } else {
  268. this.completeChildTask(task.id)
  269. }
  270. },
  271. getCategoryIcon(category) {
  272. var icons = {
  273. '学习类': '📚',
  274. '生活类': '🏠',
  275. '运动类': '⚽',
  276. '小游戏类': '🎮',
  277. '其他': '⭐'
  278. }
  279. return icons[category] || '📝'
  280. },
  281. getTimeLeft(task) {
  282. var deadline, now, diff, hours, minutes, days
  283. if (!task.deadline) return '无截止时间'
  284. deadline = new Date(task.deadline)
  285. now = new Date()
  286. diff = deadline - now
  287. if (diff < 0) return ''
  288. hours = Math.floor(diff / (1000 * 60 * 60))
  289. minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
  290. if (hours > 24) {
  291. days = Math.floor(hours / 24)
  292. return '还剩 ' + days + ' 天'
  293. }
  294. if (hours > 0) return '还剩 ' + hours + ' 小时'
  295. if (minutes > 0) return '还剩 ' + minutes + ' 分钟'
  296. return '即将截止'
  297. },
  298. checkIsOverdue(task) {
  299. var deadline, now
  300. if (task.status === 'completed') return false
  301. if (!task.deadline) return false
  302. deadline = new Date(task.deadline)
  303. now = new Date()
  304. return deadline < now
  305. },
  306. getOverdueDays(task) {
  307. var deadline, now, diff, days, hours
  308. if (!task.deadline) return ''
  309. deadline = new Date(task.deadline)
  310. now = new Date()
  311. diff = now - deadline
  312. days = Math.floor(diff / (1000 * 60 * 60 * 24))
  313. if (days > 0) return ' ' + days + '天'
  314. hours = Math.floor(diff / (1000 * 60 * 60))
  315. if (hours > 0) return ' ' + hours + '小时'
  316. return ''
  317. },
  318. switchTab(tabKey) {
  319. this.activeTab = tabKey
  320. },
  321. exitSwitch() {
  322. var self = this
  323. if (!self.isSwitchedChild) return
  324. uni.showModal({
  325. title: '退出切换',
  326. content: '请输入家长密码以确认退出',
  327. editable: true,
  328. success: function(res) {
  329. if (res.confirm && res.content) {
  330. verifyPassword(res.content).then(function(verifyResult) {
  331. if (verifyResult.code === 200) {
  332. self.switchBackToParentMode()
  333. } else {
  334. uni.showToast({ title: '密码错误', icon: 'none' })
  335. }
  336. }).catch(function() {
  337. uni.showToast({ title: '密码验证失败', icon: 'none' })
  338. })
  339. }
  340. }
  341. })
  342. },
  343. async switchBackToParentMode() {
  344. var result
  345. try {
  346. result = await switchBackToParent()
  347. if (result.data) {
  348. this.$store.commit('switchBackToParent')
  349. uni.showToast({ title: '已切换回家长模式', icon: 'success' })
  350. uni.reLaunch({ url: '/pages/home-pages/parent-index' })
  351. }
  352. } catch (_) {
  353. uni.showToast({ title: '切换失败', icon: 'none' })
  354. }
  355. }
  356. }
  357. }
  358. </script>
  359. <style scoped>
  360. /* =============================================
  361. 页面基础
  362. ============================================= */
  363. .page-bg {
  364. min-height: 100vh;
  365. background: var(--bg, #F5F9FC);
  366. padding-bottom: 40rpx;
  367. }
  368. /* =============================================
  369. 退出切换横幅
  370. ============================================= */
  371. .exit-switch {
  372. display: flex;
  373. align-items: center;
  374. justify-content: center;
  375. gap: 12rpx;
  376. padding: 24rpx;
  377. margin: 24rpx 32rpx 0;
  378. background: var(--surface, #FFFFFF);
  379. border: 2rpx solid var(--border, #CBD5E1);
  380. border-radius: var(--radius-lg, 32rpx);
  381. box-shadow: var(--shadow-sm);
  382. }
  383. .exit-switch:active {
  384. opacity: 0.7;
  385. transform: scale(0.98);
  386. }
  387. .exit-switch__icon {
  388. font-size: 32rpx;
  389. }
  390. .exit-switch__text {
  391. font-size: 28rpx;
  392. color: var(--text-secondary, #64748B);
  393. font-weight: var(--font-weight-medium, 500);
  394. }
  395. /* =============================================
  396. 顶部标签栏
  397. ============================================= */
  398. .tab-bar {
  399. display: flex;
  400. flex-direction: row;
  401. margin: 24rpx 32rpx 0;
  402. padding: 8rpx;
  403. border-radius: var(--radius-xl, 40rpx);
  404. background: var(--surface, #FFFFFF);
  405. position: relative;
  406. }
  407. .tab-item {
  408. flex: 1;
  409. display: flex;
  410. flex-direction: row;
  411. align-items: center;
  412. justify-content: center;
  413. gap: 8rpx;
  414. padding: 20rpx 12rpx;
  415. border-radius: var(--radius-lg, 32rpx);
  416. transition: all var(--transition-base, 0.25s ease);
  417. position: relative;
  418. }
  419. .tab-item--hover {
  420. background: rgba(91, 155, 213, 0.06);
  421. }
  422. .tab-item--active {
  423. background: linear-gradient(145deg, var(--color-primary-light, #8FC5E8), var(--color-primary, #5B9BD5));
  424. box-shadow: inset -2rpx -2rpx 8rpx rgba(255, 255, 255, 0.3),
  425. 4rpx 4rpx 12rpx rgba(91, 155, 213, 0.2);
  426. }
  427. .tab-item__label {
  428. font-size: 26rpx;
  429. font-weight: var(--font-weight-medium, 500);
  430. color: var(--text-secondary, #64748B);
  431. transition: color var(--transition-fast, 0.15s ease);
  432. }
  433. .tab-item--active .tab-item__label {
  434. color: var(--text-color-inverse, #FFFFFF);
  435. font-weight: var(--font-weight-bold, 600);
  436. }
  437. .tab-item__count {
  438. display: inline-flex;
  439. align-items: center;
  440. justify-content: center;
  441. min-width: 36rpx;
  442. height: 36rpx;
  443. padding: 0 8rpx;
  444. border-radius: 999rpx;
  445. background: rgba(91, 155, 213, 0.12);
  446. color: var(--color-primary, #5B9BD5);
  447. font-size: 20rpx;
  448. font-weight: var(--font-weight-bold, 600);
  449. line-height: 1;
  450. }
  451. .tab-item--active .tab-item__count {
  452. background: rgba(255, 255, 255, 0.25);
  453. color: #FFFFFF;
  454. }
  455. /* =============================================
  456. 任务列表
  457. ============================================= */
  458. .task-list {
  459. padding: 24rpx 32rpx;
  460. }
  461. /* =============================================
  462. 任务卡片(Claymorphism 风格)
  463. ============================================= */
  464. .task-card {
  465. position: relative;
  466. overflow: hidden;
  467. margin-bottom: 24rpx;
  468. transition: transform var(--transition-fast, 0.15s ease),
  469. opacity var(--transition-base, 0.25s ease);
  470. }
  471. .task-card:active {
  472. transform: scale(0.98);
  473. }
  474. /* --- 已完成状态 --- */
  475. .task-card--completed {
  476. opacity: 0.7;
  477. border-color: var(--color-success, #22C55E);
  478. background: linear-gradient(145deg, #F0FDF4, #ECFDF5);
  479. box-shadow: inset -2rpx -2rpx 6rpx rgba(34, 197, 94, 0.05),
  480. 3rpx 3rpx 10rpx rgba(34, 197, 94, 0.08);
  481. }
  482. /* --- 超时状态 --- */
  483. .task-card--overdue {
  484. border-color: var(--color-error, #EF4444);
  485. background: linear-gradient(145deg, #FEF2F2, #FEE2E2);
  486. box-shadow: inset -2rpx -2rpx 6rpx rgba(239, 68, 68, 0.05),
  487. 3rpx 3rpx 10rpx rgba(239, 68, 68, 0.08);
  488. }
  489. /* --- 完成中动画 --- */
  490. .task-card--completing {
  491. animation: cardComplete 0.6s ease forwards;
  492. pointer-events: none;
  493. }
  494. .task-card__celebrate {
  495. position: absolute;
  496. inset: 0;
  497. display: flex;
  498. align-items: center;
  499. justify-content: center;
  500. background: rgba(255, 255, 255, 0.6);
  501. border-radius: var(--radius-md, 20rpx);
  502. z-index: 10;
  503. }
  504. .task-card__celebrate-icon {
  505. font-size: 80rpx;
  506. animation: celebrateBounce 0.6s ease both;
  507. }
  508. /* --- 内部布局 --- */
  509. .task-card__inner {
  510. display: flex;
  511. flex-direction: row;
  512. align-items: center;
  513. gap: 20rpx;
  514. }
  515. /* =============================================
  516. 任务图标
  517. ============================================= */
  518. .task-icon {
  519. width: 88rpx;
  520. height: 88rpx;
  521. border-radius: var(--radius-md, 20rpx);
  522. background: linear-gradient(145deg, #F5F9FC, #E2E8F0);
  523. display: flex;
  524. align-items: center;
  525. justify-content: center;
  526. flex-shrink: 0;
  527. border: 2rpx solid var(--border-light, #E2E8F0);
  528. }
  529. .task-card--completed .task-icon {
  530. background: linear-gradient(145deg, #F0FDF4, #DCFCE7);
  531. border-color: #BBF7D0;
  532. }
  533. .task-card--overdue .task-icon {
  534. background: linear-gradient(145deg, #FEF2F2, #FECACA);
  535. border-color: #FECACA;
  536. }
  537. .task-icon__emoji {
  538. font-size: 40rpx;
  539. line-height: 1;
  540. }
  541. /* =============================================
  542. 任务内容区
  543. ============================================= */
  544. .task-content {
  545. flex: 1;
  546. min-width: 0;
  547. }
  548. .task-title {
  549. font-size: 32rpx;
  550. font-weight: var(--font-weight-bold, 600);
  551. color: var(--text, #1E293B);
  552. line-height: 1.4;
  553. margin-bottom: 10rpx;
  554. overflow: hidden;
  555. text-overflow: ellipsis;
  556. white-space: nowrap;
  557. }
  558. .task-card--completed .task-title {
  559. text-decoration: line-through;
  560. color: var(--text-secondary, #64748B);
  561. }
  562. .task-meta {
  563. display: flex;
  564. flex-direction: row;
  565. align-items: center;
  566. gap: 16rpx;
  567. flex-wrap: wrap;
  568. }
  569. .points-text {
  570. font-size: 22rpx;
  571. line-height: 1.4;
  572. }
  573. .task-time {
  574. font-size: 22rpx;
  575. color: var(--text-secondary, #64748B);
  576. line-height: 1.4;
  577. }
  578. .task-time--overdue {
  579. color: var(--color-error, #EF4444);
  580. font-weight: var(--font-weight-medium, 500);
  581. }
  582. /* =============================================
  583. 右侧操作区
  584. ============================================= */
  585. .task-action {
  586. flex-shrink: 0;
  587. margin-left: 8rpx;
  588. }
  589. /* =============================================
  590. 状态徽章
  591. ============================================= */
  592. .status-badge {
  593. display: flex;
  594. align-items: center;
  595. justify-content: center;
  596. padding: 16rpx 24rpx;
  597. border-radius: 999rpx;
  598. font-weight: var(--font-weight-bold, 600);
  599. }
  600. .status-badge--completed {
  601. background: rgba(34, 197, 94, 0.12);
  602. color: var(--color-success, #22C55E);
  603. border: 2rpx solid rgba(34, 197, 94, 0.2);
  604. }
  605. .status-badge--overdue {
  606. background: rgba(239, 68, 68, 0.12);
  607. color: var(--color-error, #EF4444);
  608. border: 2rpx solid rgba(239, 68, 68, 0.2);
  609. }
  610. .status-badge__text {
  611. font-size: 24rpx;
  612. line-height: 1;
  613. }
  614. /* =============================================
  615. 空状态
  616. ============================================= */
  617. .empty-wrapper {
  618. padding-top: 60rpx;
  619. }
  620. /* =============================================
  621. 关键帧动画
  622. ============================================= */
  623. @keyframes cardComplete {
  624. 0% {
  625. transform: scale(1);
  626. opacity: 1;
  627. }
  628. 30% {
  629. transform: scale(1.05);
  630. opacity: 0.9;
  631. }
  632. 100% {
  633. transform: scale(0.95);
  634. opacity: 0.6;
  635. }
  636. }
  637. @keyframes celebrateBounce {
  638. 0% {
  639. transform: scale(0) rotate(-180deg);
  640. opacity: 0;
  641. }
  642. 50% {
  643. transform: scale(1.3) rotate(10deg);
  644. opacity: 1;
  645. }
  646. 70% {
  647. transform: scale(0.9) rotate(-5deg);
  648. }
  649. 100% {
  650. transform: scale(1) rotate(0deg);
  651. opacity: 1;
  652. }
  653. }
  654. </style>