Ver código fonte

feat(health): 实现体重记录详情页功能

- 添加月份选择器和趋势图展示
- 实现体重数据的增删改查功能
- 集成刻度尺组件用于体重输入
- 添加画布图表绘制逻辑
- 支持月份切换和数据统计展示
- 实现响应式布局和样式优化
mcbaiyun 2 meses atrás
pai
commit
3ab5fb6fe5
1 arquivos alterados com 502 adições e 6 exclusões
  1. 502 6
      src/pages/health/details/weight.vue

+ 502 - 6
src/pages/health/details/weight.vue

@@ -1,18 +1,514 @@
 <template>
   <CustomNav title="体重" leftType="back" />
-  <view class="content">
-    <view class="placeholder">这里会显示体重历史和录入界面</view>
+  <view class="page">
+
+    <view class="header">
+      <view class="month-selector">
+        <button class="btn" @click="prevMonth">‹</button>
+        <view class="month-label">{{ displayYear }}年 {{ displayMonth }}月</view>
+        <button class="btn" @click="nextMonth">›</button>
+      </view>
+      <picker mode="date" :value="pickerValue" @change="onPickerChange">
+        <view class="picker-display">切换月份</view>
+      </picker>
+    </view>
+
+    <!-- 趋势图 -->
+    <view class="chart-wrap">
+      <view class="chart-header">本月趋势</view>
+      <canvas ref="chartCanvas" id="chartCanvas" canvas-id="weightChart" class="chart-canvas" width="340" height="120"></canvas>
+    </view>
+
+    <view class="content">
+      <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageWeight }} kg</view>
+
+      <view class="list">
+        <view v-if="records.length === 0" class="empty">暂无记录,点击右下角 + 添加</view>
+        <view v-for="item in records" :key="item.id" class="list-item">
+          <view class="date">{{ item.date }}</view>
+          <view class="value">{{ item.weight }} kg</view>
+          <button class="btn-delete" @click="confirmDeleteRecord(item.id)">删除</button>
+        </view>
+      </view>
+    </view>
+
+    <view class="fab" @click="openAdd">
+      <view class="fab-inner">+</view>
+    </view>
+
+    <!-- 添加模态 -->
+    <view class="modal" v-if="showAdd">
+      <view class="modal-backdrop" @click="closeAdd"></view>
+      <view class="modal-panel">
+        <view class="drag-handle"></view>
+        <view class="modal-header"><text class="modal-title">添加体重</text></view>
+
+        <view class="modal-inner">
+          <view class="form-row">
+            <text class="label">日期</text>
+            <picker mode="date" :value="addDate" @change="onAddDateChange">
+              <view class="picker-display">{{ addDateLabel }}</view>
+            </picker>
+          </view>
+
+          <view class="form-row">
+            <text class="label">体重 (kg)</text>
+            <input type="number" v-model.number="addWeight" class="input" placeholder="请输入体重" />
+          </view>
+        </view>
+
+        <view class="ruler-wrap">
+          <ScaleRuler v-if="showAdd" :min="30" :max="200" :step="0.1" :gutter="16" :initialValue="addWeight ?? 65" @update="onAddRulerUpdate" @change="onAddRulerChange" />
+        </view>
+
+        <view class="fixed-footer">
+          <button class="btn-primary btn-full" @click="confirmAdd">保存</button>
+        </view>
+      </view>
+    </view>
+
   </view>
   <TabBar />
 </template>
 
 <script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
 import CustomNav from '@/components/CustomNav.vue'
-
 import TabBar from '@/components/TabBar.vue'
+import ScaleRuler from '@/components/scale-ruler.vue'
+
+type RecordItem = { id: string; date: string; weight: number }
+
+// 当前展示年月
+const current = ref(new Date())
+const pickerValue = ref(formatPickerDate(current.value))
+
+function formatPickerDate(d: Date) {
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
+
+const displayYear = computed(() => current.value.getFullYear())
+const displayMonth = computed(() => current.value.getMonth() + 1)
+
+const records = ref<RecordItem[]>(generateMockRecords(current.value))
+
+function generateMockRecords(d: Date): RecordItem[] {
+  const y = d.getFullYear()
+  const m = d.getMonth()
+  const arr: RecordItem[] = []
+  const n = Math.floor(Math.random() * 7)
+  for (let i = 0; i < n; i++) {
+    const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
+    const date = new Date(y, m, day)
+    arr.push({ id: `${y}${m}${i}${Date.now()}`, date: formatDisplayDate(date), weight: Number((50 + Math.random() * 50).toFixed(1)) })
+  }
+  return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
+}
+
+function formatDisplayDate(d: Date) {
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+const averageWeight = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.weight, 0)
+  return (sum / records.value.length).toFixed(1)
+})
+
+function daysInMonth(year: number, month: number) {
+  return new Date(year, month + 1, 0).getDate()
+}
+
+const pointCoords = computed(() => {
+  const year = current.value.getFullYear()
+  const month = current.value.getMonth()
+  const days = daysInMonth(year, month)
+  const W = 340
+  const H = 120
+  const leftPad = 10
+  const rightPad = 10
+  const topPad = 10
+  const bottomPad = 10
+  const innerW = W - leftPad - rightPad
+  const innerH = H - topPad - bottomPad
+  const values: Array<number | null> = Array(days).fill(null)
+  records.value.forEach(r => {
+    const parts = r.date.split('-')
+    if (parts.length >= 3) {
+      const y = parseInt(parts[0], 10)
+      const m = parseInt(parts[1], 10) - 1
+      const d = parseInt(parts[2], 10)
+      if (y === year && m === month && d >= 1 && d <= days) {
+        values[d - 1] = r.weight
+      }
+    }
+  })
+  const numeric = values.filter(v => v !== null) as number[]
+  const min = numeric.length ? Math.min(...numeric) : 40
+  const max = numeric.length ? Math.max(...numeric) : 100
+  const range = Math.max(1, max - min)
+  const coords = values.map((v, idx) => {
+    const x = leftPad + (innerW * idx) / Math.max(1, days - 1)
+    if (v === null) return null
+    const y = topPad + innerH - ((v - min) / range) * innerH
+    return { x, y }
+  })
+  return coords
+})
+
+// Canvas 绘图
+const chartCanvas = ref<HTMLCanvasElement | null>(null)
+const vm = getCurrentInstance()
+
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise(resolve => {
+    try {
+      if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
+        uni.createSelectorQuery().select('#chartCanvas').boundingClientRect((res: any) => {
+          if (res) resolve({ width: res.width || 340, height: res.height || 120 })
+          else resolve({ width: 340, height: 120 })
+        }).exec()
+      } else if (chartCanvas.value) {
+        resolve({ width: chartCanvas.value.clientWidth || 340, height: chartCanvas.value.clientHeight || 120 })
+      } else {
+        resolve({ width: 340, height: 120 })
+      }
+    } catch (e) {
+      resolve({ width: 340, height: 120 })
+    }
+  })
+}
+
+async function drawChart() {
+  const canvas = chartCanvas.value as HTMLCanvasElement | null
+  if (canvas && canvas.getContext) {
+    const ctx = canvas.getContext('2d')
+    if (!ctx) return
+    const dpr = (typeof uni !== 'undefined' && uni.getSystemInfoSync) ? (uni.getSystemInfoSync().pixelRatio || (window?.devicePixelRatio || 1)) : (window?.devicePixelRatio || 1)
+    const cssWidth = canvas.clientWidth || 340
+    const cssHeight = canvas.clientHeight || 120
+    canvas.width = Math.round(cssWidth * dpr)
+    canvas.height = Math.round(cssHeight * dpr)
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+    ctx.clearRect(0, 0, cssWidth, cssHeight)
+    ctx.strokeStyle = 'rgba(0,0,0,0.04)'
+    ctx.lineWidth = 1
+    for (let i = 0; i <= 4; i++) {
+      const y = (cssHeight / 4) * i
+      ctx.beginPath()
+      ctx.moveTo(0, y)
+      ctx.lineTo(cssWidth, y)
+      ctx.stroke()
+    }
+    const scaleX = cssWidth / 340
+    const scaleY = cssHeight / 120
+    const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
+    const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
+
+    // draw axes labels
+    const year = current.value.getFullYear()
+    const month = current.value.getMonth()
+    const days = daysInMonth(year, month)
+    const valuesForTicks: Array<number | null> = Array(days).fill(null)
+    records.value.forEach(r => {
+      const parts = r.date.split('-')
+      if (parts.length >= 3) {
+        const y = parseInt(parts[0], 10)
+        const m = parseInt(parts[1], 10) - 1
+        const d = parseInt(parts[2], 10)
+        if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.weight
+      }
+    })
+    const numeric = valuesForTicks.filter(v => v !== null) as number[]
+    const minVal = numeric.length ? Math.min(...numeric) : 40
+    const maxVal = numeric.length ? Math.max(...numeric) : 100
+    const range = Math.max(1, maxVal - minVal)
+    const leftPad = 10 * scaleX
+    const topPad = 10 * scaleY
+    const innerW = (340 - 10 - 10) * scaleX
+    const innerH = (120 - 10 - 10) * scaleY
+    ctx.fillStyle = '#999'
+    ctx.font = '12px sans-serif'
+    ctx.textAlign = 'right'
+    ctx.textBaseline = 'middle'
+    for (let j = 0; j <= 4; j++) {
+      const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
+      const y = topPad + (innerH * j) / 4
+      ctx.fillText(String(val), leftPad - 6, y)
+      ctx.beginPath()
+      ctx.moveTo(leftPad - 3, y)
+      ctx.lineTo(leftPad, y)
+      ctx.strokeStyle = 'rgba(0,0,0,0.08)'
+      ctx.stroke()
+    }
+    const step = Math.max(1, Math.ceil(days / 6))
+    ctx.textAlign = 'center'
+    ctx.textBaseline = 'top'
+    for (let d = 1; d <= days; d += step) {
+      const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
+      const yTick = topPad + innerH
+      ctx.beginPath()
+      ctx.moveTo(x, yTick)
+      ctx.lineTo(x, yTick + 6)
+      ctx.strokeStyle = 'rgba(0,0,0,0.08)'
+      ctx.stroke()
+      ctx.fillText(String(d), x, yTick + 8)
+    }
+    if (validPts.length) {
+      ctx.beginPath()
+      ctx.moveTo(validPts[0].x, validPts[0].y)
+      validPts.forEach(p => ctx.lineTo(p.x, p.y))
+      ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
+      ctx.lineTo(validPts[0].x, cssHeight)
+      ctx.closePath()
+      ctx.fillStyle = 'rgba(0,122,255,0.08)'
+      ctx.fill()
+    }
+    if (validPts.length) {
+      ctx.beginPath()
+      ctx.moveTo(validPts[0].x, validPts[0].y)
+      validPts.forEach(p => ctx.lineTo(p.x, p.y))
+      ctx.strokeStyle = '#007aff'
+      ctx.lineWidth = 2
+      ctx.lineJoin = 'round'
+      ctx.lineCap = 'round'
+      ctx.stroke()
+    }
+    validPts.forEach(p => {
+      ctx.beginPath()
+      ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
+      ctx.fillStyle = '#007aff'
+      ctx.fill()
+    })
+    return
+  }
+
+  // fallback for uni canvas
+  try {
+    const size = await getCanvasSize()
+    const cssWidth = size.width || 340
+    const cssHeight = size.height || 120
+    const ctx = (typeof uni !== 'undefined' && uni.createCanvasContext) ? uni.createCanvasContext('weightChart', vm?.proxy) : null
+    if (!ctx) return
+    ctx.clearRect && ctx.clearRect(0, 0, cssWidth, cssHeight)
+    ctx.setStrokeStyle && ctx.setStrokeStyle('rgba(0,0,0,0.04)')
+    for (let i = 0; i <= 4; i++) {
+      const y = (cssHeight / 4) * i
+      ctx.beginPath && ctx.beginPath()
+      ctx.moveTo && ctx.moveTo(0, y)
+      ctx.lineTo && ctx.lineTo(cssWidth, y)
+      ctx.stroke && ctx.stroke()
+    }
+    const scaleX = cssWidth / 340
+    const scaleY = cssHeight / 120
+    const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
+    const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
+    const year = current.value.getFullYear()
+    const month = current.value.getMonth()
+    const days = daysInMonth(year, month)
+    const valuesForTicks: Array<number | null> = Array(days).fill(null)
+    records.value.forEach(r => {
+      const parts = r.date.split('-')
+      if (parts.length >= 3) {
+        const y = parseInt(parts[0], 10)
+        const m = parseInt(parts[1], 10) - 1
+        const d = parseInt(parts[2], 10)
+        if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.weight
+      }
+    })
+    const numeric = valuesForTicks.filter(v => v !== null) as number[]
+    const minVal = numeric.length ? Math.min(...numeric) : 40
+    const maxVal = numeric.length ? Math.max(...numeric) : 100
+    const range = Math.max(1, maxVal - minVal)
+    const leftPad = 10 * scaleX
+    const topPad = 10 * scaleY
+    const innerW = (340 - 10 - 10) * scaleX
+    const innerH = (120 - 10 - 10) * scaleY
+    ctx.setFillStyle && ctx.setFillStyle('#999')
+    ctx.setFontSize && ctx.setFontSize(12)
+    for (let j = 0; j <= 4; j++) {
+      const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
+      const y = topPad + (innerH * j) / 4
+      ctx.fillText && ctx.fillText(String(val), leftPad - 6, y)
+      ctx.beginPath && ctx.beginPath()
+      ctx.moveTo && ctx.moveTo(leftPad - 3, y)
+      ctx.lineTo && ctx.lineTo(leftPad, y)
+      ctx.setStrokeStyle && ctx.setStrokeStyle('rgba(0,0,0,0.08)')
+      ctx.stroke && ctx.stroke()
+    }
+    const step = Math.max(1, Math.ceil(days / 6))
+    for (let d = 1; d <= days; d += step) {
+      const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
+      const yTick = topPad + innerH
+      ctx.beginPath && ctx.beginPath()
+      ctx.moveTo && ctx.moveTo(x, yTick)
+      ctx.lineTo && ctx.lineTo(x, yTick + 6)
+      ctx.setStrokeStyle && ctx.setStrokeStyle('rgba(0,0,0,0.08)')
+      ctx.stroke && ctx.stroke()
+      ctx.fillText && ctx.fillText(String(d), x, yTick + 8)
+    }
+    if (validPts.length) {
+      ctx.beginPath && ctx.beginPath()
+      ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
+      validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
+      ctx.lineTo && ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
+      ctx.lineTo && ctx.lineTo(validPts[0].x, cssHeight)
+      ctx.closePath && ctx.closePath()
+      ctx.setFillStyle && ctx.setFillStyle('rgba(0,122,255,0.08)')
+      ctx.fill && ctx.fill()
+    }
+    if (validPts.length) {
+      ctx.beginPath && ctx.beginPath()
+      ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
+      validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
+      ctx.setStrokeStyle && ctx.setStrokeStyle('#007aff')
+      ctx.setLineWidth && ctx.setLineWidth(2)
+      ctx.stroke && ctx.stroke()
+    }
+    validPts.forEach(p => {
+      ctx.beginPath && ctx.beginPath()
+      ctx.arc && ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
+      ctx.setFillStyle && ctx.setFillStyle('#007aff')
+      ctx.fill && ctx.fill()
+    })
+    ctx.draw && ctx.draw(true)
+  } catch (err) {
+    // ignore
+  }
+}
+
+onMounted(() => {
+  nextTick(() => drawChart())
+})
+
+watch([() => records.value, () => current.value], () => {
+  nextTick(() => drawChart())
+}, { deep: true })
+
+onBeforeUnmount(() => {
+  // nothing
+})
+
+function prevMonth() {
+  const d = new Date(current.value)
+  d.setMonth(d.getMonth() - 1)
+  current.value = d
+  pickerValue.value = formatPickerDate(d)
+  records.value = generateMockRecords(d)
+}
+
+function nextMonth() {
+  const d = new Date(current.value)
+  d.setMonth(d.getMonth() + 1)
+  current.value = d
+  pickerValue.value = formatPickerDate(d)
+  records.value = generateMockRecords(d)
+}
+
+function onPickerChange(e: any) {
+  const val = e?.detail?.value || e
+  const parts = (val as string).split('-')
+  if (parts.length >= 2) {
+    const y = parseInt(parts[0], 10)
+    const m = parseInt(parts[1], 10) - 1
+    const d = new Date(y, m, 1)
+    current.value = d
+    pickerValue.value = formatPickerDate(d)
+    records.value = generateMockRecords(d)
+  }
+}
+
+// 添加逻辑
+const showAdd = ref(false)
+const addDate = ref(formatPickerDate(new Date()))
+const addDateLabel = ref(formatDisplayDate(new Date()))
+const addWeight = ref<number | null>(null)
+
+function onAddRulerUpdate(val: number) {
+  addWeight.value = Number(val.toFixed ? Number(val.toFixed(1)) : val)
+}
+function onAddRulerChange(val: number) {
+  addWeight.value = Number(val.toFixed ? Number(val.toFixed(1)) : val)
+}
+
+function openAdd() {
+  showAdd.value = true
+  if (!addWeight.value) addWeight.value = 65
+}
+function closeAdd() {
+  showAdd.value = false
+  addWeight.value = null
+}
+
+function onAddDateChange(e: any) {
+  const val = e?.detail?.value || e
+  addDate.value = val
+  addDateLabel.value = val.replace(/^(.{10}).*$/, '$1')
+}
+
+function confirmAdd() {
+  if (!addWeight.value) {
+    uni.showToast && uni.showToast({ title: '请输入体重', icon: 'none' })
+    return
+  }
+  const id = `user-${Date.now()}`
+  const item: RecordItem = { id, date: addDateLabel.value, weight: Number(Number(addWeight.value).toFixed(1)) }
+  const parts = addDate.value.split('-')
+  const addY = parseInt(parts[0], 10)
+  const addM = parseInt(parts[1], 10) - 1
+  if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
+    records.value = [item, ...records.value]
+  }
+  uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
+  closeAdd()
+}
+
+function confirmDeleteRecord(id: string) {
+  if (typeof uni !== 'undefined' && uni.showModal) {
+    uni.showModal({ title: '删除', content: '确认删除该条记录吗?', success(res: any) { if (res.confirm) records.value = records.value.filter(r => r.id !== id) } })
+  } else {
+    records.value = records.value.filter(r => r.id !== id)
+  }
+}
 </script>
 
-<style>
-.placeholder { padding: 40rpx; color: #666 }
-.content { padding-top: calc(var(--status-bar-height) + 44px) }
+<style scoped>
+.page { min-height: calc(100vh); padding-top: calc(var(--status-bar-height) + 44px); background: #f5f6f8; box-sizing: border-box }
+.header { padding: 20rpx 40rpx }
+.month-selector { display: flex; align-items: center; justify-content: center; gap: 12rpx }
+.month-label { font-size: 34rpx; color: #333 }
+.btn { background: transparent; border: none; font-size: 36rpx; color: #666 }
+.content { padding: 20rpx 24rpx 100rpx 24rpx }
+.chart-wrap { padding: 12rpx 24rpx }
+.chart-header { font-size: 28rpx; color: #666; margin-bottom: 10rpx }
+.chart-canvas { width: 100%; height: 160rpx; display: block }
+.summary { padding: 20rpx; color: #666; font-size: 28rpx }
+.list { background: #fff; border-radius: 12rpx; padding: 10rpx; box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) }
+.empty { padding: 40rpx; text-align: center; color: #999 }
+.list-item { display: flex; align-items: center; padding: 20rpx; border-bottom: 1rpx solid #f0f0f0 }
+.list-item .date { color: #666 }
+.list-item .value { color: #333; font-weight: 600; flex: 1; text-align: right }
+.btn-delete { width: 80rpx; height: 60rpx; min-width: 60rpx; min-height: 60rpx; display: inline-flex; align-items: center; justify-content: center; background: #fff0f0; color: #d9534f; border: 1rpx solid rgba(217,83,79,0.15); border-radius: 8rpx; margin-left: 30rpx }
+.fab { position: fixed; right: 28rpx; bottom: 160rpx; width: 110rpx; height: 110rpx; border-radius: 999px; background: linear-gradient(180deg, #ff7a00, #ff4a00); display: flex; align-items: center; justify-content: center; box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2); z-index: 1200 }
+.fab-inner { color: #fff; font-size: 56rpx; line-height: 56rpx }
+.modal { position: fixed; left: 0; right: 0; top: 0; bottom: 0; display: flex; align-items: flex-end; justify-content: center; z-index: 1300 }
+.modal-backdrop { position: absolute; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0, 0, 0, 0.4) }
+.modal-panel { position: relative; width: 100%; background: #fff; border-top-left-radius: 18rpx; border-top-right-radius: 18rpx; padding: 28rpx 24rpx 140rpx 24rpx; box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12) }
+.modal-title { font-size: 56rpx; margin-block:  60rpx; color: #222; font-weight: 700; letter-spacing: 1rpx }
+.modal-inner { max-width: 70%; margin: 0 auto }
+.form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 34rpx; padding: 14rpx 0; font-size: 32rpx }
+.input { width: 240rpx; text-align: right; padding: 16rpx; border-radius: 14rpx; border: 1rpx solid #eee; background: #fff7f0 }
+.picker-display { color: #333 }
+.modal-actions { display: flex; justify-content: center; gap: 12rpx; margin-top: 18rpx }
+.modal-actions.full { padding: 6rpx 0 24rpx 0 }
+.btn-primary { background: #ff6a00; color: #fff; padding: 18rpx 22rpx; border-radius: 16rpx; text-align: center; width: 50%; box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18) }
+.drag-handle { width: 64rpx; height: 6rpx; background: rgba(0,0,0,0.08); border-radius: 999px; margin: 10rpx auto 14rpx auto }
+.modal-header { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 6rpx }
+.label { color: #666 }
+.ruler-wrap { margin: 12rpx 0 }
+.fixed-footer { position: absolute; left: 0; right: 0; bottom: 40rpx; padding: 0 24rpx }
+.btn-full { width: 100%; padding: 18rpx; border-radius: 12rpx; }
 </style>