| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782 |
- <template>
- <CustomNav title="身高" leftType="back" />
- <view class="page">
- <view class="header">
- <view class="month-selector">
- <button class="btn" @click="prevMonth">‹</button>
- <picker mode="date" :value="pickerValue" @change="onPickerChange">
- <view class="month-label">{{ displayYear }} 年 {{ displayMonth }} 月</view>
- </picker>
- <button class="btn" @click="nextMonth">›</button>
- </view>
- </view>
- <!-- 趋势图:放在月份选择下面 -->
- <view class="chart-wrap">
- <view class="chart-header">本月趋势</view>
- <canvas ref="chartCanvas" id="chartCanvas" canvas-id="heightChart" class="chart-canvas" width="340"
- height="120"></canvas>
- </view>
- <!-- 刻度尺控制(已移入添加模态) -->
- <view class="content">
- <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm</view>
- <view class="list">
- <view v-if="records.length === 0" class="empty">本月暂无身高记录,点击右下角 + 添加</view>
- <view v-for="(r, idx) in records" :key="r.id" class="list-item">
- <view class="date">{{ r.date }}</view>
- <view class="value">{{ r.height }} cm</view>
- <button class="btn-delete" @click.stop.prevent="confirmDeleteRecord(r.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">
- <!-- Drag handle -->
- <view class="drag-handle" />
- <view class="modal-header centered">
- <view class="modal-title">新增身高记录</view>
- </view>
- <!-- 将表单与按钮限制在中间的窄容器,便于阅读和操作 -->
- <view class="modal-inner">
- <view class="form-row">
- <text class="label">日期</text>
- <picker mode="date" :value="pickerValue" @change="onAddDateChange">
- <view class="picker-display">{{ addDateLabel }}</view>
- </picker>
- </view>
- <view class="form-row">
- <text class="label">身高 (cm)</text>
- <input type="number" v-model.number="addHeight" class="input" placeholder="请输入身高" />
- </view>
- </view>
- <!-- 刻度尺:独立于 .modal-inner,保持屏幕全宽以便手指操作 -->
- <view class="ruler-wrap">
- <ScaleRuler v-if="showAdd" :min="120" :max="220" :step="1" :gutter="16" :initialValue="addHeight ?? 170"
- @change="onAddRulerChange" @update:value="onAddRulerUpdate" />
- </view>
- <!-- 固定底部的大保存按钮(距离屏幕底部至少 100rpx) -->
- <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 } 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; height: number }
- // 当前展示的年月(以 JS Date 管理)
- const current = ref(new Date())
- const pickerValue = ref(formatPickerDate(current.value))
- function formatPickerDate(d: Date) {
- // 返回 yyyy-MM-dd,用于 picker 的 value
- 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[] = []
- // 随机生成 0-6 条数据
- 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), height: 150 + Math.floor(Math.random() * 50) })
- }
- // 按日期排序
- 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 averageHeight = computed(() => {
- if (records.value.length === 0) return '--'
- const sum = records.value.reduce((s, r) => s + r.height, 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)
- // 宽度 340,高度 120,给上下留白
- 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
- // 按日填充值(null 表示无数据)
- 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.height
- }
- }
- })
- const numeric = values.filter(v => v !== null) as number[]
- const min = numeric.length ? Math.min(...numeric) : 140
- const max = numeric.length ? Math.max(...numeric) : 180
- 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
- })
- const polylinePoints = computed(() => {
- const pts = pointCoords.value
- const arr: string[] = []
- pts.forEach(p => {
- if (p) arr.push(`${p.x},${p.y}`)
- })
- return arr.length ? arr.join(' ') : ''
- })
- const areaPoints = computed(() => {
- const pts = pointCoords.value
- if (!pts.length) return ''
- const arr: string[] = []
- pts.forEach(p => {
- if (p) arr.push(`${p.x},${p.y}`)
- })
- // 加上底部闭合
- if (arr.length) {
- const firstX = pts.find(p => p)?.x ?? 0
- const lastX = pts.slice().reverse().find(p => p)?.x ?? 0
- const baseY = 120 - 10
- return `${firstX},${baseY} ` + arr.join(' ') + ` ${lastX},${baseY}`
- }
- return ''
- })
- // Canvas 绘图
- import { onMounted, watch, nextTick, onBeforeUnmount, ref as vueRef, getCurrentInstance } from 'vue'
- const chartCanvas = vueRef<HTMLCanvasElement | null>(null)
- const vm = getCurrentInstance()
- function getCanvasSize(): Promise<{ width: number; height: number }> {
- return new Promise(resolve => {
- try {
- if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
- const query = uni.createSelectorQuery().in(vm?.proxy)
- query.select('#chartCanvas').boundingClientRect((rect: any) => {
- if (rect) resolve({ width: rect.width, height: rect.height })
- 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() {
- // If running in H5 and canvas DOM is available, use native canvas
- 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 Y axis labels (4 ticks) and X axis day 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.height
- }
- })
- const numeric = valuesForTicks.filter(v => v !== null) as number[]
- const minVal = numeric.length ? Math.min(...numeric) : 140
- const maxVal = numeric.length ? Math.max(...numeric) : 180
- 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(255,106,0,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 = '#ff6a00'
- ctx.lineWidth = 2
- ctx.lineJoin = 'round'
- ctx.lineCap = 'round'
- ctx.stroke()
- }
- validPts.forEach(p => {
- ctx.beginPath()
- ctx.fillStyle = '#ff6a00'
- ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
- ctx.fill()
- })
- return
- }
- // Otherwise fallback to uni.createCanvasContext for mini-programs / native canvases
- try {
- const size = await getCanvasSize()
- const cssWidth = size.width || 340
- const cssHeight = size.height || 120
- const ctx = (typeof uni !== 'undefined' && uni.createCanvasContext) ? uni.createCanvasContext('heightChart', vm?.proxy) : null
- if (!ctx) return
- // clear by drawing a transparent rect
- 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
- if (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 }[]
- // draw labels (mini-program canvas API)
- 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.height
- }
- })
- const numeric = valuesForTicks.filter(v => v !== null) as number[]
- const minVal = numeric.length ? Math.min(...numeric) : 140
- const maxVal = numeric.length ? Math.max(...numeric) : 180
- 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)
- // Y axis
- 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.stroke && ctx.stroke()
- }
- const step = Math.max(1, Math.ceil(days / 6))
- // X axis
- for (let d = 1; d <= days; d += step) {
- const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
- const yTick = topPad + innerH
- ctx.moveTo && ctx.moveTo(x, yTick)
- ctx.lineTo && ctx.lineTo(x, yTick + 6)
- 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(255,106,0,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('#ff6a00')
- 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('#ff6a00')
- 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(() => {
- // no special cleanup required
- })
- 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 addHeight = ref<number | null>(null)
- // 刻度尺(添加模态使用)的联动处理
- function onAddRulerUpdate(val: number) {
- addHeight.value = val
- // 实时打印用户滑动时的刻度(update 在滚动过程中会频繁触发)
- console.log('[ScaleRuler] add update:value ->', val)
- }
- function onAddRulerChange(val: number) {
- addHeight.value = val
- console.log('[ScaleRuler] add change ->', val)
- }
- function openAdd() {
- showAdd.value = true
- // 打开添加模态时,初始化刻度尺/输入默认值(若之前无值则设为 170)
- if (!addHeight.value) addHeight.value = 170
- }
- function closeAdd() {
- showAdd.value = false
- addHeight.value = null
- }
- function onAddDateChange(e: any) {
- const val = e?.detail?.value || e
- addDate.value = val
- addDateLabel.value = val.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1-$2-$3')
- }
- function confirmAdd() {
- if (!addHeight.value) {
- uni.showToast({ title: '请输入身高', icon: 'none' })
- return
- }
- const id = `user-${Date.now()}`
- const item: RecordItem = { id, date: addDateLabel.value, height: addHeight.value }
- // 如果添加的月份与当前展示月份一致,插入
- 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({ title: '已添加', icon: 'success' })
- closeAdd()
- }
- // 删除记录逻辑:弹出确认对话框,确认后从 records 中移除
- function confirmDeleteRecord(id: string) {
- if (typeof uni !== 'undefined' && uni.showModal) {
- uni.showModal({
- title: '删除记录',
- content: '确认要删除该条身高记录吗?此操作无法撤销。',
- confirmText: '删除',
- cancelText: '取消',
- success: (res: any) => {
- if (res.confirm) {
- records.value = records.value.filter(r => r.id !== id)
- uni.showToast({ title: '已删除', icon: 'success' })
- }
- }
- })
- } else {
- // Fallback:直接删除
- records.value = records.value.filter(r => r.id !== id)
- }
- }
- </script>
- <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-svg {
- 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; /* allow value to take remaining space */
- text-align: right; /* keep value left aligned so delete button on 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; /* leave space for fixed footer */
- 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 and header */
- .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;
- /* keep full width so ScaleRuler spans screen width */
- }
- </style>
|