tasks.vue 19 KB

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