emotion-checkin.vue 27 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070
  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. <!-- ===== Phase 1.1: 2D 效价-激活度网格 ===== -->
  12. <view class="section">
  13. <text class="section-title">在方格中点击你的情绪位置</text>
  14. <view class="affect-grid" @tap="onAffectGridTap">
  15. <!-- 象限标签 -->
  16. <text class="grid-label label-nw">烦躁/紧张</text>
  17. <text class="grid-label label-ne">兴奋/愉快</text>
  18. <text class="grid-label label-sw">低落/疲惫</text>
  19. <text class="grid-label label-se">平静/放松</text>
  20. <!-- 坐标轴 -->
  21. <text class="axis-label axis-bottom">消极 ← 情绪效价 → 积极</text>
  22. <text class="axis-label axis-left">激<br/>活<br/>度</text>
  23. <!-- 选中的圆点 -->
  24. <view class="affect-dot" :style="{ left: affectDotX + 'rpx', top: affectDotY + 'rpx' }">
  25. <view class="affect-dot-inner"></view>
  26. </view>
  27. </view>
  28. <view class="affect-values">
  29. <text class="affect-val">效价: {{ affectValence }}/10</text>
  30. <text class="affect-val">激活度: {{ affectArousal }}/10</text>
  31. </view>
  32. </view>
  33. <!-- ===== Plutchik 情绪轮 ===== -->
  34. <view class="section">
  35. <text class="section-title">选择你的核心情绪</text>
  36. <view class="emotion-wheel">
  37. <view
  38. v-for="em in plutchikEmotions"
  39. :key="em.type"
  40. :class="['emotion-btn', selectedEmotionType === em.type ? 'emotion-active' : '']"
  41. :style="{ borderColor: selectedEmotionType === em.type ? em.color : '#eee' }"
  42. @click="selectedEmotionType = em.type">
  43. <text class="emotion-icon">{{ em.icon }}</text>
  44. <text class="emotion-label">{{ em.label }}</text>
  45. </view>
  46. </view>
  47. </view>
  48. <!-- ===== 强度滑条 ===== -->
  49. <view class="section">
  50. <text class="section-title">情绪强度</text>
  51. <view class="slider-row">
  52. <text class="slider-label">弱</text>
  53. <slider class="custom-slider" min="1" max="10" :value="intensityLevel" @change="onIntensityChange" activeColor="#FF6B9D" block-size="20" />
  54. <text class="slider-value">{{ intensityLevel }}</text>
  55. </view>
  56. <view class="intensity-hint">{{ intensityHint }}</view>
  57. </view>
  58. <!-- ===== 精力水平 ===== -->
  59. <view class="section">
  60. <text class="section-title">精力水平</text>
  61. <view class="slider-row">
  62. <text class="slider-label">低</text>
  63. <slider class="custom-slider" min="1" max="10" :value="energyLevel" @change="onEnergyChange" activeColor="#10B981" block-size="20" />
  64. <text class="slider-value">{{ energyLevel }}</text>
  65. </view>
  66. </view>
  67. <!-- ===== 压力水平 ===== -->
  68. <view class="section">
  69. <text class="section-title">压力水平</text>
  70. <view class="slider-row">
  71. <text class="slider-label">无压力</text>
  72. <slider class="custom-slider" min="1" max="10" :value="stressLevel" @change="onStressChange" activeColor="#F59E0B" block-size="20" />
  73. <text class="slider-value">{{ stressLevel }}</text>
  74. </view>
  75. </view>
  76. <!-- ===== 情绪标签(精细标签) ===== -->
  77. <view class="section">
  78. <text class="section-title">情绪标签(可多选)</text>
  79. <view class="tag-grid">
  80. <view
  81. v-for="tag in emotionTags"
  82. :key="tag"
  83. :class="['tag-btn', selectedTags.indexOf(tag) !== -1 ? 'tag-active' : '']"
  84. @click="toggleTag(tag)">
  85. <text>{{ tag }}</text>
  86. </view>
  87. </view>
  88. <view class="custom-tag-row" v-if="showCustomTagInput">
  89. <input class="custom-tag-input" v-model="customTag" placeholder="输入自定义情绪" />
  90. <text class="custom-tag-add" @click="addCustomTag">添加</text>
  91. </view>
  92. <text class="custom-tag-trigger" v-if="!showCustomTagInput" @click="showCustomTagInput = true">+ 自定义标签</text>
  93. </view>
  94. <!-- 拍照 -->
  95. <view class="section">
  96. <text class="section-title">拍一张照片记录心情</text>
  97. <view class="photo-area" @click="takePhoto">
  98. <image v-if="photoPath" :src="photoPath" mode="aspectFill" class="photo-preview" />
  99. <view v-else class="photo-placeholder">
  100. <text class="photo-icon">&#x1F4F7;</text>
  101. <text class="photo-hint">点击拍照</text>
  102. </view>
  103. </view>
  104. <text class="photo-note">照片仅对自己可见</text>
  105. </view>
  106. <!-- 情绪识别 -->
  107. <view class="section" v-if="photoPath">
  108. <text class="section-title">\uD83E\uDD16 情绪识别</text>
  109. <view class="ai-recognize-btn" v-if="!aiResult && !aiAnalyzing" @click="analyzeEmotion">
  110. <text class="ai-btn-icon">\uD83D\uDD0D</text>
  111. <text class="ai-btn-text">识别照片中的情绪</text>
  112. </view>
  113. <view class="ai-analyzing" v-if="aiAnalyzing">
  114. <text class="loading-text">分析中...</text>
  115. </view>
  116. <view class="ai-result" v-if="aiResult">
  117. <text class="ai-result-title">识别结果</text>
  118. <view class="ai-emotion-list">
  119. <view class="ai-emotion-item" v-for="em in aiResult" :key="em.label">
  120. <text class="ai-emotion-label">{{ em.label }}</text>
  121. <view class="ai-emotion-bar-track">
  122. <view class="ai-emotion-bar-fill" :style="{ width: em.confidence + '%', background: em.color }"></view>
  123. </view>
  124. <text class="ai-emotion-value">{{ em.confidence }}%</text>
  125. </view>
  126. </view>
  127. <view class="ai-apply-tags" @click="applyAiTags">
  128. <text class="ai-apply-text">应用为情绪标签</text>
  129. </view>
  130. </view>
  131. </view>
  132. <!-- 文字记录 -->
  133. <view class="section">
  134. <text class="section-title">记录一下(选填)</text>
  135. <textarea
  136. class="note-input"
  137. v-model="note"
  138. placeholder="今天发生了什么?"
  139. maxlength="500" />
  140. </view>
  141. <!-- 提交按钮 -->
  142. <view class="submit-area">
  143. <button
  144. class="submit-btn"
  145. :class="{ 'submit-disabled': !canSubmit }"
  146. :disabled="!canSubmit"
  147. @click="submitCheckin">
  148. <text class="submit-icon">&#x2764;</text>
  149. <text>记录心情</text>
  150. </button>
  151. </view>
  152. <!-- 提交中 loading -->
  153. <view class="loading-mask" v-if="submitting">
  154. <view class="loading-box">
  155. <text class="loading-text">记录中...</text>
  156. </view>
  157. </view>
  158. </view>
  159. </template>
  160. <script>
  161. import config from '../../config'
  162. export default {
  163. data() {
  164. return {
  165. // --- 2D 效价-激活度网格 ---
  166. affectValence: 5, // 1-10
  167. affectArousal: 5, // 1-10
  168. gridWidth: 360, // rpx, 会在 onReady 中动态计算
  169. gridHeight: 360,
  170. // --- Plutchik 情绪轮 ---
  171. selectedEmotionType: '',
  172. // --- 滑条 ---
  173. intensityLevel: 5,
  174. energyLevel: 6,
  175. stressLevel: 4,
  176. // --- 旧字段保留兼容 ---
  177. selectedWeather: 'sunny', // 自动从效价映射
  178. selectedTags: [],
  179. customTag: '',
  180. showCustomTagInput: false,
  181. photoPath: '',
  182. photoUrl: '',
  183. note: '',
  184. submitting: false,
  185. aiAnalyzing: false,
  186. aiResult: null,
  187. aiEmotionColors: {
  188. '开心': '#10B981',
  189. '平静': '#3B82F6',
  190. '惊喜': '#8B5CF6',
  191. '难过': '#6B9BD2',
  192. '生气': '#EF4444',
  193. '紧张': '#F59E0B',
  194. '疲惫': '#9CA3AF',
  195. '害怕': '#7C3AED'
  196. },
  197. stats: null,
  198. // Plutchik 8 基本情绪
  199. plutchikEmotions: [
  200. { type: 'joy', icon: '\uD83D\uDE04', label: '开心', color: '#FFD93D' },
  201. { type: 'trust', icon: '\uD83E\uDD1D', label: '信任', color: '#6BCB77' },
  202. { type: 'fear', icon: '\uD83D\uDE31', label: '恐惧', color: '#9B59B6' },
  203. { type: 'surprise', icon: '\uD83D\uDE32', label: '惊讶', color: '#FF9FF3' },
  204. { type: 'sadness', icon: '\uD83D\uDE22', label: '悲伤', color: '#6B9BD2' },
  205. { type: 'disgust', icon: '\uD83E\uDD2E', label: '厌恶', color: '#A0A0A0' },
  206. { type: 'anger', icon: '\uD83D\uDE21', label: '愤怒', color: '#FF6B6B' },
  207. { type: 'anticipation',icon: '\uD83E\uDD29', label: '期待', color: '#FF8C42' }
  208. ],
  209. emotionTags: ['开心', '期待', '平静', '惊喜', '难过', '生气', '紧张', '无聊', '疲惫', '害怕'],
  210. // 强度提示文案
  211. intensityHints: {
  212. 1: '几乎感觉不到',
  213. 2: '很微弱',
  214. 3: '有一点',
  215. 4: '稍微明显',
  216. 5: '中等强度',
  217. 6: '比较明显',
  218. 7: '相当强烈',
  219. 8: '很强烈',
  220. 9: '非常强烈',
  221. 10: '前所未有的强烈'
  222. }
  223. }
  224. },
  225. computed: {
  226. todayDisplay() {
  227. const now = new Date()
  228. const y = now.getFullYear()
  229. const m = now.getMonth() + 1
  230. const d = now.getDate()
  231. const weekdays = ['日', '一', '二', '三', '四', '五', '六']
  232. return y + '年' + m + '月' + d + '日 周' + weekdays[now.getDay()]
  233. },
  234. canSubmit() {
  235. return this.affectValence > 0 && this.selectedEmotionType
  236. },
  237. // 圆点位置(rpx)
  238. affectDotX() {
  239. // valence 1-10 → 0 to gridWidth
  240. // 留出边距: 从 20 到 gridWidth-20
  241. const margin = 20
  242. const usable = this.gridWidth - 2 * margin
  243. return margin + ((this.affectValence - 1) / 9) * usable
  244. },
  245. affectDotY() {
  246. // arousal 1-10 → gridHeight to 0 (Y轴反向)
  247. const margin = 20
  248. const usable = this.gridHeight - 2 * margin
  249. return margin + (1 - (this.affectArousal - 1) / 9) * usable
  250. },
  251. intensityHint() {
  252. return this.intensityHints[this.intensityLevel] || ''
  253. }
  254. },
  255. onLoad() {
  256. this.loadStats()
  257. },
  258. onReady() {
  259. // 估算 grid 尺寸(基于屏幕宽度)
  260. var sysInfo = uni.getWindowInfo()
  261. // 页面左右margin=32rpx, section padding=28rpx
  262. // gridWidth ≈ screenWidth - (32+28)*2 = screenWidth - 120
  263. var screenWidth = sysInfo.windowWidth
  264. var gridRpx = screenWidth - 120 // 估算 rpx
  265. if (gridRpx < 300) gridRpx = 300
  266. this.gridWidth = gridRpx
  267. this.gridHeight = gridRpx
  268. },
  269. methods: {
  270. loadStats() {
  271. const memberId = uni.getStorageSync('currentChildId')
  272. if (!memberId) return
  273. uni.request({
  274. url: config.API_BASE_URL + '/api/mind/checkin/stats',
  275. method: 'POST',
  276. data: { memberId },
  277. success: (res) => {
  278. if (res.data && res.data.code === 200 && res.data.data) {
  279. this.stats = res.data.data
  280. }
  281. }
  282. })
  283. },
  284. // ===== 2D 效价-激活度网格 =====
  285. onAffectGridTap(e) {
  286. // 获取点击相对位置
  287. var rect = e.target
  288. // 使用 touches 或 detail
  289. var x = e.detail && e.detail.x ? e.detail.x : 0
  290. var y = e.detail && e.detail.y ? e.detail.y : 0
  291. // fallback: 从 pageX/pageY 计算(推荐)
  292. if (e.detail && e.detail.x !== undefined) {
  293. // uni-app 在 @tap 事件中 detail 有 x/y 表示相对于目标元素的偏移
  294. x = e.detail.x
  295. y = e.detail.y
  296. }
  297. // px → rpx 转换
  298. var sysInfo = uni.getWindowInfo()
  299. var scale = 750 / sysInfo.windowWidth
  300. // 直接使用事件中的偏移(px),然后转 rpx
  301. // 注意: 如果 e.detail 没有 x/y,尝试从 e.touches 获取
  302. if ((x === 0 && y === 0) || !e.detail.x) {
  303. var touch = e.touches ? e.touches[0] : null
  304. if (touch) {
  305. // 需要根据 grid 的位置计算相对偏移
  306. x = touch.x || 0
  307. y = touch.y || 0
  308. }
  309. }
  310. // 直接用 rpx 计算,简化版:click 的位置相对 grid 容器
  311. // uni-app 的 @tap 的 detail = {x, y} 是相对于被点击元素的 px 值
  312. var pxX = e.detail.x || 0
  313. var pxY = e.detail.y || 0
  314. var rpxX = pxX * scale
  315. var rpxY = pxY * scale
  316. // 约束在有效范围内
  317. var margin = 20
  318. var maxRpx = this.gridWidth - margin
  319. if (rpxX < margin) rpxX = margin
  320. if (rpxX > maxRpx) rpxX = maxRpx
  321. if (rpxY < margin) rpxY = margin
  322. if (rpxY > this.gridHeight - margin) rpxY = this.gridHeight - margin
  323. // 映射到 1-10
  324. var usable = this.gridWidth - 2 * margin
  325. var val = Math.round(1 + ((rpxX - margin) / usable) * 9)
  326. var aro = Math.round(10 - ((rpxY - margin) / usable) * 9)
  327. this.affectValence = Math.max(1, Math.min(10, val))
  328. this.affectArousal = Math.max(1, Math.min(10, aro))
  329. // 自动映射 moodWeather 用于向后兼容
  330. this.updateWeatherFromAffect()
  331. },
  332. updateWeatherFromAffect() {
  333. // 效价高 + 激活度高 = rainbow
  334. if (this.affectValence >= 7 && this.affectArousal >= 7) {
  335. this.selectedWeather = 'rainbow'
  336. } else if (this.affectValence >= 6) {
  337. // 效价高 = sunny
  338. this.selectedWeather = 'sunny'
  339. } else if (this.affectValence <= 4 && this.affectArousal >= 7) {
  340. // 效价低 + 激活度高 = stormy
  341. this.selectedWeather = 'stormy'
  342. } else if (this.affectValence <= 4 && this.affectArousal <= 4) {
  343. // 效价低 + 激活度低 = rainy
  344. this.selectedWeather = 'rainy'
  345. } else {
  346. this.selectedWeather = 'cloudy'
  347. }
  348. },
  349. // ===== 滑条变化 =====
  350. onIntensityChange(e) {
  351. this.intensityLevel = e.detail.value
  352. },
  353. onEnergyChange(e) {
  354. this.energyLevel = e.detail.value
  355. },
  356. onStressChange(e) {
  357. this.stressLevel = e.detail.value
  358. },
  359. // ===== 情绪标签 =====
  360. toggleTag(tag) {
  361. const idx = this.selectedTags.indexOf(tag)
  362. if (idx !== -1) {
  363. this.selectedTags.splice(idx, 1)
  364. } else {
  365. this.selectedTags.push(tag)
  366. }
  367. },
  368. addCustomTag() {
  369. const tag = this.customTag.trim()
  370. if (tag && this.emotionTags.indexOf(tag) === -1 && this.selectedTags.indexOf(tag) === -1) {
  371. this.selectedTags.push(tag)
  372. }
  373. this.customTag = ''
  374. this.showCustomTagInput = false
  375. },
  376. // ===== 拍照 =====
  377. takePhoto() {
  378. uni.chooseImage({
  379. count: 1,
  380. sourceType: ['camera', 'album'],
  381. success: (res) => {
  382. this.photoPath = res.tempFilePaths[0]
  383. this.aiResult = null
  384. }
  385. })
  386. },
  387. analyzeEmotion() {
  388. var self = this
  389. if (!self.photoPath) {
  390. uni.showToast({ title: '请先拍照', icon: 'none' })
  391. return
  392. }
  393. self.aiAnalyzing = true
  394. self.aiResult = null
  395. uni.uploadFile({
  396. url: config.API_BASE_URL + '/api/media/upload',
  397. filePath: self.photoPath,
  398. name: 'file',
  399. success: function(uploadRes) {
  400. var photoUrl = ''
  401. try {
  402. var data = JSON.parse(uploadRes.data)
  403. photoUrl = data.data || data.url || ''
  404. self.photoUrl = photoUrl
  405. } catch (e) {
  406. photoUrl = uploadRes.data || ''
  407. self.photoUrl = photoUrl
  408. }
  409. if (!photoUrl) {
  410. self.aiAnalyzing = false
  411. uni.showToast({ title: '照片上传失败', icon: 'none' })
  412. return
  413. }
  414. uni.request({
  415. url: config.API_BASE_URL + '/api/mind/checkin/emotion/recognize',
  416. method: 'POST',
  417. data: { image_url: photoUrl },
  418. header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
  419. success: function(res) {
  420. self.aiAnalyzing = false
  421. if (res.data && res.data.code === 200 && res.data.data && res.data.data.length > 0) {
  422. self.aiResult = res.data.data
  423. } else {
  424. self.aiResult = []
  425. uni.showToast({ title: '识别失败,请重试', icon: 'none' })
  426. }
  427. },
  428. fail: function() {
  429. self.aiAnalyzing = false
  430. uni.showToast({ title: '网络错误', icon: 'none' })
  431. }
  432. })
  433. },
  434. fail: function() {
  435. self.aiAnalyzing = false
  436. uni.showToast({ title: '照片上传失败', icon: 'none' })
  437. }
  438. })
  439. },
  440. applyAiTags() {
  441. if (!this.aiResult) return
  442. var detected = []
  443. for (var i = 0; i < this.aiResult.length; i++) {
  444. var em = this.aiResult[i]
  445. if (em.confidence >= 20 && this.emotionTags.indexOf(em.label) !== -1) {
  446. detected.push(em.label)
  447. }
  448. }
  449. var merged = this.selectedTags.slice()
  450. for (var j = 0; j < detected.length; j++) {
  451. if (merged.indexOf(detected[j]) === -1) {
  452. merged.push(detected[j])
  453. }
  454. }
  455. this.selectedTags = merged
  456. uni.showToast({ title: '已应用情绪标签', icon: 'success' })
  457. },
  458. // ===== 提交 =====
  459. submitCheckin() {
  460. if (!this.canSubmit || this.submitting) return
  461. this.submitting = true
  462. const memberId = uni.getStorageSync('currentChildId')
  463. const doSubmit = (photoUrl) => {
  464. uni.request({
  465. url: config.API_BASE_URL + '/api/mind/checkin/create',
  466. method: 'POST',
  467. data: {
  468. memberId: memberId,
  469. // 旧字段向后兼容
  470. moodWeather: this.selectedWeather,
  471. emotionTags: JSON.stringify(this.selectedTags),
  472. photoUrl: photoUrl || '',
  473. note: this.note,
  474. // Phase 1.1 精细情绪字段
  475. moodScore: this.affectValence,
  476. emotionType: this.selectedEmotionType,
  477. stressLevel: this.stressLevel,
  478. arousalLevel: this.affectArousal,
  479. energyLevel: this.energyLevel
  480. },
  481. success: (res) => {
  482. this.submitting = false
  483. if (res.data && res.data.code === 200) {
  484. uni.showToast({
  485. title: '+5 心能量',
  486. icon: 'none',
  487. duration: 2000
  488. })
  489. setTimeout(() => {
  490. uni.navigateTo({ url: '/pages/mind/emotion-trend' })
  491. }, 1500)
  492. } else {
  493. uni.showToast({ title: res.data && res.data.message || '提交失败', icon: 'none' })
  494. }
  495. },
  496. fail: () => {
  497. this.submitting = false
  498. uni.showToast({ title: '网络错误', icon: 'none' })
  499. }
  500. })
  501. }
  502. if (this.photoUrl) {
  503. doSubmit(this.photoUrl)
  504. } else if (this.photoPath) {
  505. uni.uploadFile({
  506. url: config.API_BASE_URL + '/api/media/upload',
  507. filePath: this.photoPath,
  508. name: 'file',
  509. success: (uploadRes) => {
  510. let photoUrl = ''
  511. try {
  512. const data = JSON.parse(uploadRes.data)
  513. photoUrl = data.data || data.url || ''
  514. } catch (e) {
  515. photoUrl = uploadRes.data
  516. }
  517. this.photoUrl = photoUrl
  518. doSubmit(photoUrl)
  519. },
  520. fail: () => doSubmit('')
  521. })
  522. } else {
  523. doSubmit('')
  524. }
  525. }
  526. }
  527. }
  528. </script>
  529. <style scoped>
  530. .checkin-container {
  531. min-height: 100vh;
  532. background: linear-gradient(180deg, #FFF0F5 0%, #FFFFFF 100%);
  533. padding-bottom: 40rpx;
  534. }
  535. .checkin-header {
  536. display: flex;
  537. align-items: center;
  538. justify-content: space-between;
  539. padding: 30rpx 32rpx;
  540. }
  541. .checkin-date {
  542. font-size: 32rpx;
  543. font-weight: 600;
  544. color: #333;
  545. }
  546. .streak-badge {
  547. display: flex;
  548. align-items: center;
  549. background: linear-gradient(135deg, #FF6B9D, #FF8CB3);
  550. border-radius: 30rpx;
  551. padding: 8rpx 24rpx;
  552. }
  553. .streak-icon {
  554. font-size: 28rpx;
  555. margin-right: 8rpx;
  556. }
  557. .streak-text {
  558. font-size: 24rpx;
  559. color: #fff;
  560. font-weight: 500;
  561. }
  562. .section {
  563. margin: 20rpx 32rpx;
  564. background: #fff;
  565. border-radius: 20rpx;
  566. padding: 28rpx;
  567. box-shadow: 0 2rpx 12rpx rgba(255, 107, 157, 0.08);
  568. }
  569. .section-title {
  570. font-size: 28rpx;
  571. font-weight: 600;
  572. color: #333;
  573. margin-bottom: 20rpx;
  574. display: block;
  575. }
  576. /* ===== 2D 效价-激活度网格 ===== */
  577. .affect-grid {
  578. position: relative;
  579. width: 100%;
  580. /* height is set dynamically equal to width via aspect-ratio or inline style */
  581. aspect-ratio: 1;
  582. background: linear-gradient(135deg, #FFF5F5 0%, #FFF0FF 25%, #F0F0FF 50%, #F0FFF4 75%, #FFFFF0 100%);
  583. border-radius: 16rpx;
  584. border: 2rpx solid #E8E8E8;
  585. margin-bottom: 16rpx;
  586. /* Ensure the grid has a defined height — fallback */
  587. min-height: 300rpx;
  588. /* Overlap grid lines for visual clarity */
  589. overflow: hidden;
  590. }
  591. /* 网格线:用伪元素或背景渐变 */
  592. .affect-grid::before {
  593. content: '';
  594. position: absolute;
  595. left: 50%;
  596. top: 0;
  597. bottom: 0;
  598. width: 2rpx;
  599. background: rgba(0,0,0,0.06);
  600. transform: translateX(-50%);
  601. pointer-events: none;
  602. }
  603. .affect-grid::after {
  604. content: '';
  605. position: absolute;
  606. top: 50%;
  607. left: 0;
  608. right: 0;
  609. height: 2rpx;
  610. background: rgba(0,0,0,0.06);
  611. transform: translateY(-50%);
  612. pointer-events: none;
  613. }
  614. .grid-label {
  615. position: absolute;
  616. font-size: 22rpx;
  617. color: rgba(0,0,0,0.25);
  618. pointer-events: none;
  619. }
  620. .label-nw { top: 20rpx; left: 20rpx; }
  621. .label-ne { top: 20rpx; right: 20rpx; }
  622. .label-sw { bottom: 20rpx; left: 20rpx; }
  623. .label-se { bottom: 20rpx; right: 20rpx; }
  624. .axis-label {
  625. position: absolute;
  626. font-size: 20rpx;
  627. color: rgba(0,0,0,0.2);
  628. pointer-events: none;
  629. }
  630. .axis-bottom {
  631. bottom: 4rpx;
  632. left: 50%;
  633. transform: translateX(-50%);
  634. white-space: nowrap;
  635. }
  636. .axis-left {
  637. left: 4rpx;
  638. top: 50%;
  639. transform: translateY(-50%);
  640. font-size: 18rpx;
  641. line-height: 1.4;
  642. text-align: center;
  643. }
  644. .affect-dot {
  645. position: absolute;
  646. width: 44rpx;
  647. height: 44rpx;
  648. margin-left: -22rpx;
  649. margin-top: -22rpx;
  650. pointer-events: none;
  651. z-index: 2;
  652. }
  653. .affect-dot-inner {
  654. width: 100%;
  655. height: 100%;
  656. border-radius: 50%;
  657. background: #FF6B9D;
  658. box-shadow: 0 0 12rpx rgba(255, 107, 157, 0.5);
  659. border: 4rpx solid #fff;
  660. box-sizing: border-box;
  661. }
  662. .affect-values {
  663. display: flex;
  664. justify-content: center;
  665. gap: 40rpx;
  666. }
  667. .affect-val {
  668. font-size: 24rpx;
  669. color: #888;
  670. }
  671. /* ===== Plutchik 情绪轮 ===== */
  672. .emotion-wheel {
  673. display: flex;
  674. flex-wrap: wrap;
  675. gap: 16rpx;
  676. justify-content: center;
  677. }
  678. .emotion-btn {
  679. display: flex;
  680. flex-direction: column;
  681. align-items: center;
  682. width: 130rpx;
  683. padding: 16rpx 8rpx;
  684. border-radius: 20rpx;
  685. border: 3rpx solid #eee;
  686. background: #FAFAFA;
  687. transition: all 0.2s;
  688. }
  689. .emotion-active {
  690. background: #FFF0F5;
  691. transform: scale(1.05);
  692. }
  693. .emotion-icon {
  694. font-size: 48rpx;
  695. margin-bottom: 6rpx;
  696. }
  697. .emotion-label {
  698. font-size: 24rpx;
  699. color: #555;
  700. }
  701. /* ===== 滑条 ===== */
  702. .slider-row {
  703. display: flex;
  704. align-items: center;
  705. gap: 16rpx;
  706. }
  707. .slider-label {
  708. font-size: 24rpx;
  709. color: #999;
  710. white-space: nowrap;
  711. width: 64rpx;
  712. }
  713. .custom-slider {
  714. flex: 1;
  715. }
  716. .slider-value {
  717. font-size: 28rpx;
  718. font-weight: 600;
  719. color: #FF6B9D;
  720. width: 40rpx;
  721. text-align: center;
  722. }
  723. .intensity-hint {
  724. font-size: 22rpx;
  725. color: #aaa;
  726. text-align: center;
  727. margin-top: 4rpx;
  728. }
  729. /* ===== 情绪标签 ===== */
  730. .tag-grid {
  731. display: flex;
  732. flex-wrap: wrap;
  733. gap: 16rpx;
  734. }
  735. .tag-btn {
  736. padding: 12rpx 28rpx;
  737. border-radius: 30rpx;
  738. background: #F5F5F5;
  739. font-size: 26rpx;
  740. color: #666;
  741. border: 2rpx solid transparent;
  742. }
  743. .tag-active {
  744. background: #FFF0F5;
  745. color: #FF6B9D;
  746. border-color: #FF6B9D;
  747. }
  748. .custom-tag-row {
  749. display: flex;
  750. align-items: center;
  751. margin-top: 16rpx;
  752. gap: 16rpx;
  753. }
  754. .custom-tag-input {
  755. flex: 1;
  756. height: 60rpx;
  757. border: 2rpx solid #E0E0E0;
  758. border-radius: 12rpx;
  759. padding: 0 16rpx;
  760. font-size: 26rpx;
  761. }
  762. .custom-tag-add {
  763. padding: 12rpx 24rpx;
  764. background: #FF6B9D;
  765. color: #fff;
  766. border-radius: 12rpx;
  767. font-size: 26rpx;
  768. }
  769. .custom-tag-trigger {
  770. display: inline-block;
  771. margin-top: 16rpx;
  772. color: #FF6B9D;
  773. font-size: 26rpx;
  774. }
  775. /* ===== 拍照 ===== */
  776. .photo-area {
  777. width: 200rpx;
  778. height: 200rpx;
  779. border-radius: 16rpx;
  780. overflow: hidden;
  781. background: #F5F5F5;
  782. display: flex;
  783. align-items: center;
  784. justify-content: center;
  785. }
  786. .photo-preview {
  787. width: 100%;
  788. height: 100%;
  789. }
  790. .photo-placeholder {
  791. display: flex;
  792. flex-direction: column;
  793. align-items: center;
  794. }
  795. .photo-icon {
  796. font-size: 60rpx;
  797. }
  798. .photo-hint {
  799. font-size: 24rpx;
  800. color: #999;
  801. margin-top: 8rpx;
  802. }
  803. .photo-note {
  804. display: block;
  805. font-size: 22rpx;
  806. color: #999;
  807. margin-top: 12rpx;
  808. }
  809. /* ===== 情绪识别 ===== */
  810. .ai-recognize-btn {
  811. display: flex;
  812. align-items: center;
  813. justify-content: center;
  814. padding: 20rpx;
  815. background: linear-gradient(135deg, #8B5CF6, #A78BFA);
  816. border-radius: 16rpx;
  817. cursor: pointer;
  818. }
  819. .ai-btn-icon {
  820. font-size: 32rpx;
  821. margin-right: 10rpx;
  822. }
  823. .ai-btn-text {
  824. font-size: 26rpx;
  825. color: #fff;
  826. font-weight: 500;
  827. }
  828. .ai-recognize-btn:active {
  829. opacity: 0.8;
  830. }
  831. .ai-analyzing {
  832. display: flex;
  833. align-items: center;
  834. justify-content: center;
  835. padding: 30rpx 0;
  836. }
  837. .loading-text {
  838. font-size: 26rpx;
  839. color: #8B5CF6;
  840. }
  841. .ai-result {
  842. margin-top: 16rpx;
  843. }
  844. .ai-result-title {
  845. font-size: 24rpx;
  846. color: #666;
  847. margin-bottom: 12rpx;
  848. display: block;
  849. }
  850. .ai-emotion-list {
  851. display: flex;
  852. flex-direction: column;
  853. gap: 12rpx;
  854. }
  855. .ai-emotion-item {
  856. display: flex;
  857. align-items: center;
  858. gap: 12rpx;
  859. }
  860. .ai-emotion-label {
  861. font-size: 24rpx;
  862. color: #333;
  863. width: 64rpx;
  864. flex-shrink: 0;
  865. }
  866. .ai-emotion-bar-track {
  867. flex: 1;
  868. height: 16rpx;
  869. background: #F0F0F0;
  870. border-radius: 8rpx;
  871. overflow: hidden;
  872. }
  873. .ai-emotion-bar-fill {
  874. height: 100%;
  875. border-radius: 8rpx;
  876. transition: width 0.5s;
  877. }
  878. .ai-emotion-value {
  879. font-size: 22rpx;
  880. color: #999;
  881. width: 56rpx;
  882. text-align: right;
  883. }
  884. .ai-apply-tags {
  885. margin-top: 20rpx;
  886. padding: 16rpx;
  887. background: #F5F0FF;
  888. border-radius: 12rpx;
  889. text-align: center;
  890. cursor: pointer;
  891. }
  892. .ai-apply-text {
  893. font-size: 26rpx;
  894. color: #8B5CF6;
  895. font-weight: 500;
  896. }
  897. .ai-apply-tags:active {
  898. opacity: 0.7;
  899. }
  900. .note-input {
  901. width: 100%;
  902. min-height: 140rpx;
  903. background: #F9F9F9;
  904. border-radius: 12rpx;
  905. padding: 20rpx;
  906. font-size: 26rpx;
  907. color: #333;
  908. box-sizing: border-box;
  909. }
  910. .submit-area {
  911. margin: 40rpx 32rpx;
  912. }
  913. .submit-btn {
  914. width: 100%;
  915. height: 88rpx;
  916. line-height: 88rpx;
  917. background: linear-gradient(135deg, #FF6B9D, #FF8CB3);
  918. color: #fff;
  919. border-radius: 44rpx;
  920. font-size: 30rpx;
  921. font-weight: 600;
  922. display: flex;
  923. align-items: center;
  924. justify-content: center;
  925. }
  926. .submit-disabled {
  927. opacity: 0.5;
  928. }
  929. .submit-icon {
  930. margin-right: 8rpx;
  931. }
  932. .loading-mask {
  933. position: fixed;
  934. top: 0;
  935. left: 0;
  936. right: 0;
  937. bottom: 0;
  938. background: rgba(0, 0, 0, 0.3);
  939. display: flex;
  940. align-items: center;
  941. justify-content: center;
  942. z-index: 999;
  943. }
  944. .loading-box {
  945. background: #fff;
  946. padding: 40rpx 60rpx;
  947. border-radius: 20rpx;
  948. }
  949. .loading-text {
  950. font-size: 28rpx;
  951. color: #666;
  952. }
  953. </style>