tasks.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  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 }} CF</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. if (res.data && res.data.needReview) {
  243. uni.showToast({ title: '已提交,待审核', icon: 'none' })
  244. } else {
  245. points = res.data.pointsEarned
  246. uni.showToast({ title: '获得 ' + points + ' CF', icon: 'success' })
  247. }
  248. setTimeout(() => {
  249. this.completingTaskId = null
  250. this.loadData()
  251. }, 600)
  252. } catch (e) {
  253. this.completingTaskId = null
  254. console.error('完成任务失败', e)
  255. }
  256. },
  257. handleTaskClick(task) {
  258. var gamePages, gamePage
  259. if (this.completingTaskId === task.id) return
  260. if (task.category === '小游戏类' && task.minigameCode) {
  261. gamePages = {
  262. 'schulte': '/pages/games/schulte',
  263. '1a2b': '/pages/games/1a2b',
  264. 'sudoku': '/pages/games/sudoku'
  265. }
  266. gamePage = gamePages[task.minigameCode]
  267. if (gamePage) {
  268. uni.navigateTo({
  269. url: gamePage + '?taskId=' + task.id + '&memberId=' + this.currentChildId
  270. })
  271. }
  272. } else {
  273. this.completeChildTask(task.id)
  274. }
  275. },
  276. getCategoryIcon(category) {
  277. var icons = {
  278. '学习类': '📚',
  279. '生活类': '🏠',
  280. '运动类': '⚽',
  281. '小游戏类': '🎮',
  282. '其他': '⭐'
  283. }
  284. return icons[category] || '📝'
  285. },
  286. getTimeLeft(task) {
  287. var deadline, now, diff, hours, minutes, days
  288. if (!task.deadline) return '无截止时间'
  289. deadline = parseDate(task.deadline)
  290. if (!deadline) return ''
  291. now = new Date()
  292. diff = deadline - now
  293. if (diff < 0) return ''
  294. hours = Math.floor(diff / (1000 * 60 * 60))
  295. minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
  296. if (hours > 24) {
  297. days = Math.floor(hours / 24)
  298. return '还剩 ' + days + ' 天'
  299. }
  300. if (hours > 0) return '还剩 ' + hours + ' 小时'
  301. if (minutes > 0) return '还剩 ' + minutes + ' 分钟'
  302. return '即将截止'
  303. },
  304. checkIsOverdue(task) {
  305. var deadline, now
  306. if (task.status === 'completed') return false
  307. if (!task.deadline) return false
  308. deadline = parseDate(task.deadline)
  309. if (!deadline) return false
  310. now = new Date()
  311. return deadline < now
  312. },
  313. getOverdueDays(task) {
  314. var deadline, now, diff, days, hours
  315. if (!task.deadline) return ''
  316. deadline = parseDate(task.deadline)
  317. if (!deadline) return ''
  318. now = new Date()
  319. diff = now - deadline
  320. days = Math.floor(diff / (1000 * 60 * 60 * 24))
  321. if (days > 0) return ' ' + days + '天'
  322. hours = Math.floor(diff / (1000 * 60 * 60))
  323. if (hours > 0) return ' ' + hours + '小时'
  324. return ''
  325. },
  326. switchTab(tabKey) {
  327. this.activeTab = tabKey
  328. },
  329. exitSwitch() {
  330. var self = this
  331. if (!self.isSwitchedChild) return
  332. uni.showModal({
  333. title: '退出切换',
  334. content: '请输入家长密码以确认退出',
  335. editable: true,
  336. success: function(res) {
  337. if (res.confirm && res.content) {
  338. verifyPassword(res.content).then(function(verifyResult) {
  339. if (verifyResult.code === 200) {
  340. self.switchBackToParentMode()
  341. } else {
  342. uni.showToast({ title: '密码错误', icon: 'none' })
  343. }
  344. }).catch(function() {
  345. uni.showToast({ title: '密码验证失败', icon: 'none' })
  346. })
  347. }
  348. }
  349. })
  350. },
  351. async switchBackToParentMode() {
  352. var result
  353. try {
  354. result = await switchBackToParent()
  355. if (result.data) {
  356. this.$store.commit('switchBackToParent')
  357. uni.showToast({ title: '已切换回家长模式', icon: 'success' })
  358. uni.reLaunch({ url: '/pages/home-pages/parent-index' })
  359. }
  360. } catch (_) {
  361. uni.showToast({ title: '切换失败', icon: 'none' })
  362. }
  363. }
  364. }
  365. }
  366. </script>
  367. <style scoped lang="scss">
  368. /* =============================================
  369. 页面基础
  370. ============================================= */
  371. .page-bg {
  372. min-height: 100vh;
  373. background: var(--bg, $uni-bg-color);
  374. padding-bottom: 40rpx;
  375. }
  376. /* =============================================
  377. 退出切换横幅
  378. ============================================= */
  379. .exit-switch {
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. gap: 12rpx;
  384. padding: 24rpx;
  385. margin: 24rpx 32rpx 0;
  386. background: var(--surface, $app-surface);
  387. border: 2rpx solid var(--border, $uni-border-color);
  388. border-radius: var(--radius-lg, 32rpx);
  389. box-shadow: var(--shadow-sm);
  390. }
  391. .exit-switch:active {
  392. opacity: 0.7;
  393. transform: scale(0.98);
  394. }
  395. .exit-switch__icon {
  396. font-size: 32rpx;
  397. }
  398. .exit-switch__text {
  399. font-size: 28rpx;
  400. color: var(--text-secondary, $uni-text-color-secondary);
  401. font-weight: var(--font-weight-medium, 500);
  402. }
  403. /* =============================================
  404. 顶部标签栏
  405. ============================================= */
  406. .tab-bar {
  407. display: flex;
  408. flex-direction: row;
  409. margin: 24rpx 32rpx 0;
  410. padding: 8rpx;
  411. border-radius: var(--radius-xl, 40rpx);
  412. background: var(--surface, $app-surface);
  413. position: relative;
  414. }
  415. .tab-item {
  416. flex: 1;
  417. display: flex;
  418. flex-direction: row;
  419. align-items: center;
  420. justify-content: center;
  421. gap: 8rpx;
  422. padding: 20rpx 12rpx;
  423. border-radius: var(--radius-lg, 32rpx);
  424. transition: background-color var(--transition-base, 0.25s ease), box-shadow var(--transition-base, 0.25s ease);
  425. position: relative;
  426. }
  427. .tab-item--hover {
  428. background: rgba(249,115,22,0.06);
  429. }
  430. .tab-item--active {
  431. background: linear-gradient(145deg, var(--color-primary-light, $brand-primary-light), var(--color-primary, $brand-primary));
  432. box-shadow: inset -2rpx -2rpx 8rpx rgba(255, 255, 255, 0.3),
  433. 4rpx 4rpx 12rpx rgba(249,115,22,0.2);
  434. }
  435. .tab-item__label {
  436. font-size: 26rpx;
  437. font-weight: var(--font-weight-medium, 500);
  438. color: var(--text-secondary, $uni-text-color-secondary);
  439. transition: color var(--transition-fast, 0.15s ease);
  440. }
  441. .tab-item--active .tab-item__label {
  442. color: var(--text-color-inverse, $app-surface);
  443. font-weight: var(--font-weight-bold, 600);
  444. }
  445. .tab-item__count {
  446. display: inline-flex;
  447. align-items: center;
  448. justify-content: center;
  449. min-width: 36rpx;
  450. height: 36rpx;
  451. padding: 0 8rpx;
  452. border-radius: 999rpx;
  453. background: rgba(249,115,22,0.12);
  454. color: var(--color-primary, $brand-primary);
  455. font-size: 20rpx;
  456. font-weight: var(--font-weight-bold, 600);
  457. line-height: 1;
  458. }
  459. .tab-item--active .tab-item__count {
  460. background: rgba(255, 255, 255, 0.25);
  461. color: $app-surface;
  462. }
  463. /* =============================================
  464. 任务列表
  465. ============================================= */
  466. .task-list {
  467. padding: 24rpx 32rpx;
  468. }
  469. /* =============================================
  470. 任务卡片(Claymorphism 风格)
  471. ============================================= */
  472. .task-card {
  473. position: relative;
  474. overflow: hidden;
  475. margin-bottom: 24rpx;
  476. transition: transform var(--transition-fast, 0.15s ease),
  477. opacity var(--transition-base, 0.25s ease);
  478. }
  479. .task-card:active {
  480. transform: scale(0.98);
  481. }
  482. /* --- 已完成状态 --- */
  483. .task-card--completed {
  484. opacity: 0.7;
  485. border-color: var(--color-success, $status-success-2);
  486. background: linear-gradient(145deg, $status-success-tint-2, #ECFDF5);
  487. box-shadow: inset -2rpx -2rpx 6rpx rgba(34, 197, 94, 0.05),
  488. 3rpx 3rpx 10rpx rgba(34, 197, 94, 0.08);
  489. }
  490. /* --- 超时状态 --- */
  491. .task-card--overdue {
  492. border-color: var(--color-error, $status-danger);
  493. background: linear-gradient(145deg, #FEF2F2, $status-danger-soft);
  494. box-shadow: inset -2rpx -2rpx 6rpx rgba(239, 68, 68, 0.05),
  495. 3rpx 3rpx 10rpx rgba(239, 68, 68, 0.08);
  496. }
  497. /* --- 完成中动画 --- */
  498. .task-card--completing {
  499. animation: cardComplete 0.6s ease forwards;
  500. pointer-events: none;
  501. }
  502. .task-card__celebrate {
  503. position: absolute;
  504. inset: 0;
  505. display: flex;
  506. align-items: center;
  507. justify-content: center;
  508. background: rgba(255, 255, 255, 0.6);
  509. border-radius: var(--radius-md, 20rpx);
  510. z-index: 10;
  511. }
  512. .task-card__celebrate-icon {
  513. font-size: 80rpx;
  514. animation: celebrateBounce 0.6s ease both;
  515. }
  516. /* --- 内部布局 --- */
  517. .task-card__inner {
  518. display: flex;
  519. flex-direction: row;
  520. align-items: center;
  521. gap: 20rpx;
  522. }
  523. /* =============================================
  524. 任务图标
  525. ============================================= */
  526. .task-icon {
  527. width: 88rpx;
  528. height: 88rpx;
  529. border-radius: var(--radius-md, 20rpx);
  530. background: linear-gradient(145deg, $uni-bg-color, $uni-border-color-light);
  531. display: flex;
  532. align-items: center;
  533. justify-content: center;
  534. flex-shrink: 0;
  535. border: 2rpx solid var(--border-light, $uni-border-color-light);
  536. }
  537. .task-card--completed .task-icon {
  538. background: linear-gradient(145deg, $status-success-tint-2, $status-success-soft-2);
  539. border-color: #BBF7D0;
  540. }
  541. .task-card--overdue .task-icon {
  542. background: linear-gradient(145deg, #FEF2F2, #FECACA);
  543. border-color: #FECACA;
  544. }
  545. .task-icon__emoji {
  546. font-size: 40rpx;
  547. line-height: 1;
  548. }
  549. /* =============================================
  550. 任务内容区
  551. ============================================= */
  552. .task-content {
  553. flex: 1;
  554. min-width: 0;
  555. }
  556. .task-title {
  557. font-size: 32rpx;
  558. font-weight: var(--font-weight-bold, 600);
  559. color: var(--text, $uni-text-color);
  560. line-height: 1.4;
  561. margin-bottom: 10rpx;
  562. overflow: hidden;
  563. text-overflow: ellipsis;
  564. white-space: nowrap;
  565. }
  566. .task-card--completed .task-title {
  567. text-decoration: line-through;
  568. color: var(--text-secondary, $uni-text-color-secondary);
  569. }
  570. .task-meta {
  571. display: flex;
  572. flex-direction: row;
  573. align-items: center;
  574. gap: 16rpx;
  575. flex-wrap: wrap;
  576. }
  577. .points-text {
  578. font-size: 22rpx;
  579. line-height: 1.4;
  580. }
  581. .task-time {
  582. font-size: 22rpx;
  583. color: var(--text-secondary, $uni-text-color-secondary);
  584. line-height: 1.4;
  585. }
  586. .task-time--overdue {
  587. color: var(--color-error, $status-danger);
  588. font-weight: var(--font-weight-medium, 500);
  589. }
  590. /* =============================================
  591. 右侧操作区
  592. ============================================= */
  593. .task-action {
  594. flex-shrink: 0;
  595. margin-left: 8rpx;
  596. }
  597. /* =============================================
  598. 状态徽章
  599. ============================================= */
  600. .status-badge {
  601. display: flex;
  602. align-items: center;
  603. justify-content: center;
  604. padding: 16rpx 24rpx;
  605. border-radius: 999rpx;
  606. font-weight: var(--font-weight-bold, 600);
  607. }
  608. .status-badge--completed {
  609. background: rgba(34, 197, 94, 0.12);
  610. color: var(--color-success, $status-success-2);
  611. border: 2rpx solid rgba(34, 197, 94, 0.2);
  612. }
  613. .status-badge--overdue {
  614. background: rgba(239, 68, 68, 0.12);
  615. color: var(--color-error, $status-danger);
  616. border: 2rpx solid rgba(239, 68, 68, 0.2);
  617. }
  618. .status-badge__text {
  619. font-size: 24rpx;
  620. line-height: 1;
  621. }
  622. /* =============================================
  623. 空状态
  624. ============================================= */
  625. .empty-wrapper {
  626. padding-top: 60rpx;
  627. }
  628. /* =============================================
  629. 关键帧动画
  630. ============================================= */
  631. @keyframes cardComplete {
  632. 0% {
  633. transform: scale(1);
  634. opacity: 1;
  635. }
  636. 30% {
  637. transform: scale(1.05);
  638. opacity: 0.9;
  639. }
  640. 100% {
  641. transform: scale(0.95);
  642. opacity: 0.6;
  643. }
  644. }
  645. @keyframes celebrateBounce {
  646. 0% {
  647. transform: scale(0) rotate(-180deg);
  648. opacity: 0;
  649. }
  650. 50% {
  651. transform: scale(1.3) rotate(10deg);
  652. opacity: 1;
  653. }
  654. 70% {
  655. transform: scale(0.9) rotate(-5deg);
  656. }
  657. 100% {
  658. transform: scale(1) rotate(0deg);
  659. opacity: 1;
  660. }
  661. }
  662. </style>