emotion-checkin.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. <template>
  2. <view class="checkin-container">
  3. <!-- 顶部:日期 + 连续打卡 -->
  4. <view class="checkin-header">
  5. <text class="checkin-date">{{ todayDisplay }}</text>
  6. <view class="streak-badge" v-if="stats && stats.currentStreak > 0">
  7. <text class="streak-icon">&#x1F525;</text>
  8. <text class="streak-text">连续{{ stats.currentStreak }}天</text>
  9. </view>
  10. </view>
  11. <!-- 天气选择 -->
  12. <view class="section">
  13. <text class="section-title">今天心情怎么样?</text>
  14. <view class="weather-grid">
  15. <view
  16. v-for="w in weatherOptions"
  17. :key="w.value"
  18. :class="['weather-btn', selectedWeather === w.value ? 'weather-active' : '']"
  19. :style="{ borderColor: selectedWeather === w.value ? w.color : 'transparent' }"
  20. @click="selectedWeather = w.value">
  21. <text class="weather-icon">{{ w.icon }}</text>
  22. <text class="weather-label">{{ w.label }}</text>
  23. </view>
  24. </view>
  25. </view>
  26. <!-- 情绪标签 -->
  27. <view class="section">
  28. <text class="section-title">情绪标签(可多选)</text>
  29. <view class="tag-grid">
  30. <view
  31. v-for="tag in emotionTags"
  32. :key="tag"
  33. :class="['tag-btn', selectedTags.indexOf(tag) !== -1 ? 'tag-active' : '']"
  34. @click="toggleTag(tag)">
  35. <text>{{ tag }}</text>
  36. </view>
  37. </view>
  38. <view class="custom-tag-row" v-if="showCustomTagInput">
  39. <input class="custom-tag-input" v-model="customTag" placeholder="输入自定义情绪" />
  40. <text class="custom-tag-add" @click="addCustomTag">添加</text>
  41. </view>
  42. <text class="custom-tag-trigger" v-if="!showCustomTagInput" @click="showCustomTagInput = true">+ 自定义标签</text>
  43. </view>
  44. <!-- 拍照 -->
  45. <view class="section">
  46. <text class="section-title">拍一张照片记录心情</text>
  47. <view class="photo-area" @click="takePhoto">
  48. <image v-if="photoPath" :src="photoPath" mode="aspectFill" class="photo-preview" />
  49. <view v-else class="photo-placeholder">
  50. <text class="photo-icon">&#x1F4F7;</text>
  51. <text class="photo-hint">点击拍照</text>
  52. </view>
  53. </view>
  54. <text class="photo-note">照片仅对自己可见</text>
  55. </view>
  56. <!-- AI 情绪识别 -->
  57. <view class="section" v-if="photoPath">
  58. <text class="section-title">\uD83E\uDD16 AI 情绪识别</text>
  59. <view class="ai-recognize-btn" v-if="!aiResult && !aiAnalyzing" @click="analyzeEmotion">
  60. <text class="ai-btn-icon">\uD83D\uDD0D</text>
  61. <text class="ai-btn-text">识别照片中的情绪</text>
  62. </view>
  63. <view class="ai-analyzing" v-if="aiAnalyzing">
  64. <text class="ai-loading-text">AI 分析中...</text>
  65. </view>
  66. <view class="ai-result" v-if="aiResult">
  67. <text class="ai-result-title">识别结果</text>
  68. <view class="ai-emotion-list">
  69. <view class="ai-emotion-item" v-for="em in aiResult" :key="em.label">
  70. <text class="ai-emotion-label">{{ em.label }}</text>
  71. <view class="ai-emotion-bar-track">
  72. <view class="ai-emotion-bar-fill" :style="{ width: em.confidence + '%', background: em.color }"></view>
  73. </view>
  74. <text class="ai-emotion-value">{{ em.confidence }}%</text>
  75. </view>
  76. </view>
  77. <view class="ai-apply-tags" @click="applyAiTags">
  78. <text class="ai-apply-text">应用为情绪标签</text>
  79. </view>
  80. </view>
  81. </view>
  82. <!-- 文字记录 -->
  83. <view class="section">
  84. <text class="section-title">记录一下(选填)</text>
  85. <textarea
  86. class="note-input"
  87. v-model="note"
  88. placeholder="今天发生了什么?"
  89. maxlength="500" />
  90. </view>
  91. <!-- 提交按钮 -->
  92. <view class="submit-area">
  93. <button
  94. class="submit-btn"
  95. :class="{ 'submit-disabled': !canSubmit }"
  96. :disabled="!canSubmit"
  97. @click="submitCheckin">
  98. <text class="submit-icon">&#x2764;</text>
  99. <text>记录心情</text>
  100. </button>
  101. </view>
  102. <!-- 提交中 loading -->
  103. <view class="loading-mask" v-if="submitting">
  104. <view class="loading-box">
  105. <text class="loading-text">记录中...</text>
  106. </view>
  107. </view>
  108. </view>
  109. </template>
  110. <script>
  111. import config from '../../config'
  112. export default {
  113. data() {
  114. return {
  115. selectedWeather: '',
  116. selectedTags: [],
  117. customTag: '',
  118. showCustomTagInput: false,
  119. photoPath: '',
  120. note: '',
  121. submitting: false,
  122. aiAnalyzing: false,
  123. aiResult: null,
  124. aiEmotionColors: {
  125. '开心': '#10B981',
  126. '平静': '#3B82F6',
  127. '惊喜': '#8B5CF6',
  128. '难过': '#6B9BD2',
  129. '生气': '#EF4444',
  130. '紧张': '#F59E0B',
  131. '疲惫': '#9CA3AF',
  132. '害怕': '#7C3AED'
  133. },
  134. stats: null,
  135. weatherOptions: [
  136. { value: 'sunny', icon: '\u2600\uFE0F', label: '心情很好', color: '#FFB347' },
  137. { value: 'cloudy', icon: '\u26C5', label: '一般般', color: '#A0A0A0' },
  138. { value: 'rainy', icon: '\uD83C\uDF27\uFE0F', label: '有点难过', color: '#6B9BD2' },
  139. { value: 'stormy', icon: '\u26A1', label: '很烦躁', color: '#8B5CF6' },
  140. { value: 'rainbow', icon: '\uD83C\uDF08', label: '特别开心', color: '#FF6B9D' }
  141. ],
  142. emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕']
  143. }
  144. },
  145. computed: {
  146. todayDisplay() {
  147. const now = new Date()
  148. const y = now.getFullYear()
  149. const m = now.getMonth() + 1
  150. const d = now.getDate()
  151. const weekdays = ['日', '一', '二', '三', '四', '五', '六']
  152. return y + '年' + m + '月' + d + '日 周' + weekdays[now.getDay()]
  153. },
  154. canSubmit() {
  155. return !!this.selectedWeather
  156. }
  157. },
  158. onLoad() {
  159. this.loadStats()
  160. },
  161. methods: {
  162. loadStats() {
  163. const app = getApp()
  164. const childId = app.globalData.currentChildId
  165. if (!childId) return
  166. uni.request({
  167. url: config.API_BASE_URL + '/api/mind/checkin/stats',
  168. method: 'POST',
  169. data: { childId },
  170. success: (res) => {
  171. if (res.data && res.data.code === 200 && res.data.data) {
  172. this.stats = res.data.data
  173. }
  174. }
  175. })
  176. },
  177. toggleTag(tag) {
  178. const idx = this.selectedTags.indexOf(tag)
  179. if (idx !== -1) {
  180. this.selectedTags.splice(idx, 1)
  181. } else {
  182. this.selectedTags.push(tag)
  183. }
  184. },
  185. addCustomTag() {
  186. const tag = this.customTag.trim()
  187. if (tag && this.emotionTags.indexOf(tag) === -1 && this.selectedTags.indexOf(tag) === -1) {
  188. this.selectedTags.push(tag)
  189. }
  190. this.customTag = ''
  191. this.showCustomTagInput = false
  192. },
  193. takePhoto() {
  194. uni.chooseImage({
  195. count: 1,
  196. sourceType: ['camera', 'album'],
  197. success: (res) => {
  198. this.photoPath = res.tempFilePaths[0]
  199. this.aiResult = null
  200. }
  201. })
  202. },
  203. analyzeEmotion() {
  204. this.aiAnalyzing = true
  205. this.aiResult = null
  206. // Simulate AI emotion analysis (in production, call a vision API)
  207. var self = this
  208. setTimeout(function() {
  209. self.aiAnalyzing = false
  210. // Simulated result based on the selected weather/mood as hint
  211. var baseEmotions = [
  212. { label: '开心', confidence: 45, color: '#10B981' },
  213. { label: '平静', confidence: 30, color: '#3B82F6' },
  214. { label: '惊喜', confidence: 15, color: '#8B5CF6' },
  215. { label: '疲惫', confidence: 10, color: '#9CA3AF' }
  216. ]
  217. // Adjust based on weather selection if available
  218. if (self.selectedWeather === 'rainy') {
  219. baseEmotions = [
  220. { label: '难过', confidence: 55, color: '#6B9BD2' },
  221. { label: '疲惫', confidence: 25, color: '#9CA3AF' },
  222. { label: '平静', confidence: 12, color: '#3B82F6' },
  223. { label: '生气', confidence: 8, color: '#EF4444' }
  224. ]
  225. } else if (self.selectedWeather === 'stormy') {
  226. baseEmotions = [
  227. { label: '生气', confidence: 48, color: '#EF4444' },
  228. { label: '紧张', confidence: 32, color: '#F59E0B' },
  229. { label: '害怕', confidence: 12, color: '#7C3AED' },
  230. { label: '开心', confidence: 8, color: '#10B981' }
  231. ]
  232. } else if (self.selectedWeather === 'rainbow') {
  233. baseEmotions = [
  234. { label: '开心', confidence: 60, color: '#10B981' },
  235. { label: '惊喜', confidence: 25, color: '#8B5CF6' },
  236. { label: '平静', confidence: 10, color: '#3B82F6' },
  237. { label: '期待', confidence: 5, color: '#F59E0B' }
  238. ]
  239. }
  240. self.aiResult = baseEmotions
  241. }, 1200)
  242. },
  243. applyAiTags() {
  244. if (!this.aiResult) return
  245. var detected = []
  246. for (var i = 0; i < this.aiResult.length; i++) {
  247. var em = this.aiResult[i]
  248. // Only apply high-confidence emotions as tags
  249. if (em.confidence >= 20 && this.emotionTags.indexOf(em.label) !== -1) {
  250. detected.push(em.label)
  251. }
  252. }
  253. // Merge with existing selections (no duplicates)
  254. var merged = this.selectedTags.slice()
  255. for (var j = 0; j < detected.length; j++) {
  256. if (merged.indexOf(detected[j]) === -1) {
  257. merged.push(detected[j])
  258. }
  259. }
  260. this.selectedTags = merged
  261. uni.showToast({ title: '已应用情绪标签', icon: 'success' })
  262. },
  263. submitCheckin() {
  264. if (!this.canSubmit || this.submitting) return
  265. this.submitting = true
  266. const app = getApp()
  267. const childId = app.globalData.currentChildId
  268. // Upload photo first if exists, then submit
  269. const doSubmit = (photoUrl) => {
  270. uni.request({
  271. url: config.API_BASE_URL + '/api/mind/checkin/create',
  272. method: 'POST',
  273. data: {
  274. childId: childId,
  275. moodWeather: this.selectedWeather,
  276. emotionTags: JSON.stringify(this.selectedTags),
  277. photoUrl: photoUrl || '',
  278. note: this.note
  279. },
  280. success: (res) => {
  281. this.submitting = false
  282. if (res.data && res.data.code === 200) {
  283. uni.showToast({
  284. title: '+5 \u5FC3\u80FD\u91CF',
  285. icon: 'none',
  286. duration: 2000
  287. })
  288. setTimeout(() => {
  289. uni.navigateTo({ url: '/pages/mind/emotion-trend' })
  290. }, 1500)
  291. } else {
  292. uni.showToast({ title: res.data && res.data.message || '提交失败', icon: 'none' })
  293. }
  294. },
  295. fail: () => {
  296. this.submitting = false
  297. uni.showToast({ title: '网络错误', icon: 'none' })
  298. }
  299. })
  300. }
  301. if (this.photoPath) {
  302. uni.uploadFile({
  303. url: config.API_BASE_URL + '/api/upload',
  304. filePath: this.photoPath,
  305. name: 'file',
  306. success: (uploadRes) => {
  307. let photoUrl = ''
  308. try {
  309. const data = JSON.parse(uploadRes.data)
  310. photoUrl = data.data || data.url || ''
  311. } catch (e) {
  312. photoUrl = uploadRes.data
  313. }
  314. doSubmit(photoUrl)
  315. },
  316. fail: () => doSubmit('')
  317. })
  318. } else {
  319. doSubmit('')
  320. }
  321. }
  322. }
  323. }
  324. </script>
  325. <style scoped>
  326. .checkin-container {
  327. min-height: 100vh;
  328. background: linear-gradient(180deg, #FFF0F5 0%, #FFFFFF 100%);
  329. padding-bottom: 40rpx;
  330. }
  331. .checkin-header {
  332. display: flex;
  333. align-items: center;
  334. justify-content: space-between;
  335. padding: 30rpx 32rpx;
  336. }
  337. .checkin-date {
  338. font-size: 32rpx;
  339. font-weight: 600;
  340. color: #333;
  341. }
  342. .streak-badge {
  343. display: flex;
  344. align-items: center;
  345. background: linear-gradient(135deg, #FF6B9D, #FF8CB3);
  346. border-radius: 30rpx;
  347. padding: 8rpx 24rpx;
  348. }
  349. .streak-icon {
  350. font-size: 28rpx;
  351. margin-right: 8rpx;
  352. }
  353. .streak-text {
  354. font-size: 24rpx;
  355. color: #fff;
  356. font-weight: 500;
  357. }
  358. .section {
  359. margin: 20rpx 32rpx;
  360. background: #fff;
  361. border-radius: 20rpx;
  362. padding: 28rpx;
  363. box-shadow: 0 2rpx 12rpx rgba(255, 107, 157, 0.08);
  364. }
  365. .section-title {
  366. font-size: 28rpx;
  367. font-weight: 600;
  368. color: #333;
  369. margin-bottom: 20rpx;
  370. display: block;
  371. }
  372. .weather-grid {
  373. display: flex;
  374. flex-wrap: wrap;
  375. gap: 16rpx;
  376. justify-content: center;
  377. }
  378. .weather-btn {
  379. display: flex;
  380. flex-direction: column;
  381. align-items: center;
  382. width: 120rpx;
  383. padding: 16rpx 8rpx;
  384. border-radius: 16rpx;
  385. border: 3rpx solid transparent;
  386. background: #FAFAFA;
  387. transition: all 0.2s;
  388. }
  389. .weather-active {
  390. background: #FFF0F5;
  391. }
  392. .weather-icon {
  393. font-size: 44rpx;
  394. margin-bottom: 8rpx;
  395. }
  396. .weather-label {
  397. font-size: 22rpx;
  398. color: #666;
  399. }
  400. .tag-grid {
  401. display: flex;
  402. flex-wrap: wrap;
  403. gap: 16rpx;
  404. }
  405. .tag-btn {
  406. padding: 12rpx 28rpx;
  407. border-radius: 30rpx;
  408. background: #F5F5F5;
  409. font-size: 26rpx;
  410. color: #666;
  411. border: 2rpx solid transparent;
  412. }
  413. .tag-active {
  414. background: #FFF0F5;
  415. color: #FF6B9D;
  416. border-color: #FF6B9D;
  417. }
  418. .custom-tag-row {
  419. display: flex;
  420. align-items: center;
  421. margin-top: 16rpx;
  422. gap: 16rpx;
  423. }
  424. .custom-tag-input {
  425. flex: 1;
  426. height: 60rpx;
  427. border: 2rpx solid #E0E0E0;
  428. border-radius: 12rpx;
  429. padding: 0 16rpx;
  430. font-size: 26rpx;
  431. }
  432. .custom-tag-add {
  433. padding: 12rpx 24rpx;
  434. background: #FF6B9D;
  435. color: #fff;
  436. border-radius: 12rpx;
  437. font-size: 26rpx;
  438. }
  439. .custom-tag-trigger {
  440. display: inline-block;
  441. margin-top: 16rpx;
  442. color: #FF6B9D;
  443. font-size: 26rpx;
  444. }
  445. .photo-area {
  446. width: 200rpx;
  447. height: 200rpx;
  448. border-radius: 16rpx;
  449. overflow: hidden;
  450. background: #F5F5F5;
  451. display: flex;
  452. align-items: center;
  453. justify-content: center;
  454. }
  455. .photo-preview {
  456. width: 100%;
  457. height: 100%;
  458. }
  459. .photo-placeholder {
  460. display: flex;
  461. flex-direction: column;
  462. align-items: center;
  463. }
  464. .photo-icon {
  465. font-size: 60rpx;
  466. }
  467. .photo-hint {
  468. font-size: 24rpx;
  469. color: #999;
  470. margin-top: 8rpx;
  471. }
  472. .photo-note {
  473. display: block;
  474. font-size: 22rpx;
  475. color: #999;
  476. margin-top: 12rpx;
  477. }
  478. /* ===== AI 情绪识别 ===== */
  479. .ai-recognize-btn {
  480. display: flex;
  481. align-items: center;
  482. justify-content: center;
  483. padding: 20rpx;
  484. background: linear-gradient(135deg, #8B5CF6, #A78BFA);
  485. border-radius: 16rpx;
  486. cursor: pointer;
  487. }
  488. .ai-btn-icon {
  489. font-size: 32rpx;
  490. margin-right: 10rpx;
  491. }
  492. .ai-btn-text {
  493. font-size: 26rpx;
  494. color: #fff;
  495. font-weight: 500;
  496. }
  497. .ai-recognize-btn:active {
  498. opacity: 0.8;
  499. }
  500. .ai-analyzing {
  501. display: flex;
  502. align-items: center;
  503. justify-content: center;
  504. padding: 30rpx 0;
  505. }
  506. .ai-loading-text {
  507. font-size: 26rpx;
  508. color: #8B5CF6;
  509. }
  510. .ai-result {
  511. margin-top: 16rpx;
  512. }
  513. .ai-result-title {
  514. font-size: 24rpx;
  515. color: #666;
  516. margin-bottom: 12rpx;
  517. display: block;
  518. }
  519. .ai-emotion-list {
  520. display: flex;
  521. flex-direction: column;
  522. gap: 12rpx;
  523. }
  524. .ai-emotion-item {
  525. display: flex;
  526. align-items: center;
  527. gap: 12rpx;
  528. }
  529. .ai-emotion-label {
  530. font-size: 24rpx;
  531. color: #333;
  532. width: 64rpx;
  533. flex-shrink: 0;
  534. }
  535. .ai-emotion-bar-track {
  536. flex: 1;
  537. height: 16rpx;
  538. background: #F0F0F0;
  539. border-radius: 8rpx;
  540. overflow: hidden;
  541. }
  542. .ai-emotion-bar-fill {
  543. height: 100%;
  544. border-radius: 8rpx;
  545. transition: width 0.5s;
  546. }
  547. .ai-emotion-value {
  548. font-size: 22rpx;
  549. color: #999;
  550. width: 56rpx;
  551. text-align: right;
  552. }
  553. .ai-apply-tags {
  554. margin-top: 20rpx;
  555. padding: 16rpx;
  556. background: #F5F0FF;
  557. border-radius: 12rpx;
  558. text-align: center;
  559. cursor: pointer;
  560. }
  561. .ai-apply-text {
  562. font-size: 26rpx;
  563. color: #8B5CF6;
  564. font-weight: 500;
  565. }
  566. .ai-apply-tags:active {
  567. opacity: 0.7;
  568. }
  569. .note-input {
  570. width: 100%;
  571. min-height: 140rpx;
  572. background: #F9F9F9;
  573. border-radius: 12rpx;
  574. padding: 20rpx;
  575. font-size: 26rpx;
  576. color: #333;
  577. box-sizing: border-box;
  578. }
  579. .submit-area {
  580. margin: 40rpx 32rpx;
  581. }
  582. .submit-btn {
  583. width: 100%;
  584. height: 88rpx;
  585. line-height: 88rpx;
  586. background: linear-gradient(135deg, #FF6B9D, #FF8CB3);
  587. color: #fff;
  588. border-radius: 44rpx;
  589. font-size: 30rpx;
  590. font-weight: 600;
  591. display: flex;
  592. align-items: center;
  593. justify-content: center;
  594. }
  595. .submit-disabled {
  596. opacity: 0.5;
  597. }
  598. .submit-icon {
  599. margin-right: 8rpx;
  600. }
  601. .loading-mask {
  602. position: fixed;
  603. top: 0;
  604. left: 0;
  605. right: 0;
  606. bottom: 0;
  607. background: rgba(0, 0, 0, 0.3);
  608. display: flex;
  609. align-items: center;
  610. justify-content: center;
  611. z-index: 999;
  612. }
  613. .loading-box {
  614. background: #fff;
  615. padding: 40rpx 60rpx;
  616. border-radius: 20rpx;
  617. }
  618. .loading-text {
  619. font-size: 28rpx;
  620. color: #666;
  621. }
  622. </style>