sleep-checkin.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  1. <template>
  2. <view class="page">
  3. <view class="content">
  4. <!-- ===== 夜间主题头部:日期 / 本次时长 / 入睡-醒来时间轴 ===== -->
  5. <view class="hero">
  6. <view class="hero-glow"></view>
  7. <view class="hero-stars">
  8. <view class="star" v-for="s in stars" :key="s.id"
  9. :style="{ left: s.left, top: s.top, width: s.size, height: s.size, opacity: s.opacity, animationDelay: s.delay }"></view>
  10. </view>
  11. <view class="hero-moon">🌙</view>
  12. <view class="hero-top">
  13. <view class="hero-chip">
  14. <text class="hero-chip-dot"></text>
  15. <text class="hero-chip-text">今日睡眠打卡</text>
  16. </view>
  17. <text class="hero-date">{{ todayDate }} {{ todayWeek }}</text>
  18. </view>
  19. <view class="hero-duration">
  20. <text class="hero-duration-num">{{ durationText }}</text>
  21. <text class="hero-duration-label">本次睡眠时长</text>
  22. </view>
  23. <view class="tl">
  24. <view class="tl-pills">
  25. <view class="tl-pill tl-pill-moon" :class="timeMode === 'sleep' ? 'tl-pill-on' : ''" @tap="setTimeMode('sleep')">
  26. <text class="tl-pill-ic">🌙</text>
  27. <text class="tl-pill-t">入睡</text>
  28. <text class="tl-pill-time">{{ form.sleepTime || '--:--' }}</text>
  29. </view>
  30. <view class="tl-pill tl-pill-sun" :class="timeMode === 'wake' ? 'tl-pill-on' : ''" @tap="setTimeMode('wake')">
  31. <text class="tl-pill-ic">☀️</text>
  32. <text class="tl-pill-t">醒来</text>
  33. <text class="tl-pill-time">{{ form.wakeTime || '--:--' }}</text>
  34. </view>
  35. </view>
  36. <scroll-view class="tl-scroll" scroll-x :scroll-left="tlScrollLeft" scroll-with-animation :show-scrollbar="false">
  37. <view class="tl-bar">
  38. <view class="tl-seg" v-for="seg in segments" :key="seg.k" :class="seg.cls" @tap="onTimelineTap(seg.k)">
  39. <text class="tl-seg-time" v-if="seg.showLabel">{{ seg.label }}</text>
  40. </view>
  41. </view>
  42. </scroll-view>
  43. <view class="tl-hint">点选「{{ timeMode === 'sleep' ? '入睡' : '醒来' }}」时间 · 每格 30 分钟,左右滑动找时间</view>
  44. </view>
  45. <view class="hero-tip">💡 建议青少年每天睡足 8-10 小时</view>
  46. </view>
  47. <!-- ===== 睡眠质量(点选打分,替代滑动条) ===== -->
  48. <view class="card">
  49. <view class="card-head">
  50. <view class="card-head-icon"><text>⭐</text></view>
  51. <text class="card-title">睡眠质量</text>
  52. <text class="card-hint">1-10 分</text>
  53. </view>
  54. <view class="card-body">
  55. <view class="quality-head">
  56. <view class="quality-face">{{ qualityFace }}</view>
  57. <view class="quality-info">
  58. <view class="quality-score-row">
  59. <text class="quality-score">{{ form.qualityScore }}</text>
  60. <text class="quality-unit">分</text>
  61. <text class="quality-badge" :class="'quality-badge quality-badge-' + qualityTier">{{ qualityLabel }}</text>
  62. </view>
  63. <text class="quality-desc">{{ qualityDesc }}</text>
  64. </view>
  65. </view>
  66. <view class="q-chips">
  67. <view class="q-chip" v-for="o in qualityOptions" :key="o.v"
  68. :class="'q-chip q-chip-' + o.tier + (form.qualityScore === o.v ? ' q-chip-active' : '')"
  69. @tap="setQuality(o.v)">
  70. <text>{{ o.v }}</text>
  71. </view>
  72. </view>
  73. <view class="q-scale">
  74. <text class="q-scale-end">1 · 很差</text>
  75. <text class="q-scale-mid">点击数字打分</text>
  76. <text class="q-scale-end">10 · 很好</text>
  77. </view>
  78. </view>
  79. </view>
  80. <!-- ===== 快速标签(选填,将写入备注) ===== -->
  81. <view class="card">
  82. <view class="card-head">
  83. <view class="card-head-icon"><text>🏷️</text></view>
  84. <text class="card-title">睡眠情况</text>
  85. <text class="card-hint">选填 · 可多选</text>
  86. </view>
  87. <view class="card-body">
  88. <view class="tag-cloud">
  89. <view class="s-tag" v-for="t in quickTags" :key="t"
  90. :class="selectedTags.indexOf(t) > -1 ? 's-tag s-tag-active' : 's-tag'"
  91. @tap="toggleTag(t)">
  92. <text>{{ t }}</text>
  93. </view>
  94. </view>
  95. </view>
  96. </view>
  97. <!-- ===== 备注 ===== -->
  98. <view class="card">
  99. <view class="card-head">
  100. <view class="card-head-icon"><text>📝</text></view>
  101. <text class="card-title">备注</text>
  102. <text class="card-hint">{{ form.remark.length }}/100</text>
  103. </view>
  104. <view class="card-body">
  105. <textarea class="remark-input" v-model="form.remark"
  106. placeholder="昨晚睡得怎么样?有什么想记录的…" placeholder-class="ph-gray" maxlength="100" />
  107. </view>
  108. </view>
  109. <!-- ===== 提交按钮 ===== -->
  110. <view class="submit-wrap">
  111. <view class="submit-btn" :class="submitting ? 'submit-btn submitting' : 'submit-btn'" @tap="doCheckin">
  112. <text class="submit-text">{{ submitting ? '打卡中...' : '完成打卡' }}</text>
  113. </view>
  114. </view>
  115. <!-- ===== 历史记录 ===== -->
  116. <view class="card" v-if="history.length > 0">
  117. <view class="card-head">
  118. <view class="card-head-icon"><text>📜</text></view>
  119. <text class="card-title">近期睡眠</text>
  120. <text class="card-hint" v-if="avgDurationText">平均 {{ avgDurationText }} · {{ avgQuality }} 分</text>
  121. </view>
  122. <view class="card-body">
  123. <view class="history-list">
  124. <view class="history-item" v-for="item in history" :key="item.id">
  125. <view class="history-badge"><text class="history-badge-icon">🌙</text></view>
  126. <view class="history-main">
  127. <text class="history-line1">{{ item._range }}</text>
  128. <text class="history-line2">{{ item._duration }} · 质量 {{ item.qualityScore || '-' }} 分</text>
  129. <view class="qbar">
  130. <view class="qbar-fill" :style="{ width: item._percent + '%', background: item._barBg }"></view>
  131. </view>
  132. </view>
  133. <text class="history-date">{{ formatDate(item.createdAt) }}</text>
  134. </view>
  135. </view>
  136. </view>
  137. </view>
  138. <!-- 底部留白 -->
  139. <view class="bottom-spacer"></view>
  140. </view>
  141. </view>
  142. </template>
  143. <script>
  144. import config from '../../config'
  145. import { formatDate } from '../../utils/format.js'
  146. export default {
  147. data() {
  148. return {
  149. memberId: null,
  150. taskId: null,
  151. submitting: false,
  152. timeMode: 'sleep',
  153. tlScrollLeft: 0,
  154. tlTotalPx: 0,
  155. tlAdvancePx: 0,
  156. tlViewPx: 0,
  157. history: [],
  158. todayDate: '',
  159. todayWeek: '',
  160. stars: [
  161. { id: 's1', left: '6%', top: '16%', size: '8rpx', opacity: 0.9, delay: '0s' },
  162. { id: 's2', left: '16%', top: '42%', size: '5rpx', opacity: 0.6, delay: '0.8s' },
  163. { id: 's3', left: '26%', top: '12%', size: '6rpx', opacity: 0.75, delay: '1.6s' },
  164. { id: 's4', left: '38%', top: '46%', size: '4rpx', opacity: 0.5, delay: '0.4s' },
  165. { id: 's5', left: '48%', top: '18%', size: '5rpx', opacity: 0.65, delay: '2.2s' }
  166. ],
  167. quickTags: ['入睡困难', '夜醒', '多梦', '打鼾', '早醒', '赖床', '熬夜', '午睡过长'],
  168. selectedTags: [],
  169. qualityOptions: [
  170. { v: 1, tier: 'poor' },
  171. { v: 2, tier: 'poor' },
  172. { v: 3, tier: 'poor' },
  173. { v: 4, tier: 'fair' },
  174. { v: 5, tier: 'fair' },
  175. { v: 6, tier: 'good' },
  176. { v: 7, tier: 'good' },
  177. { v: 8, tier: 'great' },
  178. { v: 9, tier: 'great' },
  179. { v: 10, tier: 'great' }
  180. ],
  181. qualityMap: {
  182. 1: { face: '😣', label: '很差', tier: 'poor', desc: '几乎整晚没睡好,白天完全不在状态' },
  183. 2: { face: '😣', label: '很差', tier: 'poor', desc: '睡得很浅,反复醒,醒来很累' },
  184. 3: { face: '😕', label: '较差', tier: 'poor', desc: '睡得不太踏实,精神差一些' },
  185. 4: { face: '😕', label: '偏差', tier: 'fair', desc: '将就睡了一晚,不算安稳' },
  186. 5: { face: '😐', label: '一般', tier: 'fair', desc: '不好不差,勉强够用' },
  187. 6: { face: '🙂', label: '尚可', tier: 'good', desc: '睡得还行,基本恢复精力' },
  188. 7: { face: '🙂', label: '不错', tier: 'good', desc: '睡得比较踏实,状态正常' },
  189. 8: { face: '😊', label: '良好', tier: 'great', desc: '睡得很沉,醒来神清气爽' },
  190. 9: { face: '😄', label: '很好', tier: 'great', desc: '一觉到天亮,几乎不想起床' },
  191. 10: { face: '🤩', label: '极好', tier: 'great', desc: '完美的一觉,元气满满' }
  192. },
  193. form: {
  194. sleepTime: '21:00',
  195. wakeTime: '07:00',
  196. qualityScore: 7,
  197. remark: ''
  198. }
  199. }
  200. },
  201. computed: {
  202. // 本次睡眠时长(分钟),跨午夜自动换算
  203. durationMinutes: function () {
  204. var s = this.form.sleepTime
  205. var w = this.form.wakeTime
  206. if (!s || !w) return 0
  207. var sp = String(s).split(':')
  208. var wp = String(w).split(':')
  209. var sm = parseInt(sp[0]) * 60 + parseInt(sp[1])
  210. var wm = parseInt(wp[0]) * 60 + parseInt(wp[1])
  211. if (isNaN(sm) || isNaN(wm)) return 0
  212. return wm > sm ? wm - sm : (24 * 60 - sm + wm)
  213. },
  214. durationText: function () {
  215. return this.durationFromMinutes(this.durationMinutes)
  216. },
  217. // 24 小时 × 2 格/小时 = 48 个半小时间隔段(用于时间轴高亮与标签)
  218. segments: function () {
  219. var self = this
  220. var s = this.slotOf(this.form.sleepTime)
  221. var w = this.slotOf(this.form.wakeTime)
  222. var arr = []
  223. for (var i = 0; i < 48; i++) {
  224. var on = false
  225. if (s >= 0 && w >= 0 && s !== w) {
  226. if (w > s) on = i >= s && i <= w
  227. else on = i >= s || i <= w
  228. }
  229. var cls = on ? 'tl-seg tl-seg-on' : 'tl-seg'
  230. if (i === s) cls += ' tl-seg-sleep'
  231. if (i === w) cls += ' tl-seg-wake'
  232. var showLabel = (i % 2 === 0) || i === s || i === w
  233. arr.push({ k: i, cls: cls, showLabel: showLabel, label: self.timeFromSlot(i) })
  234. }
  235. return arr
  236. },
  237. qualityFace: function () {
  238. var m = this.qualityMap[this.form.qualityScore]
  239. return m ? m.face : '🙂'
  240. },
  241. qualityLabel: function () {
  242. var m = this.qualityMap[this.form.qualityScore]
  243. return m ? m.label : '一般'
  244. },
  245. qualityTier: function () {
  246. var m = this.qualityMap[this.form.qualityScore]
  247. return m ? m.tier : 'fair'
  248. },
  249. qualityDesc: function () {
  250. var m = this.qualityMap[this.form.qualityScore]
  251. return m ? m.desc : ''
  252. },
  253. avgDurationText: function () {
  254. var total = 0
  255. var count = 0
  256. this.history.forEach(function (h) {
  257. if (h.durationMinutes) { total += h.durationMinutes; count++ }
  258. })
  259. if (!count) return ''
  260. return this.durationFromMinutes(Math.round(total / count))
  261. },
  262. avgQuality: function () {
  263. var total = 0
  264. var count = 0
  265. this.history.forEach(function (h) {
  266. if (h.qualityScore) { total += h.qualityScore; count++ }
  267. })
  268. if (!count) return 0
  269. return (total / count).toFixed(1)
  270. }
  271. },
  272. onLoad: function (options) {
  273. this.taskId = options.taskId || null
  274. var d = new Date()
  275. var weekdays = ['日', '一', '二', '三', '四', '五', '六']
  276. var m2 = ('0' + (d.getMonth() + 1)).slice(-2)
  277. var day = ('0' + d.getDate()).slice(-2)
  278. this.todayDate = d.getFullYear() + '-' + m2 + '-' + day
  279. this.todayWeek = '周' + weekdays[d.getDay()]
  280. var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
  281. if (role !== 'child') {
  282. this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
  283. }
  284. this.loadHistory()
  285. },
  286. onReady: function () {
  287. var self = this
  288. // 量取时间轴实际像素宽度,用于把选中时间滚动到可视区中间
  289. uni.createSelectorQuery().in(this).select('.tl-bar').boundingClientRect(function (rect) {
  290. if (rect && rect.width) {
  291. self.tlTotalPx = rect.width
  292. self.tlAdvancePx = rect.width / 48
  293. var winW = uni.getWindowInfo().windowWidth
  294. self.tlViewPx = winW - 112 * (winW / 750)
  295. self.scrollToSlot(self.slotOf(self.form.sleepTime))
  296. }
  297. }).exec()
  298. },
  299. methods: {
  300. durationFromMinutes: function (mins) {
  301. if (!mins || mins <= 0) return '--'
  302. var h = Math.floor(mins / 60)
  303. var m = mins % 60
  304. if (h > 0) return h + ' 小时' + (m > 0 ? ' ' + m + ' 分' : '')
  305. return m + ' 分钟'
  306. },
  307. // 时间轴:半小时间隔 (0=00:00 .. 47=23:30)
  308. slotOf: function (t) {
  309. if (!t) return -1
  310. var p = String(t).split(':')
  311. var h = parseInt(p[0]); var m = parseInt(p[1])
  312. if (isNaN(h) || isNaN(m)) return -1
  313. return Math.round((h * 60 + m) / 30) % 48
  314. },
  315. timeFromSlot: function (i) {
  316. var h = Math.floor(i / 2)
  317. var m = (i % 2) * 30
  318. return ('0' + h).slice(-2) + ':' + ('0' + m).slice(-2)
  319. },
  320. setTimeMode: function (mode) {
  321. this.timeMode = mode
  322. this.scrollToSlot(this.slotOf(this.form[mode === 'sleep' ? 'sleepTime' : 'wakeTime']))
  323. },
  324. onTimelineTap: function (i) {
  325. var self = this
  326. var t = this.timeFromSlot(i)
  327. if (self.timeMode === 'sleep') {
  328. self.form.sleepTime = t
  329. self.timeMode = 'wake'
  330. } else {
  331. self.form.wakeTime = t
  332. }
  333. self.scrollToSlot(i)
  334. },
  335. scrollToSlot: function (slot) {
  336. if (slot < 0 || !this.tlAdvancePx) return
  337. var target = slot * this.tlAdvancePx - this.tlViewPx / 2 + this.tlAdvancePx / 2
  338. var max = Math.max(0, this.tlTotalPx - this.tlViewPx)
  339. this.tlScrollLeft = Math.max(0, Math.min(target, max))
  340. },
  341. setQuality: function (score) {
  342. this.form.qualityScore = score
  343. },
  344. toggleTag: function (tag) {
  345. var idx = this.selectedTags.indexOf(tag)
  346. if (idx > -1) {
  347. this.selectedTags.splice(idx, 1)
  348. } else {
  349. this.selectedTags.push(tag)
  350. }
  351. },
  352. loadHistory: function () {
  353. var self = this
  354. var token = uni.getStorageSync('token')
  355. if (!token || !self.memberId) return
  356. uni.request({
  357. url: config.API_BASE_URL + '/api/health/sleep/list',
  358. method: 'POST',
  359. data: { memberId: self.memberId },
  360. header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
  361. success: function (res) {
  362. if (res.data && res.data.code === 200 && res.data.data) {
  363. var arr = Array.isArray(res.data.data) ? res.data.data : []
  364. self.history = arr.slice(0, 5).map(function (item) {
  365. self.decorateHistoryItem(item)
  366. return item
  367. })
  368. }
  369. }
  370. })
  371. },
  372. // 预计算列表渲染字段(避免模板中调用带参方法)
  373. decorateHistoryItem: function (item) {
  374. var score = item.qualityScore || 0
  375. item._range = (item.sleepTime ? String(item.sleepTime).slice(0, 5) : '--:--') +
  376. ' - ' + (item.wakeTime ? String(item.wakeTime).slice(0, 5) : '--:--')
  377. item._duration = this.durationFromMinutes(item.durationMinutes)
  378. item._percent = Math.min(100, Math.max(0, Math.round(score * 10)))
  379. if (score >= 8) item._barBg = 'linear-gradient(90deg,#818CF8,#6366F1)'
  380. else if (score >= 6) item._barBg = 'linear-gradient(90deg,#34D399,#10B981)'
  381. else if (score >= 4) item._barBg = 'linear-gradient(90deg,#FBBF24,#F59E0B)'
  382. else item._barBg = 'linear-gradient(90deg,#FB7185,#EF4444)'
  383. },
  384. buildRemark: function () {
  385. var remark = this.form.remark ? this.form.remark.trim() : ''
  386. var tagText = this.selectedTags.join(',')
  387. if (tagText && remark) return tagText + ',' + remark
  388. if (tagText) return tagText
  389. return remark
  390. },
  391. doCheckin: function () {
  392. var self = this
  393. if (self.submitting) return
  394. if (!self.form.sleepTime) { uni.showToast({ title: '请选择入睡时间', icon: 'none' }); return }
  395. if (!self.form.wakeTime) { uni.showToast({ title: '请选择醒来时间', icon: 'none' }); return }
  396. if (self.form.sleepTime === self.form.wakeTime) { uni.showToast({ title: '入睡和醒来时间不能相同', icon: 'none' }); return }
  397. if (!self.form.qualityScore) { uni.showToast({ title: '请选择睡眠质量', icon: 'none' }); return }
  398. if (!self.memberId) { uni.showToast({ title: '成员ID无效', icon: 'none' }); return }
  399. self.submitting = true
  400. var token = uni.getStorageSync('token')
  401. uni.request({
  402. url: config.API_BASE_URL + '/api/health/sleep/create',
  403. method: 'POST',
  404. data: {
  405. memberId: self.memberId,
  406. taskId: self.taskId || undefined,
  407. sleepTime: self.form.sleepTime,
  408. wakeTime: self.form.wakeTime,
  409. durationMinutes: self.durationMinutes,
  410. qualityScore: self.form.qualityScore,
  411. remark: self.buildRemark()
  412. },
  413. header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
  414. success: function (res) {
  415. self.submitting = false
  416. if (res.data && res.data.code === 200) {
  417. uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
  418. // 本地插入新记录(复用已返回的数据,不重复请求列表接口)
  419. var created = res.data.data
  420. if (created) {
  421. self.decorateHistoryItem(created)
  422. var list = self.history.slice()
  423. list.unshift(created)
  424. self.history = list.slice(0, 5)
  425. }
  426. setTimeout(function () {
  427. uni.navigateBack()
  428. }, 800)
  429. } else {
  430. uni.showToast({ title: (res.data && res.data.message) || '打卡失败', icon: 'none' })
  431. }
  432. },
  433. fail: function () {
  434. self.submitting = false
  435. uni.showToast({ title: '网络错误', icon: 'none' })
  436. }
  437. })
  438. }
  439. }
  440. }
  441. </script>
  442. <style scoped>
  443. /* ===== 页面基础 ===== */
  444. .page { min-height: 100vh; background: #EEF1F8; }
  445. .content { padding: 20rpx 0 0; }
  446. /* ===== 夜间主题头部 ===== */
  447. .hero {
  448. position: relative;
  449. margin: 8rpx 24rpx 8rpx;
  450. padding: 36rpx 32rpx 32rpx;
  451. border-radius: 32rpx;
  452. overflow: hidden;
  453. background: linear-gradient(155deg, #3B4A8C 0%, #2A3470 45%, #1B2350 100%);
  454. box-shadow: 0 16rpx 40rpx rgba(27, 35, 80, 0.35);
  455. color: #fff;
  456. }
  457. .hero-glow {
  458. position: absolute;
  459. top: -120rpx;
  460. right: -80rpx;
  461. width: 320rpx;
  462. height: 320rpx;
  463. border-radius: 50%;
  464. background: radial-gradient(circle, rgba(255, 209, 102, 0.4) 0%, rgba(255, 209, 102, 0) 70%);
  465. }
  466. .hero-moon {
  467. position: absolute;
  468. top: 20rpx;
  469. right: 32rpx;
  470. font-size: 72rpx;
  471. line-height: 1;
  472. animation: moon-float 4s ease-in-out infinite;
  473. }
  474. @keyframes moon-float {
  475. 0%, 100% { transform: translateY(0); }
  476. 50% { transform: translateY(-10rpx); }
  477. }
  478. .star {
  479. position: absolute;
  480. border-radius: 50%;
  481. background: #FFE9A8;
  482. animation: star-twinkle 2.4s ease-in-out infinite;
  483. }
  484. @keyframes star-twinkle {
  485. 0%, 100% { opacity: 0.2; transform: scale(0.8); }
  486. 50% { opacity: 0.95; transform: scale(1.15); }
  487. }
  488. .hero-top {
  489. display: flex;
  490. align-items: center;
  491. justify-content: space-between;
  492. position: relative;
  493. z-index: 1;
  494. }
  495. .hero-chip {
  496. display: flex;
  497. align-items: center;
  498. gap: 10rpx;
  499. padding: 8rpx 20rpx;
  500. border-radius: 999rpx;
  501. background: rgba(255, 255, 255, 0.14);
  502. }
  503. .hero-chip-dot {
  504. width: 10rpx;
  505. height: 10rpx;
  506. border-radius: 50%;
  507. background: #FFD166;
  508. box-shadow: 0 0 12rpx rgba(255, 209, 102, 0.9);
  509. }
  510. .hero-chip-text { font-size: 22rpx; color: #fff; }
  511. .hero-date { font-size: 24rpx; color: rgba(255, 255, 255, 0.75); }
  512. .hero-duration {
  513. position: relative;
  514. z-index: 1;
  515. margin-top: 24rpx;
  516. }
  517. .hero-duration-num {
  518. font-size: 56rpx;
  519. font-weight: 800;
  520. color: #fff;
  521. line-height: 1.1;
  522. }
  523. .hero-duration-label {
  524. display: block;
  525. font-size: 24rpx;
  526. color: rgba(255, 255, 255, 0.7);
  527. margin-top: 6rpx;
  528. }
  529. .tl {
  530. position: relative;
  531. z-index: 1;
  532. margin-top: 28rpx;
  533. }
  534. .tl-pills {
  535. display: flex;
  536. gap: 16rpx;
  537. }
  538. .tl-pill {
  539. flex: 1;
  540. display: flex;
  541. align-items: center;
  542. gap: 10rpx;
  543. padding: 16rpx 20rpx;
  544. border-radius: 20rpx;
  545. background: rgba(255, 255, 255, 0.1);
  546. border: 2rpx solid rgba(255, 255, 255, 0.14);
  547. transition: transform 0.2s ease;
  548. }
  549. .tl-pill:active { transform: scale(0.97); }
  550. .tl-pill-on {
  551. background: rgba(255, 255, 255, 0.22);
  552. border-color: rgba(255, 209, 102, 0.75);
  553. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.22);
  554. }
  555. .tl-pill-ic { font-size: 30rpx; line-height: 1; }
  556. .tl-pill-t { font-size: 24rpx; color: rgba(255, 255, 255, 0.72); }
  557. .tl-pill-time {
  558. flex: 1;
  559. text-align: right;
  560. font-size: 34rpx;
  561. font-weight: 700;
  562. color: #fff;
  563. }
  564. .tl-pill-on .tl-pill-time { color: #FFD166; }
  565. .tl-scroll {
  566. margin-top: 20rpx;
  567. width: 100%;
  568. height: 92rpx;
  569. }
  570. .tl-bar {
  571. display: flex;
  572. gap: 4rpx;
  573. padding: 0 2rpx;
  574. align-items: stretch;
  575. }
  576. .tl-seg {
  577. width: 96rpx;
  578. height: 76rpx;
  579. flex-shrink: 0;
  580. border-radius: 10rpx;
  581. background: rgba(255, 255, 255, 0.13);
  582. display: flex;
  583. align-items: center;
  584. justify-content: center;
  585. transition: background 0.15s ease;
  586. }
  587. .tl-seg:active { background: rgba(255, 255, 255, 0.4); }
  588. .tl-seg-time {
  589. font-size: 18rpx;
  590. color: rgba(255, 255, 255, 0.8);
  591. line-height: 1;
  592. }
  593. .tl-seg-on { background: rgba(255, 209, 102, 0.55); }
  594. .tl-seg-on .tl-seg-time { color: rgba(255, 255, 255, 0.95); }
  595. .tl-seg-sleep {
  596. background: linear-gradient(180deg, #FFD166, #FFB84D);
  597. box-shadow: 0 0 12rpx rgba(255, 209, 102, 0.8);
  598. }
  599. .tl-seg-wake {
  600. background: linear-gradient(180deg, #FF9F68, #FF7A45);
  601. box-shadow: 0 0 12rpx rgba(255, 159, 104, 0.8);
  602. }
  603. .tl-seg-sleep .tl-seg-time, .tl-seg-wake .tl-seg-time {
  604. color: #4A2A00;
  605. font-weight: 700;
  606. }
  607. .tl-hint {
  608. font-size: 20rpx;
  609. color: rgba(255, 255, 255, 0.55);
  610. margin-top: 14rpx;
  611. }
  612. .hero-tip {
  613. position: relative;
  614. z-index: 1;
  615. margin-top: 24rpx;
  616. padding-top: 20rpx;
  617. border-top: 1rpx solid rgba(255, 255, 255, 0.12);
  618. font-size: 22rpx;
  619. color: rgba(255, 255, 255, 0.65);
  620. }
  621. /* ===== 卡片通用 ===== */
  622. .card {
  623. margin: 24rpx 24rpx 0;
  624. background: #fff;
  625. border-radius: 28rpx;
  626. box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.05);
  627. }
  628. .card-head {
  629. display: flex;
  630. align-items: center;
  631. gap: 14rpx;
  632. padding: 26rpx 28rpx 14rpx;
  633. }
  634. .card-head-icon {
  635. width: 56rpx;
  636. height: 56rpx;
  637. border-radius: 18rpx;
  638. background: linear-gradient(135deg, #EEF2FF, #E0E7FF);
  639. display: flex;
  640. align-items: center;
  641. justify-content: center;
  642. font-size: 30rpx;
  643. flex-shrink: 0;
  644. }
  645. .card-title {
  646. flex: 1;
  647. font-size: 30rpx;
  648. font-weight: 600;
  649. color: #1E293B;
  650. }
  651. .card-hint { font-size: 22rpx; color: #94A3B8; }
  652. .card-body { padding: 4rpx 28rpx 30rpx; }
  653. /* ===== 睡眠质量 ===== */
  654. .quality-head {
  655. display: flex;
  656. align-items: center;
  657. gap: 24rpx;
  658. background: linear-gradient(160deg, #F8FAFF, #F1F5FF);
  659. border-radius: 24rpx;
  660. padding: 26rpx 24rpx;
  661. margin-bottom: 26rpx;
  662. }
  663. .quality-face {
  664. width: 116rpx;
  665. height: 116rpx;
  666. border-radius: 50%;
  667. background: #fff;
  668. box-shadow: 0 6rpx 18rpx rgba(79, 70, 229, 0.12);
  669. display: flex;
  670. align-items: center;
  671. justify-content: center;
  672. font-size: 62rpx;
  673. flex-shrink: 0;
  674. }
  675. .quality-info { flex: 1; min-width: 0; }
  676. .quality-score-row { display: flex; align-items: baseline; }
  677. .quality-score {
  678. font-size: 54rpx;
  679. font-weight: 800;
  680. color: #1E293B;
  681. line-height: 1;
  682. }
  683. .quality-unit { font-size: 24rpx; color: #94A3B8; margin-left: 6rpx; }
  684. .quality-badge {
  685. margin-left: 14rpx;
  686. padding: 6rpx 18rpx;
  687. border-radius: 999rpx;
  688. font-size: 22rpx;
  689. font-weight: 600;
  690. }
  691. .quality-badge-poor { background: #FEE2E2; color: #DC2626; }
  692. .quality-badge-fair { background: #FEF3C7; color: #B45309; }
  693. .quality-badge-good { background: #D1FAE5; color: #047857; }
  694. .quality-badge-great { background: #E0E7FF; color: #4338CA; }
  695. .quality-desc { display: block; font-size: 24rpx; color: #64748B; margin-top: 12rpx; }
  696. .q-chips { display: flex; flex-wrap: wrap; gap: 14rpx; }
  697. .q-chip {
  698. flex: 1;
  699. height: 78rpx;
  700. border-radius: 22rpx;
  701. background: #F8FAFC;
  702. border: 2rpx solid #EDF1F7;
  703. display: flex;
  704. align-items: center;
  705. justify-content: center;
  706. transition: transform 0.18s ease;
  707. }
  708. .q-chip text { font-size: 30rpx; font-weight: 600; color: #64748B; }
  709. .q-chip:active { transform: scale(0.94); }
  710. .q-chip-active { border-color: transparent; transform: translateY(-4rpx); }
  711. .q-chip-active text { color: #fff; }
  712. .q-chip-poor text { color: #F87171; }
  713. .q-chip-fair text { color: #D97706; }
  714. .q-chip-good text { color: #059669; }
  715. .q-chip-great text { color: #4F46E5; }
  716. .q-chip-poor.q-chip-active { background: linear-gradient(135deg, #FB7185, #EF4444); box-shadow: 0 8rpx 20rpx rgba(239, 68, 68, 0.3); }
  717. .q-chip-fair.q-chip-active { background: linear-gradient(135deg, #FBBF24, #F59E0B); box-shadow: 0 8rpx 20rpx rgba(245, 158, 11, 0.3); }
  718. .q-chip-good.q-chip-active { background: linear-gradient(135deg, #34D399, #10B981); box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.3); }
  719. .q-chip-great.q-chip-active { background: linear-gradient(135deg, #818CF8, #6366F1); box-shadow: 0 8rpx 20rpx rgba(99, 102, 241, 0.3); }
  720. .q-scale {
  721. display: flex;
  722. justify-content: space-between;
  723. align-items: center;
  724. margin-top: 14rpx;
  725. }
  726. .q-scale-end { font-size: 22rpx; color: #94A3B8; }
  727. .q-scale-mid { font-size: 20rpx; color: #CBD5E1; }
  728. /* ===== 快速标签 ===== */
  729. .tag-cloud { display: flex; flex-wrap: wrap; gap: 16rpx; }
  730. .s-tag {
  731. padding: 14rpx 26rpx;
  732. border-radius: 999rpx;
  733. background: #F8FAFC;
  734. border: 2rpx solid #EDF1F7;
  735. transition: transform 0.18s ease;
  736. }
  737. .s-tag text { font-size: 26rpx; color: #475569; }
  738. .s-tag:active { transform: scale(0.96); }
  739. .s-tag-active { background: #EEF2FF; border-color: #6366F1; }
  740. .s-tag-active text { color: #4F46E5; font-weight: 600; }
  741. /* ===== 备注 ===== */
  742. .remark-input {
  743. width: 100%;
  744. min-height: 150rpx;
  745. background: #F8FAFC;
  746. border: 2rpx solid #EDF1F7;
  747. border-radius: 20rpx;
  748. padding: 20rpx;
  749. font-size: 26rpx;
  750. color: #1E293B;
  751. box-sizing: border-box;
  752. }
  753. .ph-gray { color: #CBD5E1; font-size: 26rpx; }
  754. /* ===== 提交按钮 ===== */
  755. .submit-wrap { padding: 32rpx 24rpx 8rpx; }
  756. .submit-btn {
  757. background: linear-gradient(135deg, #6366F1, #4F46E5 60%, #4338CA);
  758. border-radius: 999rpx;
  759. padding: 26rpx 0;
  760. text-align: center;
  761. box-shadow: 0 12rpx 28rpx rgba(79, 70, 229, 0.35);
  762. transition: transform 0.2s ease;
  763. }
  764. .submit-btn:active {
  765. transform: scale(0.98);
  766. box-shadow: 0 6rpx 14rpx rgba(79, 70, 229, 0.25);
  767. }
  768. .submit-text { color: #fff; font-size: 30rpx; font-weight: 700; letter-spacing: 2rpx; }
  769. .submitting { opacity: 0.6; }
  770. /* ===== 历史记录 ===== */
  771. .history-list { display: flex; flex-direction: column; }
  772. .history-item {
  773. display: flex;
  774. align-items: center;
  775. gap: 20rpx;
  776. padding: 20rpx 0;
  777. border-bottom: 2rpx solid #F1F5F9;
  778. }
  779. .history-item:last-child { border-bottom: none; }
  780. .history-badge {
  781. width: 76rpx;
  782. height: 76rpx;
  783. border-radius: 24rpx;
  784. background: linear-gradient(135deg, #EEF2FF, #E0E7FF);
  785. display: flex;
  786. align-items: center;
  787. justify-content: center;
  788. flex-shrink: 0;
  789. }
  790. .history-badge-icon { font-size: 36rpx; line-height: 1; }
  791. .history-main { flex: 1; min-width: 0; }
  792. .history-line1 { font-size: 28rpx; font-weight: 600; color: #1E293B; display: block; }
  793. .history-line2 { font-size: 22rpx; color: #64748B; display: block; margin-top: 6rpx; }
  794. .qbar {
  795. height: 8rpx;
  796. border-radius: 999rpx;
  797. background: #F1F5F9;
  798. margin-top: 12rpx;
  799. overflow: hidden;
  800. }
  801. .qbar-fill { height: 100%; border-radius: 999rpx; transition: width 0.3s ease; }
  802. .history-date { font-size: 22rpx; color: #94A3B8; flex-shrink: 0; }
  803. /* ===== 底部留白 ===== */
  804. .bottom-spacer { height: 60rpx; }
  805. </style>