checkin.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. <template>
  2. <scroll-view class="container" scroll-y>
  3. <!-- 顶部能量提示 -->
  4. <view class="energy-tip">
  5. <text class="energy-tip-text">⚡ 每次打卡可获得 5 财富能量</text>
  6. <text class="energy-tip-sub">坚持打卡,养成理财好习惯</text>
  7. </view>
  8. <!-- 当月日历打卡 -->
  9. <view class="calendar-card">
  10. <view class="calendar-header">
  11. <text class="calendar-title">{{ currentYear }}年{{ currentMonth }}月</text>
  12. <view class="calendar-nav">
  13. <text class="nav-btn" @click="changeMonth(-1)">‹</text>
  14. <text class="nav-btn" @click="changeMonth(1)">›</text>
  15. </view>
  16. </view>
  17. <view class="calendar-weekdays">
  18. <text class="weekday" v-for="(w, i) in weekdays" :key="i">{{ w }}</text>
  19. </view>
  20. <view class="calendar-days">
  21. <view class="day-cell empty" v-for="(e, i) in emptyDays" :key="getEmptyKey(i)"></view>
  22. <view
  23. v-for="(day, i) in daysInMonth"
  24. :key="getDayKey(i)"
  25. class="day-cell"
  26. :class="{
  27. checked: checkinDates[day],
  28. today: day === currentDay && currentMonth === realMonth && currentYear === realYear
  29. }"
  30. @click="onDayClick(day)">
  31. <text class="day-num">{{ day }}</text>
  32. <text class="day-dot" v-if="checkinDates[day]">●</text>
  33. </view>
  34. </view>
  35. <view class="calendar-stats">
  36. <text>本月打卡 <text class="stat-highlight">{{ checkinDaysThisMonth }}</text> 天</text>
  37. <text>连续 <text class="stat-highlight">{{ streakDays }}</text> 天</text>
  38. </view>
  39. </view>
  40. <!-- 快速打卡 -->
  41. <view class="quick-checkin">
  42. <view class="section-title-row">
  43. <text class="section-title">快速打卡</text>
  44. <text class="section-subtitle">今日已打卡 {{ todayCount }} 次</text>
  45. </view>
  46. <view class="checkin-types">
  47. <view
  48. v-for="(item, index) in checkinTypes"
  49. :key="index"
  50. class="checkin-btn"
  51. :class="{ disabled: item.disabled }"
  52. @click="showCheckinDialog(item.type)">
  53. <text class="checkin-icon">{{ item.icon }}</text>
  54. <text class="checkin-label">{{ item.label }}</text>
  55. </view>
  56. </view>
  57. </view>
  58. <!-- 统计卡片 -->
  59. <view class="stats-card">
  60. <view class="stat-item">
  61. <text class="stat-value">{{ stats.totalCheckins || 0 }}</text>
  62. <text class="stat-label">总打卡</text>
  63. </view>
  64. <view class="stat-divider"></view>
  65. <view class="stat-item">
  66. <text class="stat-value">{{ stats.totalEnergy || 0 }}</text>
  67. <text class="stat-label">总能量</text>
  68. </view>
  69. <view class="stat-divider"></view>
  70. <view class="stat-item">
  71. <text class="stat-value">{{ stats.dailyPlanCount || 0 }}</text>
  72. <text class="stat-label">规划</text>
  73. </view>
  74. <view class="stat-divider"></view>
  75. <view class="stat-item">
  76. <text class="stat-value">{{ stats.savingCount || 0 }}</text>
  77. <text class="stat-label">储蓄</text>
  78. </view>
  79. </view>
  80. <!-- 打卡记录 -->
  81. <view class="record-section">
  82. <view class="section-title-row">
  83. <text class="section-title">打卡记录</text>
  84. <text class="section-subtitle" @click="toggleRecords">
  85. {{ showAllRecords ? '收起' : '查看全部' }}
  86. </text>
  87. </view>
  88. <view v-if="displayRecords.length === 0" class="empty-state">
  89. <text class="empty-icon">📝</text>
  90. <text class="empty-text">暂无打卡记录,开始打卡吧!</text>
  91. </view>
  92. <view
  93. v-for="(item, index) in displayRecords"
  94. :key="index"
  95. class="record-item"
  96. @longpress="confirmDelete(item)">
  97. <view class="record-left">
  98. <text class="record-icon">{{ getTypeIcon(item.checkinType) }}</text>
  99. <view class="record-info">
  100. <text class="record-type">{{ getTypeLabel(item.checkinType) }}</text>
  101. <text class="record-date">{{ formatDate(item.createdAt) }}</text>
  102. <text class="record-note" v-if="item.note">{{ item.note }}</text>
  103. </view>
  104. </view>
  105. <view class="record-right">
  106. <text class="record-energy">+{{ item.earnedEnergy || 5 }}</text>
  107. <text class="record-mood" v-if="item.mood">{{ item.mood }}</text>
  108. </view>
  109. </view>
  110. </view>
  111. <!-- 打卡弹窗 -->
  112. <view class="dialog-mask" v-if="showDialog" @click="closeDialog">
  113. <view class="dialog-card" @click.stop>
  114. <text class="dialog-title">{{ dialogType ? getTypeLabel(dialogType) : '' }}打卡</text>
  115. <view class="dialog-form">
  116. <view class="form-item">
  117. <text class="form-label">金额(选填)</text>
  118. <input class="form-input" type="digit" v-model="formAmount" placeholder="输入金额" />
  119. </view>
  120. <view class="form-item">
  121. <text class="form-label">分类</text>
  122. <picker class="form-picker" mode="selector" :range="categoryOptions" @change="onCategoryChange">
  123. <text class="picker-text">{{ formCategory || '选择分类' }}</text>
  124. </picker>
  125. </view>
  126. <view class="form-item">
  127. <text class="form-label">备注</text>
  128. <input class="form-input" type="text" v-model="formNote" placeholder="备注信息" />
  129. </view>
  130. <view class="form-item">
  131. <text class="form-label">心情</text>
  132. <view class="mood-picker">
  133. <text
  134. v-for="(m, i) in moodOptions"
  135. :key="i"
  136. class="mood-item"
  137. :class="{ selected: formMood === m.value }"
  138. @click="selectMood(m.value)">{{ m.icon }}</text>
  139. </view>
  140. </view>
  141. </view>
  142. <view class="dialog-actions">
  143. <button class="dialog-btn cancel" @click="closeDialog">取消</button>
  144. <button class="dialog-btn confirm" @click="doCheckin">确认打卡</button>
  145. </view>
  146. </view>
  147. </view>
  148. <!-- 能量奖励动画 -->
  149. <view class="energy-animation" v-if="showEnergyAnim">
  150. <text class="energy-anim-text">+{{ earnedEnergy }} ⚡</text>
  151. </view>
  152. </scroll-view>
  153. </template>
  154. <script>
  155. import { getCheckinList, getCheckinStats, createCheckin, deleteCheckin } from '../../utils/api'
  156. export default {
  157. data() {
  158. return {
  159. checkinTypes: [
  160. { type: 'daily_plan', label: '记账规划', icon: '📋' },
  161. { type: 'saving', label: '储蓄记录', icon: '💰' },
  162. { type: 'expense', label: '消费记录', icon: '🛒' },
  163. { type: 'review', label: '财务回顾', icon: '📊' }
  164. ],
  165. checkins: [],
  166. stats: {},
  167. todayCount: 0,
  168. todayTimer: null,
  169. // 日历
  170. currentYear: 0,
  171. currentMonth: 0,
  172. realMonth: 0,
  173. realYear: 0,
  174. currentDay: 0,
  175. weekdays: ['日', '一', '二', '三', '四', '五', '六'],
  176. checkinDates: {},
  177. streakDays: 0,
  178. // 弹窗
  179. showDialog: false,
  180. dialogType: '',
  181. formAmount: '',
  182. formCategory: '',
  183. formNote: '',
  184. formMood: '',
  185. categoryOptions: ['日常', '餐饮', '交通', '购物', '娱乐', '教育', '医疗', '其他'],
  186. moodOptions: [
  187. { icon: '😊', value: 'happy' },
  188. { icon: '😐', value: 'neutral' },
  189. { icon: '😔', value: 'sad' },
  190. { icon: '😤', value: 'angry' },
  191. { icon: '🎉', value: 'excited' }
  192. ],
  193. // 能量动画
  194. showEnergyAnim: false,
  195. earnedEnergy: 5,
  196. showAllRecords: false,
  197. childId: null
  198. }
  199. },
  200. computed: {
  201. daysInMonth: function() {
  202. return new Date(this.currentYear, this.currentMonth, 0).getDate()
  203. },
  204. emptyDays: function() {
  205. var firstDay = new Date(this.currentYear, this.currentMonth - 1, 1).getDay()
  206. return firstDay
  207. },
  208. checkinDaysThisMonth: function() {
  209. return Object.keys(this.checkinDates).length
  210. },
  211. displayRecords: function() {
  212. if (this.showAllRecords) return this.checkins
  213. return this.checkins.slice(0, 10)
  214. }
  215. },
  216. onShow() {
  217. var now = new Date()
  218. this.currentYear = now.getFullYear()
  219. this.currentMonth = now.getMonth() + 1
  220. this.realMonth = this.currentMonth
  221. this.realYear = this.currentYear
  222. this.currentDay = now.getDate()
  223. this.childId = this.$route && this.$route.query && this.$route.query.childId
  224. this.loadData()
  225. this.loadCalendarData()
  226. },
  227. methods: {
  228. async loadData() {
  229. try {
  230. var params = {}
  231. if (this.childId) params.childId = this.childId
  232. var res = await getCheckinList(params)
  233. if (res && res.code === 200) {
  234. this.checkins = res.data || []
  235. }
  236. var statsRes = await getCheckinStats(params)
  237. if (statsRes && statsRes.code === 200) {
  238. this.stats = statsRes.data || {}
  239. }
  240. this.calcTodayCount()
  241. } catch (e) {
  242. console.error('加载打卡数据失败', e)
  243. }
  244. },
  245. async loadCalendarData() {
  246. try {
  247. var ym = this.currentYear + '-' + (this.currentMonth < 10 ? '0' : '') + this.currentMonth
  248. var params = { yearMonth: ym }
  249. if (this.childId) params.childId = this.childId
  250. var res = await getCheckinList(params)
  251. if (res && res.code === 200) {
  252. var list = res.data || []
  253. var dates = {}
  254. for (var i = 0; i < list.length; i++) {
  255. var d = list[i]
  256. if (d.checkinDate) {
  257. var day = parseInt(d.checkinDate.substring(8, 10))
  258. dates[day] = true
  259. }
  260. }
  261. this.checkinDates = dates
  262. this.calcStreak(list)
  263. }
  264. } catch (e) {
  265. console.error('加载日历数据失败', e)
  266. }
  267. },
  268. calcStreak: function(list) {
  269. if (!list || list.length === 0) { this.streakDays = 0; return }
  270. var dates = []
  271. for (var i = 0; i < list.length; i++) {
  272. if (list[i].checkinDate) dates.push(list[i].checkinDate)
  273. }
  274. dates.sort().reverse()
  275. var streak = 0
  276. var today = new Date()
  277. var checkDate = new Date(today.getFullYear(), today.getMonth(), today.getDate())
  278. for (var j = 0; j < dates.length; j++) {
  279. var d = new Date(dates[j])
  280. var diff = Math.round((checkDate - d) / 86400000)
  281. if (diff === streak) {
  282. streak++
  283. } else if (diff > streak) {
  284. break
  285. }
  286. }
  287. this.streakDays = streak
  288. },
  289. calcTodayCount: function() {
  290. var today = new Date()
  291. var todayStr = today.toISOString().split('T')[0]
  292. this.todayCount = this.checkins.filter(function(c) {
  293. if (!c.createdAt) return false
  294. return c.createdAt.indexOf(todayStr) === 0
  295. }).length
  296. },
  297. changeMonth: function(delta) {
  298. this.currentMonth += delta
  299. if (this.currentMonth > 12) { this.currentMonth = 1; this.currentYear++ }
  300. if (this.currentMonth < 1) { this.currentMonth = 12; this.currentYear-- }
  301. this.loadCalendarData()
  302. },
  303. onDayClick: function(day) {
  304. // 点击日期跳转到当天或者查看记录
  305. },
  306. toggleRecords: function() {
  307. this.showAllRecords = !this.showAllRecords
  308. },
  309. selectMood: function(value) {
  310. this.formMood = value
  311. },
  312. closeDialog: function() {
  313. this.showDialog = false
  314. },
  315. showCheckinDialog: function(type) {
  316. this.dialogType = type
  317. this.formAmount = ''
  318. this.formCategory = ''
  319. this.formNote = ''
  320. this.formMood = ''
  321. this.showDialog = true
  322. },
  323. onCategoryChange: function(e) {
  324. this.formCategory = this.categoryOptions[e.detail.value]
  325. },
  326. async doCheckin() {
  327. if (this.todayTimer) {
  328. uni.showToast({ title: '操作太频繁,请稍后', icon: 'none' })
  329. return
  330. }
  331. this.todayTimer = setTimeout(function() { this.todayTimer = null }.bind(this), 3000)
  332. var params = {
  333. checkinType: this.dialogType,
  334. checkinDate: new Date().toISOString().split('T')[0],
  335. earnedEnergy: 5
  336. }
  337. if (this.childId) params.childId = this.childId
  338. if (this.formAmount) params.amount = parseInt(this.formAmount)
  339. if (this.formCategory) params.category = this.formCategory
  340. if (this.formNote) params.note = this.formNote
  341. if (this.formMood) params.mood = this.formMood
  342. try {
  343. var res = await createCheckin(params)
  344. if (res && res.code === 200) {
  345. this.showDialog = false
  346. this.earnedEnergy = 5
  347. this.showEnergyAnim = true
  348. setTimeout(function() { this.showEnergyAnim = false }.bind(this), 1500)
  349. uni.showToast({ title: '打卡成功!+5能量', icon: 'success' })
  350. this.loadData()
  351. this.loadCalendarData()
  352. } else {
  353. uni.showToast({ title: res && res.message || '打卡失败', icon: 'none' })
  354. }
  355. } catch (e) {
  356. uni.showToast({ title: '网络错误', icon: 'none' })
  357. }
  358. },
  359. confirmDelete: function(item) {
  360. var self = this
  361. uni.showModal({
  362. title: '提示',
  363. content: '确定删除此打卡记录?',
  364. success: function(res) {
  365. if (res.confirm) self.deleteRecord(item)
  366. }
  367. })
  368. },
  369. async deleteRecord(item) {
  370. try {
  371. var res = await deleteCheckin(item.id)
  372. if (res && res.code === 200) {
  373. uni.showToast({ title: '已删除', icon: 'success' })
  374. this.loadData()
  375. this.loadCalendarData()
  376. }
  377. } catch (e) {
  378. console.error('删除失败', e)
  379. }
  380. },
  381. getTypeIcon: function(type) {
  382. var map = { daily_plan: '📋', saving: '💰', expense: '🛒', review: '📊' }
  383. return map[type] || '📌'
  384. },
  385. getTypeLabel: function(type) {
  386. var map = { daily_plan: '记账规划', saving: '储蓄记录', expense: '消费记录', review: '财务回顾' }
  387. return map[type] || type
  388. },
  389. formatDate: function(dateStr) {
  390. if (!dateStr) return ''
  391. return dateStr.substring(0, 16).replace('T', ' ')
  392. },
  393. getEmptyKey: function(i) {
  394. return 'e' + i
  395. },
  396. getDayKey: function(i) {
  397. return 'd' + i
  398. }
  399. }
  400. }
  401. </script>
  402. <style>
  403. .container { min-height: 100vh; background: #f5f7fa; padding: 20rpx 30rpx 120rpx; }
  404. /* 能量提示 */
  405. .energy-tip {
  406. background: linear-gradient(135deg, #F59E0B, #F97316);
  407. border-radius: 20rpx; padding: 24rpx 30rpx; margin-bottom: 24rpx;
  408. }
  409. .energy-tip-text { color: #fff; font-size: 28rpx; font-weight: 600; }
  410. .energy-tip-sub { color: rgba(255,255,255,0.8); font-size: 22rpx; margin-top: 6rpx; }
  411. /* 日历 */
  412. .calendar-card {
  413. background: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 24rpx;
  414. }
  415. .calendar-header {
  416. display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx;
  417. }
  418. .calendar-title { font-size: 32rpx; font-weight: 700; color: #333; }
  419. .nav-btn { font-size: 40rpx; color: #F97316; padding: 0 16rpx; }
  420. .calendar-weekdays {
  421. display: flex; flex-direction: row; margin-bottom: 12rpx;
  422. }
  423. .weekday { width: 14.28%; text-align: center; font-size: 24rpx; color: #999; }
  424. .calendar-days { display: flex; flex-direction: row; flex-wrap: wrap; }
  425. .day-cell {
  426. width: 14.28%; height: 72rpx; display: flex; flex-direction: column;
  427. align-items: center; justify-content: center; position: relative;
  428. }
  429. .day-cell.today .day-num {
  430. background: #F97316; color: #fff; border-radius: 50%;
  431. width: 48rpx; height: 48rpx; text-align: center; line-height: 48rpx;
  432. }
  433. .day-num { font-size: 26rpx; color: #333; }
  434. .day-dot { font-size: 16rpx; color: #F97316; position: absolute; bottom: 4rpx; }
  435. .day-cell.empty { height: 72rpx; }
  436. .calendar-stats {
  437. display: flex; justify-content: space-between; margin-top: 16rpx;
  438. padding-top: 16rpx; border-top: 2rpx solid #f5f5f5;
  439. font-size: 24rpx; color: #999;
  440. }
  441. .stat-highlight { color: #F97316; font-weight: 700; font-size: 28rpx; }
  442. /* 快速打卡 */
  443. .quick-checkin { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 24rpx; }
  444. .section-title-row {
  445. display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx;
  446. }
  447. .section-title { font-size: 30rpx; font-weight: 700; color: #333; }
  448. .section-subtitle { font-size: 22rpx; color: #999; }
  449. .checkin-types { display: flex; flex-direction: row; justify-content: space-between; }
  450. .checkin-btn {
  451. display: flex; flex-direction: column; align-items: center; justify-content: center;
  452. width: 150rpx; height: 130rpx; background: #FFFBEB; border-radius: 16rpx;
  453. border: 2rpx solid #FDE68A;
  454. }
  455. .checkin-btn:active { background: #FEF3C7; }
  456. .checkin-btn.disabled { opacity: 0.5; }
  457. .checkin-icon { font-size: 44rpx; margin-bottom: 8rpx; }
  458. .checkin-label { font-size: 24rpx; color: #92400E; font-weight: 500; }
  459. /* 统计 */
  460. .stats-card {
  461. background: #fff; border-radius: 20rpx; padding: 24rpx;
  462. display: flex; flex-direction: row; justify-content: space-around;
  463. align-items: center; margin-bottom: 24rpx;
  464. }
  465. .stat-item { display: flex; flex-direction: column; align-items: center; }
  466. .stat-value { font-size: 38rpx; font-weight: 800; color: #F59E0B; }
  467. .stat-label { font-size: 22rpx; color: #999; margin-top: 4rpx; }
  468. .stat-divider { width: 2rpx; height: 60rpx; background: #eee; }
  469. /* 记录 */
  470. .record-section { background: #fff; border-radius: 20rpx; padding: 24rpx; }
  471. .empty-state { text-align: center; padding: 48rpx 0; }
  472. .empty-icon { font-size: 48rpx; }
  473. .empty-text { color: #999; font-size: 26rpx; margin-top: 12rpx; }
  474. .record-item {
  475. display: flex; flex-direction: row; justify-content: space-between;
  476. align-items: center; padding: 20rpx 0; border-bottom: 2rpx solid #f5f5f5;
  477. }
  478. .record-item:last-child { border-bottom: none; }
  479. .record-left { display: flex; flex-direction: row; align-items: center; flex: 1; }
  480. .record-icon { font-size: 36rpx; margin-right: 16rpx; }
  481. .record-info { display: flex; flex-direction: column; }
  482. .record-type { font-size: 28rpx; color: #333; font-weight: 500; }
  483. .record-date { font-size: 22rpx; color: #999; margin-top: 2rpx; }
  484. .record-note { font-size: 22rpx; color: #666; margin-top: 2rpx; max-width: 300rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  485. .record-right { display: flex; flex-direction: column; align-items: flex-end; }
  486. .record-energy { font-size: 28rpx; color: #F59E0B; font-weight: 700; }
  487. .record-mood { font-size: 28rpx; margin-top: 4rpx; }
  488. /* 打卡弹窗 */
  489. .dialog-mask {
  490. position: fixed; top: 0; left: 0; right: 0; bottom: 0;
  491. background: rgba(0,0,0,0.5); z-index: 999; display: flex;
  492. align-items: center; justify-content: center;
  493. }
  494. .dialog-card {
  495. background: #fff; border-radius: 24rpx; width: 620rpx; padding: 32rpx;
  496. }
  497. .dialog-title { font-size: 34rpx; font-weight: 700; color: #333; text-align: center; margin-bottom: 28rpx; }
  498. .form-item { margin-bottom: 24rpx; }
  499. .form-label { font-size: 26rpx; color: #666; margin-bottom: 8rpx; display: block; }
  500. .form-input {
  501. border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 16rpx 20rpx;
  502. font-size: 26rpx; width: 100%; box-sizing: border-box;
  503. }
  504. .form-picker {
  505. border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 16rpx 20rpx;
  506. font-size: 26rpx; color: #333;
  507. }
  508. .picker-text { color: #333; }
  509. .mood-picker { display: flex; flex-direction: row; gap: 16rpx; }
  510. .mood-item { font-size: 44rpx; padding: 8rpx; border-radius: 12rpx; border: 2rpx solid transparent; }
  511. .mood-item.selected { border-color: #F59E0B; background: #FFFBEB; }
  512. .dialog-actions { display: flex; flex-direction: row; gap: 20rpx; margin-top: 32rpx; }
  513. .dialog-btn { flex: 1; padding: 20rpx; border-radius: 12rpx; font-size: 28rpx; text-align: center; }
  514. .dialog-btn.cancel { background: #f5f5f5; color: #666; }
  515. .dialog-btn.confirm { background: linear-gradient(135deg, #F59E0B, #F97316); color: #fff; }
  516. /* 能量动画 */
  517. .energy-animation {
  518. position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  519. z-index: 1000; pointer-events: none;
  520. }
  521. .energy-anim-text {
  522. font-size: 80rpx; font-weight: 900; color: #F59E0B;
  523. text-shadow: 0 4rpx 20rpx rgba(245,158,11,0.5);
  524. animation: floatUp 1.5s ease-out forwards;
  525. }
  526. @keyframes floatUp {
  527. 0% { opacity: 1; transform: scale(0.5); }
  528. 50% { opacity: 1; transform: scale(1.2); }
  529. 100% { opacity: 0; transform: translateY(-200rpx) scale(1); }
  530. }
  531. </style>