会员个人中心页面初步完成
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
const COLORS = {
|
||||
primary: '#0B2B4B',
|
||||
accent: '#FF6B35',
|
||||
accentLight: 'rgba(255, 107, 53, 0.25)',
|
||||
grid: '#E9EDF2',
|
||||
text: '#5E6F8D',
|
||||
fill: 'rgba(26, 74, 111, 0.35)',
|
||||
line: '#1A4A6F'
|
||||
}
|
||||
|
||||
function setupCanvas(canvas, width, height, dpr) {
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 绘制雷达图 */
|
||||
export function drawRadarChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 280,
|
||||
height = 240,
|
||||
labels = [],
|
||||
values = [],
|
||||
dpr = 1
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const cx = width / 2
|
||||
const cy = height / 2 + 8
|
||||
const radius = Math.min(width, height) * 0.32
|
||||
const count = labels.length || 6
|
||||
const angleStep = (Math.PI * 2) / count
|
||||
|
||||
for (let level = 1; level <= 4; level += 1) {
|
||||
ctx.beginPath()
|
||||
const r = (radius * level) / 4
|
||||
for (let i = 0; i <= count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + r * Math.cos(angle)
|
||||
const y = cy + r * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle))
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
values.forEach((val, i) => {
|
||||
const ratio = Math.min(1, Math.max(0, val / 100))
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + radius * ratio * Math.cos(angle)
|
||||
const y = cy + radius * ratio * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = COLORS.fill
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = COLORS.accent
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.textAlign = 'center'
|
||||
labels.forEach((label, i) => {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + (radius + 18) * Math.cos(angle)
|
||||
const y = cy + (radius + 18) * Math.sin(angle) + 4
|
||||
ctx.fillText(label, x, y)
|
||||
})
|
||||
}
|
||||
|
||||
/** 绘制折线趋势图 */
|
||||
export function drawTrendChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 300,
|
||||
height = 160,
|
||||
points = [],
|
||||
dpr = 1,
|
||||
unit = ''
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('暂无趋势数据', width / 2, height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
const pad = { top: 16, right: 12, bottom: 28, left: 12 }
|
||||
const chartW = width - pad.left - pad.right
|
||||
const chartH = height - pad.top - pad.bottom
|
||||
const values = points.map((p) => p.value)
|
||||
const min = Math.min(...values) * 0.95
|
||||
const max = Math.max(...values) * 1.05
|
||||
const range = max - min || 1
|
||||
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i <= 3; i += 1) {
|
||||
const y = pad.top + (chartH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(width - pad.right, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const coords = points.map((p, i) => ({
|
||||
x: pad.left + (chartW * i) / Math.max(1, points.length - 1),
|
||||
y: pad.top + chartH - ((p.value - min) / range) * chartH
|
||||
}))
|
||||
|
||||
ctx.beginPath()
|
||||
coords.forEach((pt, i) => {
|
||||
if (i === 0) ctx.moveTo(pt.x, pt.y)
|
||||
else ctx.lineTo(pt.x, pt.y)
|
||||
})
|
||||
ctx.strokeStyle = COLORS.line
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
coords.forEach((pt, i) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = COLORS.accent
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(points[i].label, pt.x, height - 8)
|
||||
})
|
||||
|
||||
if (unit) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(unit, pad.left, pad.top - 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { bodyTestMock } from './mockData.js'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${min}`
|
||||
}
|
||||
|
||||
export function getDefaultBodyTestState() {
|
||||
return {
|
||||
settings: { ...bodyTestMock.settings },
|
||||
device: { ...bodyTestMock.device },
|
||||
records: clone(bodyTestMock.records)
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBodyTestState(saved) {
|
||||
const defaults = getDefaultBodyTestState()
|
||||
if (!saved) return defaults
|
||||
return {
|
||||
settings: { ...defaults.settings, ...(saved.settings || {}) },
|
||||
device: { ...defaults.device, ...(saved.device || {}) },
|
||||
records: saved.records?.length ? saved.records : defaults.records
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestBodyTestRecord(store) {
|
||||
const records = store.bodyTest?.records || []
|
||||
return records.length ? { ...records[0] } : null
|
||||
}
|
||||
|
||||
export function getBodyTestRecordById(store, id) {
|
||||
const numId = Number(id)
|
||||
const record = (store.bodyTest?.records || []).find((item) => item.id === numId)
|
||||
return record ? { ...record } : null
|
||||
}
|
||||
|
||||
export function getBodyTestHistory(store, year) {
|
||||
let list = (store.bodyTest?.records || []).map((item) => ({ ...item }))
|
||||
if (year && year !== 'all') {
|
||||
list = list.filter((r) => r.date.startsWith(String(year)))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function getBodyTestChangeBadge(record, previous) {
|
||||
if (!previous?.metrics || !record?.metrics) return null
|
||||
const diff = Math.round((record.metrics.bodyFat - previous.metrics.bodyFat) * 10) / 10
|
||||
if (diff === 0) return null
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
return { key: 'bodyFat', text: `体脂率${sign}${diff}%`, good: diff < 0 }
|
||||
}
|
||||
|
||||
export function getBodyTestYears(store) {
|
||||
const years = new Set((store.bodyTest?.records || []).map((r) => r.date.slice(0, 4)))
|
||||
return ['all', ...Array.from(years).sort().reverse()]
|
||||
}
|
||||
|
||||
export function computeGrade(score) {
|
||||
if (score >= 90) return { grade: 'A', gradeLabel: '优秀' }
|
||||
if (score >= 80) return { grade: 'B+', gradeLabel: '良好' }
|
||||
if (score >= 70) return { grade: 'B', gradeLabel: '中等' }
|
||||
if (score >= 60) return { grade: 'C', gradeLabel: '一般' }
|
||||
return { grade: 'D', gradeLabel: '需改善' }
|
||||
}
|
||||
|
||||
export function computeScore(metrics) {
|
||||
const bmi = metrics.bmi || 22
|
||||
const bodyFat = metrics.bodyFat || 25
|
||||
const muscle = metrics.muscleMass || 22
|
||||
const bmiScore = bmi >= 18.5 && bmi <= 24 ? 90 : bmi >= 17 && bmi <= 27 ? 75 : 60
|
||||
const fatScore = bodyFat <= 22 ? 92 : bodyFat <= 26 ? 80 : bodyFat <= 30 ? 68 : 55
|
||||
const muscleScore = muscle >= 22 ? 88 : muscle >= 20 ? 76 : 62
|
||||
return Math.round((bmiScore + fatScore + muscleScore) / 3)
|
||||
}
|
||||
|
||||
export function computeChanges(current, previous) {
|
||||
if (!previous?.metrics) return {}
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr', 'bodyWater', 'boneMass']
|
||||
const changes = {}
|
||||
keys.forEach((key) => {
|
||||
const cur = Number(current.metrics[key])
|
||||
const prev = Number(previous.metrics[key])
|
||||
if (Number.isFinite(cur) && Number.isFinite(prev)) {
|
||||
const diff = Math.round((cur - prev) * 10) / 10
|
||||
changes[key] = diff
|
||||
}
|
||||
})
|
||||
return changes
|
||||
}
|
||||
|
||||
export function formatChangeValue(key, diff, unitSystem = 'metric') {
|
||||
if (diff === undefined || diff === null) return '--'
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const units = {
|
||||
weight: unitSystem === 'metric' ? 'kg' : 'lb',
|
||||
bodyFat: '%',
|
||||
muscleMass: 'kg',
|
||||
bmi: '',
|
||||
visceralFat: '级',
|
||||
bmr: 'kcal',
|
||||
bodyWater: '%',
|
||||
boneMass: 'kg'
|
||||
}
|
||||
const unit = units[key] || ''
|
||||
return `${sign}${diff}${unit}`
|
||||
}
|
||||
|
||||
export function buildBodyReportSummary(record, previous) {
|
||||
if (!record) {
|
||||
return {
|
||||
date: '--',
|
||||
weight: '--',
|
||||
bmi: '--',
|
||||
bodyFat: '--',
|
||||
bmr: '--',
|
||||
status: '暂无数据',
|
||||
change: '--'
|
||||
}
|
||||
}
|
||||
const changes = computeChanges(record, previous)
|
||||
const weightChange = changes.weight
|
||||
let changeText = '--'
|
||||
if (weightChange !== undefined) {
|
||||
const sign = weightChange > 0 ? '+' : ''
|
||||
changeText = `${sign}${weightChange}kg`
|
||||
}
|
||||
return {
|
||||
date: record.date,
|
||||
weight: String(record.metrics.weight),
|
||||
bmi: String(record.metrics.bmi),
|
||||
bodyFat: `${record.metrics.bodyFat}%`,
|
||||
bmr: String(record.metrics.bmr),
|
||||
status: record.status,
|
||||
change: changeText,
|
||||
recordId: record.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getBodyTestTrendData(store, metricKey, limit = 6) {
|
||||
const records = [...(store.bodyTest?.records || [])].reverse().slice(-limit)
|
||||
return records.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
label: item.date.slice(5),
|
||||
value: Number(item.metrics[metricKey]) || 0
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCompareData(store, idA, idB) {
|
||||
const a = getBodyTestRecordById(store, idA)
|
||||
const b = getBodyTestRecordById(store, idB)
|
||||
if (!a || !b) return null
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
|
||||
const metrics = keys.map((key) => ({
|
||||
key,
|
||||
label: bodyTestMock.metricDefs.find((m) => m.key === key)?.label || key,
|
||||
valueA: a.metrics[key],
|
||||
valueB: b.metrics[key],
|
||||
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
|
||||
}))
|
||||
return { recordA: a, recordB: b, metrics }
|
||||
}
|
||||
|
||||
export function getRecommendedCourses(record) {
|
||||
const ids = record?.recommendedCourseIds || []
|
||||
return bodyTestMock.recommendedCourses.filter((c) => ids.includes(c.id))
|
||||
}
|
||||
|
||||
export function updateBodyTestSettings(store, patch) {
|
||||
store.bodyTest.settings = { ...store.bodyTest.settings, ...patch }
|
||||
return store
|
||||
}
|
||||
|
||||
export function connectBodyTestDevice(store) {
|
||||
store.bodyTest.device = {
|
||||
...store.bodyTest.device,
|
||||
connected: true,
|
||||
battery: Math.min(100, (store.bodyTest.device.battery || 80) + Math.floor(Math.random() * 5)),
|
||||
lastConnected: formatRecordTime(new Date())
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
export function disconnectBodyTestDevice(store) {
|
||||
store.bodyTest.device = { ...store.bodyTest.device, connected: false }
|
||||
return store
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
/** 模拟一次完整体测并写入记录 */
|
||||
export function saveSimulatedBodyTestRecord(store, finalMetrics) {
|
||||
const now = new Date()
|
||||
const previous = getLatestBodyTestRecord(store)
|
||||
const metrics = { ...finalMetrics }
|
||||
const heightCm = Number(store.profile?.height) || 165
|
||||
const heightM = heightCm / 100
|
||||
metrics.bmi = Math.round((metrics.weight / (heightM * heightM)) * 10) / 10
|
||||
|
||||
const score = computeScore(metrics)
|
||||
const { grade, gradeLabel } = computeGrade(score)
|
||||
const status = score >= 80 ? '比较健康' : score >= 70 ? '需关注' : '建议改善'
|
||||
|
||||
const radar = {
|
||||
weight: Math.min(95, Math.round(score * 0.9 + Math.random() * 5)),
|
||||
bodyFat: Math.min(95, Math.round(100 - metrics.bodyFat * 2.5)),
|
||||
muscle: Math.min(95, Math.round(metrics.muscleMass * 3.2)),
|
||||
bone: Math.min(95, Math.round(metrics.boneMass * 32)),
|
||||
water: Math.min(95, Math.round(metrics.bodyWater * 1.4)),
|
||||
bmr: Math.min(95, Math.round(metrics.bmr / 16))
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: nextRecordId(store.bodyTest.records),
|
||||
date: formatIsoDate(now),
|
||||
time: formatTime(now),
|
||||
score,
|
||||
grade,
|
||||
gradeLabel,
|
||||
status,
|
||||
metrics,
|
||||
radar,
|
||||
bodySegments: clone(bodyTestMock.records[0].bodySegments),
|
||||
advice: clone(bodyTestMock.records[0].advice),
|
||||
recommendedCourseIds: [1, 2]
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
record.changes = computeChanges(record, previous)
|
||||
}
|
||||
|
||||
store.bodyTest.records.unshift(record)
|
||||
store.bodyReport = buildBodyReportSummary(record, previous)
|
||||
return record
|
||||
}
|
||||
|
||||
/** 测量过程实时数据插值 */
|
||||
export function interpolateMeasuringMetrics(progress, profile) {
|
||||
const baseWeight = Number(profile?.weight) || 64
|
||||
const target = {
|
||||
weight: baseWeight - 0.3 + Math.random() * 0.2,
|
||||
bodyFat: 24.5 + Math.random() * 0.8,
|
||||
muscleMass: 22.4 + Math.random() * 0.3,
|
||||
visceralFat: 6,
|
||||
bmr: 1380 + Math.floor(Math.random() * 20),
|
||||
bodyWater: 52.5 + Math.random(),
|
||||
boneMass: 2.4,
|
||||
protein: 16.2
|
||||
}
|
||||
const ratio = Math.min(1, progress / 100)
|
||||
return {
|
||||
weight: Math.round((baseWeight + (target.weight - baseWeight) * ratio) * 10) / 10,
|
||||
bodyFat: Math.round((26 + (target.bodyFat - 26) * ratio) * 10) / 10,
|
||||
muscleMass: Math.round((21.5 + (target.muscleMass - 21.5) * ratio) * 10) / 10,
|
||||
bmr: Math.round(1340 + (target.bmr - 1340) * ratio),
|
||||
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
|
||||
}
|
||||
}
|
||||
|
||||
export { bodyTestMock }
|
||||
@@ -0,0 +1,127 @@
|
||||
import { courseCatalogMock } from './mockData.js'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function getDefaultCourseCatalog() {
|
||||
return clone(courseCatalogMock.courses)
|
||||
}
|
||||
|
||||
export function mergeCourseCatalog(saved) {
|
||||
const defaults = getDefaultCourseCatalog()
|
||||
if (!saved?.length) return defaults
|
||||
return saved.map((item, i) => ({ ...defaults[i], ...item }))
|
||||
}
|
||||
|
||||
function parseCourseStart(course) {
|
||||
const str = `${course.date} ${course.startTime}`.replace(/-/g, '/')
|
||||
return new Date(str)
|
||||
}
|
||||
|
||||
function getPeriod(hour) {
|
||||
if (hour < 12) return 'morning'
|
||||
if (hour < 18) return 'afternoon'
|
||||
return 'evening'
|
||||
}
|
||||
|
||||
export function filterCourses(courses, filters = {}) {
|
||||
const {
|
||||
date = '',
|
||||
weekDates = [],
|
||||
type = 'all',
|
||||
coach = '全部',
|
||||
period = 'all'
|
||||
} = filters
|
||||
|
||||
return courses.filter((c) => {
|
||||
if (type !== 'all' && c.type !== type) return false
|
||||
if (coach !== '全部' && c.coach !== coach) return false
|
||||
if (period !== 'all' && c.period !== period) return false
|
||||
if (date && c.date !== date) {
|
||||
if (!weekDates.length || !weekDates.includes(c.date)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function getCourseById(store, id) {
|
||||
const course = (store.courseCatalog || []).find((c) => c.id === Number(id))
|
||||
return course ? { ...course } : null
|
||||
}
|
||||
|
||||
export function canCancelBooking(item) {
|
||||
if (!item?.courseDate || !item?.startTime) return !!item?.canCancel
|
||||
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
|
||||
const diff = start - Date.now()
|
||||
return diff >= 2 * 3600000
|
||||
}
|
||||
|
||||
export function bookCourse(store, courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === Number(courseId))
|
||||
if (!course) return { ok: false, message: '课程不存在' }
|
||||
if (course.enrolled >= course.capacity) return { ok: false, message: '课程已约满' }
|
||||
const exists = store.ongoingBookings.some((b) => b.courseId === course.id)
|
||||
if (exists) return { ok: false, message: '您已预约该课程' }
|
||||
|
||||
course.enrolled += 1
|
||||
const nextId = store.ongoingBookings.reduce((m, b) => Math.max(m, b.id || 0), 0) + 1
|
||||
const parts = course.date.split('-')
|
||||
const booking = {
|
||||
id: nextId,
|
||||
courseId: course.id,
|
||||
title: course.title,
|
||||
banner: course.banner,
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: `${parts[1]}月${parts[2]}日 ${course.startTime}-${course.endTime}`,
|
||||
dateDay: parts[2],
|
||||
dateMonth: `月${parts[2]}日`,
|
||||
timeRange: `${course.startTime}-${course.endTime}`,
|
||||
courseDate: course.date,
|
||||
startTime: course.startTime,
|
||||
coach: course.coach,
|
||||
coachShort: course.coach.replace('教练', ''),
|
||||
location: course.location,
|
||||
footerText: `可取消(需提前2小时,截止 ${parts[1]}/${parts[2]} ${course.startTime} 前2小时)`,
|
||||
canCancel: true,
|
||||
type: course.type
|
||||
}
|
||||
store.ongoingBookings.unshift(booking)
|
||||
return { ok: true, message: '预约成功', booking }
|
||||
}
|
||||
|
||||
export function getWeekDates(baseDateStr) {
|
||||
const base = baseDateStr ? new Date(baseDateStr.replace(/-/g, '/')) : new Date()
|
||||
const day = base.getDay() || 7
|
||||
const monday = new Date(base)
|
||||
monday.setDate(base.getDate() - day + 1)
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
dates.push(formatIso(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
function formatIso(d) {
|
||||
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}`
|
||||
}
|
||||
|
||||
export function enrichCourseForDisplay(course) {
|
||||
const remaining = course.capacity - course.enrolled
|
||||
const percent = Math.round((course.enrolled / course.capacity) * 100)
|
||||
return {
|
||||
...course,
|
||||
remaining,
|
||||
percent,
|
||||
full: remaining <= 0,
|
||||
scarcityLabel: remaining > 0 && remaining <= 5 ? `仅剩${remaining}席` : ''
|
||||
}
|
||||
}
|
||||
|
||||
export { courseCatalogMock }
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 手机号展示脱敏(中间四位 ****) */
|
||||
|
||||
export function maskPhone(phone) {
|
||||
if (phone == null || phone === '') return ''
|
||||
|
||||
const str = String(phone).trim()
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length === 11) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(7)}`
|
||||
}
|
||||
if (digits.length > 4) {
|
||||
const hideLen = Math.min(4, digits.length - 3)
|
||||
const start = Math.floor((digits.length - hideLen) / 2)
|
||||
return `${digits.slice(0, start)}${'*'.repeat(hideLen)}${digits.slice(start + hideLen)}`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/** 个人中心头部:138****6789 已绑定微信 */
|
||||
export function formatMemberCenterPhone(phone) {
|
||||
const masked = maskPhone(phone)
|
||||
return masked ? `${masked} 已绑定微信` : ''
|
||||
}
|
||||
|
||||
/** 保存前规范化:尽量存 11 位数字;已是脱敏串则原样保留 */
|
||||
export function normalizePhoneForStore(phone) {
|
||||
const str = String(phone || '').trim()
|
||||
if (!str) return ''
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length >= 11) return digits.slice(0, 11)
|
||||
return digits || str
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export { memberCenterMock, userInfoMock, fitnessGoalOptions, bookingMock, memberCardMock, bodyTestMock, moduleMock, courseCatalogMock } from './mockData.js'
|
||||
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
|
||||
export {
|
||||
loadMemberStore,
|
||||
saveMemberStore,
|
||||
persistMemberStore,
|
||||
syncStats,
|
||||
computeRemainingDays,
|
||||
buildCardTip,
|
||||
formatUpcomingAlert,
|
||||
getBookingPreview,
|
||||
getCenterPageData,
|
||||
cancelOngoingBooking,
|
||||
renewMemberCard,
|
||||
parseLocalDate,
|
||||
saveUserProfile
|
||||
} from './store.js'
|
||||
export {
|
||||
getLatestBodyTestRecord,
|
||||
getBodyTestRecordById,
|
||||
getBodyTestHistory,
|
||||
computeChanges,
|
||||
formatChangeValue,
|
||||
buildBodyReportSummary,
|
||||
getBodyTestTrendData,
|
||||
getCompareData,
|
||||
getRecommendedCourses,
|
||||
getBodyTestChangeBadge,
|
||||
getBodyTestYears,
|
||||
updateBodyTestSettings,
|
||||
connectBodyTestDevice,
|
||||
disconnectBodyTestDevice,
|
||||
saveSimulatedBodyTestRecord,
|
||||
interpolateMeasuringMetrics,
|
||||
bodyTestMock
|
||||
} from './bodyTestStore.js'
|
||||
export {
|
||||
getTrainingReportData,
|
||||
getTrainingSessionById,
|
||||
filterTrainingSessions,
|
||||
getCouponsByStatus,
|
||||
getCouponById,
|
||||
useCoupon,
|
||||
deleteExpiredCoupon,
|
||||
getCouponCenterList,
|
||||
claimCouponFromCenter,
|
||||
getPointsPageData,
|
||||
redeemPointsReward,
|
||||
filterPointsHistory,
|
||||
getReferralPageData,
|
||||
getMyCoursesData,
|
||||
getMyCoursesByTab,
|
||||
getOnlineCourseById,
|
||||
updateOnlineProgress,
|
||||
getCheckInHistory,
|
||||
moduleMock
|
||||
} from './moduleStore.js'
|
||||
export {
|
||||
filterCourses,
|
||||
getCourseById,
|
||||
bookCourse,
|
||||
canCancelBooking,
|
||||
enrichCourseForDisplay,
|
||||
getWeekDates,
|
||||
courseCatalogMock
|
||||
} from './bookingStore.js'
|
||||
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
|
||||
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
|
||||
export {
|
||||
validateName,
|
||||
validatePhone,
|
||||
validatePhoneForRebind,
|
||||
validateHeight,
|
||||
validateWeight,
|
||||
validateBirthday,
|
||||
validateFitnessGoals,
|
||||
validateUserProfile,
|
||||
showValidationError
|
||||
} from './validate.js'
|
||||
@@ -0,0 +1,159 @@
|
||||
/** 头像等媒体:真机选图后须 saveFile,/static/ 须 getImageInfo */
|
||||
|
||||
function buildStaticPathCandidates(url) {
|
||||
const list = [url]
|
||||
if (url.startsWith('/')) {
|
||||
list.push(url.slice(1))
|
||||
} else {
|
||||
list.push(`/${url}`)
|
||||
}
|
||||
return [...new Set(list.filter(Boolean))]
|
||||
}
|
||||
|
||||
function isPackageStaticPath(url) {
|
||||
return /^(\/)?static\//i.test(url)
|
||||
}
|
||||
|
||||
/** chooseImage / saveFile 产生的本地路径(含真机 temp、usr、store) */
|
||||
export function isLocalFilePath(url) {
|
||||
if (!url) return false
|
||||
if (/^(wxfile:|file:|blob:|data:)/i.test(url)) return true
|
||||
if (/^https?:\/\/(tmp|usr|store)\//i.test(url)) return true
|
||||
if (/^https?:\/\//i.test(url) && !isPackageStaticPath(url)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function showPreviewFail() {
|
||||
uni.showToast({ title: '无法预览头像', icon: 'none' })
|
||||
}
|
||||
|
||||
function openPreview(path, onFail) {
|
||||
if (!path) {
|
||||
;(onFail || showPreviewFail)()
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
urls: [path],
|
||||
current: path,
|
||||
fail: () => (onFail ? onFail() : showPreviewFail())
|
||||
})
|
||||
}
|
||||
|
||||
function previewLocalFile(url) {
|
||||
openPreview(url, () => {
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: (res) => {
|
||||
openPreview(res.path || url, showPreviewFail)
|
||||
},
|
||||
fail: showPreviewFail
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function tryGetImageInfo(candidates, index, onSuccess, onFail) {
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
uni.getImageInfo({
|
||||
src: candidates[index],
|
||||
success: (res) => onSuccess(res.path || candidates[index]),
|
||||
fail: () => tryGetImageInfo(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
}
|
||||
|
||||
function getMpUserDataPath() {
|
||||
// #ifdef MP-WEIXIN
|
||||
return wx.env.USER_DATA_PATH
|
||||
// #endif
|
||||
return ''
|
||||
}
|
||||
|
||||
function tryCopyFile(candidates, index, onSuccess, onFail) {
|
||||
// #ifdef MP-WEIXIN
|
||||
const userPath = getMpUserDataPath()
|
||||
if (!userPath) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
const fs = uni.getFileSystemManager()
|
||||
const extMatch = candidates[0]?.match(/\.(\w+)(?:\?|$)/)
|
||||
const ext = extMatch ? extMatch[1] : 'png'
|
||||
const dest = `${userPath}/avatar_preview_${Date.now()}.${ext}`
|
||||
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
|
||||
fs.copyFile({
|
||||
srcPath: candidates[index],
|
||||
destPath: dest,
|
||||
success: () => onSuccess(dest),
|
||||
fail: () => tryCopyFile(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
onFail()
|
||||
// #endif
|
||||
}
|
||||
|
||||
function previewPackageStatic(url) {
|
||||
const candidates = buildStaticPathCandidates(url)
|
||||
tryGetImageInfo(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
() => {
|
||||
tryCopyFile(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
showPreviewFail
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 选图后将临时文件转为真机可预览、可持久化的本地路径 */
|
||||
export function persistChosenImage(tempPath) {
|
||||
return new Promise((resolve) => {
|
||||
const path = String(tempPath || '').trim()
|
||||
if (!path) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.saveFile({
|
||||
tempFilePath: path,
|
||||
success: (res) => resolve(res.savedFilePath || path),
|
||||
fail: () => resolve(path)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
resolve(path)
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
export function previewImage(src, fallback = '') {
|
||||
const url = String(src || fallback || '').trim()
|
||||
if (!url) {
|
||||
uni.showToast({ title: '暂无头像', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (isLocalFilePath(url)) {
|
||||
previewLocalFile(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPackageStaticPath(url)) {
|
||||
previewPackageStatic(url)
|
||||
return
|
||||
}
|
||||
|
||||
previewLocalFile(url)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { backToMemberCenter } from '../constants/routes.js'
|
||||
|
||||
/** 状态栏时间(Pixso 顶栏占位) */
|
||||
export const statusBarTimeMixin = {
|
||||
data() {
|
||||
return {
|
||||
statusBarTime: '9:41'
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.updateStatusBarTime()
|
||||
},
|
||||
methods: {
|
||||
updateStatusBarTime() {
|
||||
const now = new Date()
|
||||
this.statusBarTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 子页面返回个人中心 tab */
|
||||
export const subPageMixin = {
|
||||
methods: {
|
||||
goBack() {
|
||||
backToMemberCenter()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
/** 个人中心模块 mock 数据(后续可替换为 API) */
|
||||
|
||||
export const memberCenterMock = {
|
||||
userInfo: {
|
||||
name: '张小芳',
|
||||
phone: '13812345678 已绑定微信',
|
||||
memberLevel: '黄金会员',
|
||||
avatar: '/static/images/AvatarEditWrap.png'
|
||||
},
|
||||
stats: {
|
||||
checkInCount: 128,
|
||||
trainingHours: 23,
|
||||
pointsBalance: 1250
|
||||
},
|
||||
cardInfo: {
|
||||
name: '健身时长卡',
|
||||
detailTag: '详情',
|
||||
expireDate: '有效期至 2025年12月31日',
|
||||
remainingDays: 187,
|
||||
tip: '距离下次到期还有187天,请及时续费'
|
||||
},
|
||||
checkIns: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private'
|
||||
}
|
||||
],
|
||||
bodyReport: {
|
||||
date: '2024-07-01',
|
||||
weight: '63.5',
|
||||
bmi: '22.1',
|
||||
bodyFat: '24.8%',
|
||||
bmr: '165',
|
||||
status: '比较健康',
|
||||
change: '-1.2kg'
|
||||
},
|
||||
couponPoints: {
|
||||
amount: '¥50',
|
||||
couponDesc: '满500可用 · 1张',
|
||||
couponAction: '去使用',
|
||||
points: 1250,
|
||||
pointsLabel: '我的积分',
|
||||
pointsAction: '去兑换'
|
||||
},
|
||||
referral: {
|
||||
code: 'FIT-ZXF-2024',
|
||||
invited: 5,
|
||||
registered: 3,
|
||||
purchased: 2
|
||||
}
|
||||
}
|
||||
|
||||
export const userInfoMock = {
|
||||
name: '张小芳',
|
||||
phone: '13812345678',
|
||||
gender: 'female',
|
||||
birthday: '1995年06月15日',
|
||||
height: '165',
|
||||
weight: '63.5',
|
||||
fitnessGoals: ['减脂', '塑形'],
|
||||
avatar: '/static/images/AvatarEditWrap.png'
|
||||
}
|
||||
|
||||
export const fitnessGoalOptions = ['减脂', '塑形', '增肌', '提升耐力', '改善体态']
|
||||
|
||||
export const memberCardMock = {
|
||||
card: {
|
||||
name: '黄金健身时长卡',
|
||||
status: '生效中',
|
||||
validityStart: '2024年01月01日',
|
||||
validity: '2024年01月01日 - 2025年12月31日',
|
||||
validityEnd: '2025-12-31',
|
||||
remainingDays: 187
|
||||
},
|
||||
recordTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'consume', label: '消费' },
|
||||
{ key: 'checkin', label: '签到' }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'checkin',
|
||||
title: '瑜伽初级班 · 团课签到',
|
||||
time: '2024-07-12 09:05',
|
||||
value: '-1次',
|
||||
valueType: 'negative',
|
||||
icon: '/static/images/dumbbell.png',
|
||||
iconTheme: 'orange'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'checkin',
|
||||
title: '自由进馆',
|
||||
time: '2024-07-11 18:30',
|
||||
value: '-1天',
|
||||
valueType: 'negative',
|
||||
icon: '/static/images/mappin.png',
|
||||
iconTheme: 'green'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'consume',
|
||||
title: '会员卡充值',
|
||||
time: '2024-07-01 10:00',
|
||||
value: '+90天',
|
||||
valueType: 'positive',
|
||||
icon: '/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
}
|
||||
],
|
||||
rules: [
|
||||
'时长卡有效期内不限入场次数,但需提前预约团课',
|
||||
'卡到期后不退余额,请合理安排使用',
|
||||
'一卡仅限本人使用,不可转让'
|
||||
]
|
||||
}
|
||||
|
||||
/** 智能体测模块 mock 数据 */
|
||||
export const bodyTestMock = {
|
||||
settings: {
|
||||
autoSync: true,
|
||||
bluetoothEnabled: true,
|
||||
notifyOnComplete: true,
|
||||
shareAnonymous: false,
|
||||
unitSystem: 'metric'
|
||||
},
|
||||
device: {
|
||||
connected: false,
|
||||
name: 'InBody 270',
|
||||
model: 'IB-270',
|
||||
battery: 86,
|
||||
signal: 'strong',
|
||||
lastConnected: '2024-07-10 18:20'
|
||||
},
|
||||
connectSteps: [
|
||||
{ step: 1, title: '开启体测仪', desc: '长按电源键 3 秒,等待蓝牙指示灯闪烁' },
|
||||
{ step: 2, title: '靠近设备', desc: '将手机靠近体测仪 1 米范围内' },
|
||||
{ step: 3, title: '确认连接', desc: '点击下方按钮搜索并配对设备' }
|
||||
],
|
||||
metricDefs: [
|
||||
{ key: 'weight', label: '体重', unit: 'kg', icon: '/static/images/target.png' },
|
||||
{ key: 'bmi', label: 'BMI', unit: '', icon: '/static/images/activity.png' },
|
||||
{ key: 'bodyFat', label: '体脂率', unit: '%', icon: '/static/images/trendingdown.png' },
|
||||
{ key: 'muscleMass', label: '肌肉量', unit: 'kg', icon: '/static/images/dumbbell.png' },
|
||||
{ key: 'visceralFat', label: '内脏脂肪', unit: '级', icon: '/static/images/alertcircle.png' },
|
||||
{ key: 'bmr', label: '基础代谢', unit: 'kcal', icon: '/static/images/clock.png' },
|
||||
{ key: 'bodyWater', label: '体水分', unit: '%', icon: '/static/images/shield.png' },
|
||||
{ key: 'boneMass', label: '骨量', unit: 'kg', icon: '/static/images/user.png' }
|
||||
],
|
||||
radarLabels: [
|
||||
{ key: 'weight', label: '体重控制' },
|
||||
{ key: 'bodyFat', label: '体脂肪' },
|
||||
{ key: 'muscle', label: '肌肉量' },
|
||||
{ key: 'bone', label: '骨量' },
|
||||
{ key: 'water', label: '体水分' },
|
||||
{ key: 'bmr', label: '基础代谢' }
|
||||
],
|
||||
trendMetrics: [
|
||||
{ key: 'weight', label: '体重' },
|
||||
{ key: 'bodyFat', label: '体脂率' },
|
||||
{ key: 'muscleMass', label: '肌肉量' },
|
||||
{ key: 'bmi', label: 'BMI' }
|
||||
],
|
||||
recommendedCourses: [
|
||||
{
|
||||
id: 1,
|
||||
title: '燃脂 HIIT 团课',
|
||||
coach: '李明教练',
|
||||
schedule: '每周二、四 19:00',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
tag: '减脂推荐'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '核心力量塑形',
|
||||
coach: '王强教练',
|
||||
schedule: '每周一、三 18:30',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
tag: '塑形推荐'
|
||||
}
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 4,
|
||||
date: '2024-07-12',
|
||||
time: '09:05',
|
||||
score: 85,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 27,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 63.5,
|
||||
bmi: 22.1,
|
||||
bodyFat: 24.8,
|
||||
muscleMass: 22.6,
|
||||
visceralFat: 6,
|
||||
bmr: 1385,
|
||||
bodyWater: 52.8,
|
||||
boneMass: 2.42,
|
||||
protein: 16.4
|
||||
},
|
||||
radar: { weight: 78, bodyFat: 72, muscle: 74, bone: 81, water: 79, bmr: 73 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.2kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.5kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.6kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.7kg' }
|
||||
],
|
||||
advice: [
|
||||
'体脂率略高,建议增加有氧训练频率至每周 3-4 次',
|
||||
'核心肌群表现良好,可尝试进阶力量课程',
|
||||
'保持当前蛋白质摄入,有助于维持肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2024-06-28',
|
||||
time: '18:40',
|
||||
score: 82,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 28,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 64.7,
|
||||
bmi: 22.5,
|
||||
bodyFat: 25.3,
|
||||
muscleMass: 22.2,
|
||||
visceralFat: 7,
|
||||
bmr: 1370,
|
||||
bodyWater: 52.1,
|
||||
boneMass: 2.4,
|
||||
protein: 16.1
|
||||
},
|
||||
radar: { weight: 74, bodyFat: 68, muscle: 70, bone: 80, water: 76, bmr: 70 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.2kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.5kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.6kg' }
|
||||
],
|
||||
advice: [
|
||||
'体重较上次下降 0.8kg,减脂方向正确',
|
||||
'建议配合拉伸课程改善体态'
|
||||
],
|
||||
recommendedCourseIds: [1]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2024-06-10',
|
||||
time: '10:15',
|
||||
score: 79,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 30,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 65.5,
|
||||
bmi: 22.8,
|
||||
bodyFat: 26.1,
|
||||
muscleMass: 21.8,
|
||||
visceralFat: 8,
|
||||
bmr: 1355,
|
||||
bodyWater: 51.5,
|
||||
boneMass: 2.38,
|
||||
protein: 15.8
|
||||
},
|
||||
radar: { weight: 70, bodyFat: 62, muscle: 66, bone: 78, water: 72, bmr: 66 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.0kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.4kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.5kg' }
|
||||
],
|
||||
advice: [
|
||||
'内脏脂肪偏高,建议减少高糖饮食',
|
||||
'增加抗阻训练提升肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [2]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
date: '2024-05-20',
|
||||
time: '14:30',
|
||||
score: 76,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 31,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 66.2,
|
||||
bmi: 23.1,
|
||||
bodyFat: 26.8,
|
||||
muscleMass: 21.5,
|
||||
visceralFat: 9,
|
||||
bmr: 1340,
|
||||
bodyWater: 51.0,
|
||||
boneMass: 2.35,
|
||||
protein: 15.5
|
||||
},
|
||||
radar: { weight: 66, bodyFat: 58, muscle: 62, bone: 76, water: 68, bmr: 62 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.8kg' },
|
||||
{ part: '右臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '躯干', level: 'high', value: '27.8kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.3kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.4kg' }
|
||||
],
|
||||
advice: [
|
||||
'建议制定 8 周减脂计划并定期复测',
|
||||
'每日饮水量建议达到 2000ml'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const bookingMock = {
|
||||
upcomingAlert: '明天 09:00 有一堂瑜伽课,请提前 30 分钟到场',
|
||||
tabs: [
|
||||
{ key: 'ongoing', label: '进行中' },
|
||||
{ key: 'history', label: '历史预约' }
|
||||
],
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: '07月15日 09:00-10:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月15日',
|
||||
timeRange: '09:00-10:00',
|
||||
coach: '李明教练',
|
||||
coachShort: '李明',
|
||||
location: '一楼 大厅',
|
||||
footerText: '可取消(截止 07/15 07:00)',
|
||||
canCancel: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '私教健身课',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
status: 'pending',
|
||||
statusLabel: '待上课',
|
||||
schedule: '07月18日 14:00-15:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月18日',
|
||||
timeRange: '14:00-15:00',
|
||||
coach: '王强教练',
|
||||
coachShort: '王强',
|
||||
location: 'B区私教室',
|
||||
footerText: '地点:B区私教室',
|
||||
canCancel: true
|
||||
}
|
||||
],
|
||||
history: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
status: 'completed',
|
||||
statusLabel: '已完成',
|
||||
schedule: '07月10日 19:00-20:00',
|
||||
coach: '赵敏教练',
|
||||
footerText: '已签到',
|
||||
canCancel: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '普拉提进阶',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
schedule: '07月05日 10:00-11:00',
|
||||
coach: '李明教练',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 可预约课程 catalog */
|
||||
export const courseCatalogMock = {
|
||||
coaches: ['全部', '李明教练', '王强教练', '赵敏教练'],
|
||||
periodOptions: [
|
||||
{ key: 'all', label: '全部时段' },
|
||||
{ key: 'morning', label: '上午' },
|
||||
{ key: 'afternoon', label: '下午' },
|
||||
{ key: 'evening', label: '晚上' }
|
||||
],
|
||||
typeOptions: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' }
|
||||
],
|
||||
courses: [
|
||||
{
|
||||
id: 101,
|
||||
title: '瑜伽基础班',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: '/static/images/user0.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
location: '一楼大厅',
|
||||
enrolled: 12,
|
||||
capacity: 20,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '适合零基础学员,重点提升柔韧性与呼吸控制。',
|
||||
suitable: '久坐办公族、初学者、想改善体态者',
|
||||
coachBio: '国家一级瑜伽指导员,5年教学经验',
|
||||
coachRating: 4.9,
|
||||
reviews: [
|
||||
{ user: '会员 A', score: 5, text: '教练讲解很细致,氛围很好' },
|
||||
{ user: '会员 B', score: 5, text: '适合新手,推荐' }
|
||||
],
|
||||
cancelRule: '至少提前 2 小时取消,否则视为爽约'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'HIIT 燃脂团课',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: '/static/images/user1.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '19:00',
|
||||
endTime: '20:00',
|
||||
location: '有氧区',
|
||||
enrolled: 18,
|
||||
capacity: 20,
|
||||
price: '时长卡',
|
||||
payType: 'duration',
|
||||
period: 'evening',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '高强度间歇训练,快速燃脂提升心肺。',
|
||||
suitable: '有一定运动基础、目标减脂者',
|
||||
coachBio: 'ACE 认证教练,擅长 HIIT 与动感单车',
|
||||
coachRating: 4.8,
|
||||
reviews: [{ user: '会员 C', score: 5, text: '强度够,出汗很多' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: '私教 · 力量训练',
|
||||
type: 'private',
|
||||
coach: '王强教练',
|
||||
coachAvatar: '/static/images/user2.png',
|
||||
date: '2024-07-16',
|
||||
startTime: '14:00',
|
||||
endTime: '15:00',
|
||||
location: 'B区私教室',
|
||||
enrolled: 1,
|
||||
capacity: 1,
|
||||
price: '私教课时卡',
|
||||
payType: 'private',
|
||||
period: 'afternoon',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
intro: '一对一力量训练,定制训练计划。',
|
||||
suitable: '增肌塑形、康复训练',
|
||||
coachBio: 'NSCA 认证私教,8年从业经验',
|
||||
coachRating: 5.0,
|
||||
reviews: [{ user: '会员 D', score: 5, text: '非常专业' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
title: '普拉提进阶',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: '/static/images/user0.png',
|
||||
date: '2024-07-17',
|
||||
startTime: '10:30',
|
||||
endTime: '11:30',
|
||||
location: '二楼瑜伽室',
|
||||
enrolled: 8,
|
||||
capacity: 15,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
intro: '核心稳定与体态矫正进阶课程。',
|
||||
suitable: '有普拉提基础者',
|
||||
coachBio: '国家一级瑜伽指导员',
|
||||
coachRating: 4.9,
|
||||
reviews: [],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
title: '动感单车',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: '/static/images/user1.png',
|
||||
date: '2024-07-18',
|
||||
startTime: '18:30',
|
||||
endTime: '19:30',
|
||||
location: '单车房',
|
||||
enrolled: 20,
|
||||
capacity: 20,
|
||||
price: '储值卡 ¥39',
|
||||
payType: 'stored',
|
||||
period: 'evening',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '音乐骑行,团队氛围燃脂。',
|
||||
suitable: '所有级别,可调节阻力',
|
||||
coachBio: 'ACE 认证教练',
|
||||
coachRating: 4.7,
|
||||
reviews: [{ user: '会员 E', score: 4, text: '音乐很带感' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 个人中心其它模块 mock 数据 */
|
||||
export const moduleMock = {
|
||||
trainingReport: {
|
||||
periodLabel: '本周训练',
|
||||
summary: {
|
||||
sessions: 4,
|
||||
hours: 6.5,
|
||||
calories: 2180,
|
||||
streak: 3,
|
||||
visits: 5
|
||||
},
|
||||
monthlyHours: [
|
||||
{ label: '第1周', value: 4.2 },
|
||||
{ label: '第2周', value: 5.8 },
|
||||
{ label: '第3周', value: 6.5 },
|
||||
{ label: '第4周', value: 5.0 }
|
||||
],
|
||||
monthlyCalories: [
|
||||
{ label: '第1周', value: 1200 },
|
||||
{ label: '第2周', value: 1680 },
|
||||
{ label: '第3周', value: 2180 },
|
||||
{ label: '第4周', value: 1850 }
|
||||
],
|
||||
weeklyHours: [
|
||||
{ label: '一', value: 1.2 },
|
||||
{ label: '二', value: 0 },
|
||||
{ label: '三', value: 1.5 },
|
||||
{ label: '四', value: 0.8 },
|
||||
{ label: '五', value: 1.0 },
|
||||
{ label: '六', value: 2.0 },
|
||||
{ label: '日', value: 0 }
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
date: '2024-07-12',
|
||||
time: '09:00-10:00',
|
||||
duration: '60分钟',
|
||||
calories: 320,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 力量',
|
||||
coach: '自主训练',
|
||||
date: '2024-07-11',
|
||||
time: '18:30-19:45',
|
||||
duration: '75分钟',
|
||||
calories: 480,
|
||||
type: 'free',
|
||||
typeLabel: '自由'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教 · 核心塑形',
|
||||
coach: '王强教练',
|
||||
date: '2024-07-10',
|
||||
time: '14:00-15:00',
|
||||
duration: '60分钟',
|
||||
calories: 410,
|
||||
type: 'private',
|
||||
typeLabel: '私教'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '动感单车',
|
||||
coach: '赵敏教练',
|
||||
date: '2024-07-08',
|
||||
time: '19:00-20:00',
|
||||
duration: '60分钟',
|
||||
calories: 520,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
}
|
||||
]
|
||||
},
|
||||
couponTabs: [
|
||||
{ key: 'available', label: '可用' },
|
||||
{ key: 'used', label: '已使用' },
|
||||
{ key: 'expired', label: '已过期' }
|
||||
],
|
||||
coupons: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'available',
|
||||
amount: 50,
|
||||
title: '满500减50',
|
||||
desc: '全场团课/私教可用',
|
||||
expire: '2024-12-31',
|
||||
minSpend: 500,
|
||||
tag: '通用券',
|
||||
rules: '1. 满500元可用\n2. 适用于团课/私教\n3. 不可与其他优惠叠加\n4. 有效期至2024-12-31',
|
||||
scope: '全门店 · 团课/私教',
|
||||
flow: '选择课程 → 确认订单 → 选择优惠券 → 完成支付'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'available',
|
||||
amount: 30,
|
||||
title: '新人专享',
|
||||
desc: '首次购课立减',
|
||||
expire: '2024-08-31',
|
||||
minSpend: 200,
|
||||
tag: '新人券',
|
||||
rules: '1. 限新注册用户首次购课\n2. 满200可用',
|
||||
scope: '全门店 · 首次购课',
|
||||
flow: '首次预约课程时自动提示使用'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'used',
|
||||
amount: 20,
|
||||
title: '签到奖励券',
|
||||
desc: '连续签到7天获得',
|
||||
expire: '2024-07-01',
|
||||
minSpend: 100,
|
||||
tag: '奖励券',
|
||||
usedAt: '2024-06-28',
|
||||
rules: '满100可用',
|
||||
scope: '团课',
|
||||
flow: '预约时使用'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
status: 'expired',
|
||||
amount: 100,
|
||||
title: '周年庆特惠',
|
||||
desc: '满1000可用',
|
||||
expire: '2024-06-01',
|
||||
minSpend: 1000,
|
||||
tag: '活动券',
|
||||
rules: '满1000可用,已过期',
|
||||
scope: '全门店',
|
||||
flow: '—'
|
||||
}
|
||||
],
|
||||
couponCenter: [
|
||||
{
|
||||
id: 11,
|
||||
amount: 20,
|
||||
title: '周末团课券',
|
||||
desc: '周末团课满200减20',
|
||||
expireDays: 30,
|
||||
minSpend: 200,
|
||||
tag: '可领取',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
amount: 50,
|
||||
title: '私教体验券',
|
||||
desc: '私教课满500减50',
|
||||
expireDays: 15,
|
||||
minSpend: 500,
|
||||
tag: '限时',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
amount: 10,
|
||||
title: '签到加油券',
|
||||
desc: '无门槛10元券',
|
||||
expireDays: 7,
|
||||
minSpend: 0,
|
||||
tag: '每日',
|
||||
claimed: true
|
||||
}
|
||||
],
|
||||
pointsConfig: {
|
||||
rate: '100积分 = 1元',
|
||||
rule: '签到、训练、邀请好友、购课均可获得积分;积分可用于商城兑换。'
|
||||
},
|
||||
pointsRewards: [
|
||||
{ id: 1, name: '团课体验券', cost: 500, stock: 12, icon: '/static/images/ticket.png' },
|
||||
{ id: 2, name: '运动毛巾', cost: 800, stock: 5, icon: '/static/images/dumbbell.png' },
|
||||
{ id: 3, name: '私教体验30分钟', cost: 2000, stock: 3, icon: '/static/images/usercheck.png' },
|
||||
{ id: 4, name: '蛋白粉小样', cost: 350, stock: 20, icon: '/static/images/star.png' }
|
||||
],
|
||||
pointsHistory: [
|
||||
{ id: 1, type: 'earn', title: '团课签到', amount: 50, time: '2024-07-12 09:10', balance: 1250 },
|
||||
{ id: 2, type: 'earn', title: '邀请好友注册', amount: 200, time: '2024-07-08 15:30', balance: 1200 },
|
||||
{ id: 3, type: 'spend', title: '兑换团课体验券', amount: -500, time: '2024-07-01 11:00', balance: 1000 },
|
||||
{ id: 4, type: 'earn', title: '会员卡续费奖励', amount: 100, time: '2024-07-01 10:05', balance: 1500 },
|
||||
{ id: 5, type: 'earn', title: '体测完成奖励', amount: 30, time: '2024-06-28 18:45', balance: 1400 }
|
||||
],
|
||||
referralRules: [
|
||||
'好友通过您的邀请码注册,双方各得 100 积分',
|
||||
'好友首次购课成功后,您额外获得 300 积分',
|
||||
'每月邀请奖励上限 10 人,超出不再计奖',
|
||||
'积分可用于兑换课程体验券及周边礼品'
|
||||
],
|
||||
referralRecords: [
|
||||
{ id: 1, name: '李**', avatar: '/static/images/user0.png', status: 'purchased', statusLabel: '已购课', time: '2024-07-05', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 2, name: '王**', avatar: '/static/images/user1.png', status: 'registered', statusLabel: '已注册', time: '2024-06-20', reward: '+100积分', rewardStatus: '已发放' },
|
||||
{ id: 3, name: '陈**', avatar: '/static/images/user2.png', status: 'invited', statusLabel: '已邀请', time: '2024-06-15', reward: '待注册', rewardStatus: '待发放' },
|
||||
{ id: 4, name: '赵**', avatar: '/static/images/user3.png', status: 'purchased', statusLabel: '已购课', time: '2024-06-01', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 5, name: '刘**', avatar: '/static/images/user0.png', status: 'registered', statusLabel: '已注册', time: '2024-05-28', reward: '+100积分', rewardStatus: '已发放' }
|
||||
],
|
||||
referralRewardSummary: {
|
||||
totalPoints: 800,
|
||||
totalCoupons: 2,
|
||||
pendingCount: 1
|
||||
},
|
||||
myCourseTabs: [
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'online', label: '线上课' },
|
||||
{ key: 'package', label: '训练营' }
|
||||
],
|
||||
myCourses: {
|
||||
group: {
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 6,
|
||||
total: 12,
|
||||
schedule: '每周二、四 09:00',
|
||||
location: '一楼大厅',
|
||||
nextClass: '07月16日 09:00',
|
||||
canCancel: true,
|
||||
bookingId: 1
|
||||
}
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车入门',
|
||||
coach: '赵敏教练',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 8,
|
||||
total: 8,
|
||||
schedule: '已结课',
|
||||
location: '单车房',
|
||||
completedAt: '2024-06-30',
|
||||
canEvaluate: true
|
||||
}
|
||||
]
|
||||
},
|
||||
private: {
|
||||
remaining: 7,
|
||||
coach: '王强教练',
|
||||
coachAvatar: '/static/images/user2.png',
|
||||
nextClass: '07月15日 14:00',
|
||||
bookings: [
|
||||
{ id: 2, title: '私教 · 力量训练', time: '07月18日 14:00', status: '已预约', location: 'B区私教室' }
|
||||
],
|
||||
completed: [
|
||||
{ id: 5, title: '私教 · 核心塑形', time: '2024-07-10 14:00', coach: '王强教练' }
|
||||
]
|
||||
},
|
||||
online: [
|
||||
{
|
||||
id: 201,
|
||||
title: '居家核心训练',
|
||||
cover: '/static/images/AC2Banner.png',
|
||||
duration: '45分钟',
|
||||
progress: 60,
|
||||
chapters: 6,
|
||||
watched: 4,
|
||||
type: 'video'
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
title: '直播 · 晨间拉伸',
|
||||
cover: '/static/images/AC1Banner.png',
|
||||
duration: '30分钟',
|
||||
progress: 0,
|
||||
liveTime: '07月20日 07:00',
|
||||
type: 'live'
|
||||
}
|
||||
],
|
||||
package: [
|
||||
{
|
||||
id: 301,
|
||||
title: '28天减脂训练营',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 3,
|
||||
total: 10,
|
||||
coach: '李明教练',
|
||||
schedule: '每周5练'
|
||||
}
|
||||
]
|
||||
},
|
||||
checkInTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'free', label: '自由' }
|
||||
],
|
||||
checkInHistory: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '一楼大厅'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '器械区'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private',
|
||||
location: 'B区私教室'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '团课签到 · 动感单车',
|
||||
time: '2024-07-08 19:02',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '单车房'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-06 17:45',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '有氧区'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { moduleMock } from './mockData.js'
|
||||
|
||||
|
||||
|
||||
function clone(value) {
|
||||
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getDefaultModuleState() {
|
||||
|
||||
return {
|
||||
|
||||
trainingReport: clone(moduleMock.trainingReport),
|
||||
|
||||
coupons: clone(moduleMock.coupons),
|
||||
|
||||
couponCenter: clone(moduleMock.couponCenter),
|
||||
|
||||
pointsHistory: clone(moduleMock.pointsHistory),
|
||||
|
||||
pointsRewards: clone(moduleMock.pointsRewards),
|
||||
|
||||
redeemRecords: [],
|
||||
|
||||
referralRecords: clone(moduleMock.referralRecords),
|
||||
|
||||
myCourses: clone(moduleMock.myCourses),
|
||||
|
||||
checkInHistory: clone(moduleMock.checkInHistory)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function mergeModuleState(saved) {
|
||||
|
||||
const defaults = getDefaultModuleState()
|
||||
|
||||
if (!saved) return defaults
|
||||
|
||||
return {
|
||||
|
||||
trainingReport: { ...defaults.trainingReport, ...(saved.trainingReport || {}) },
|
||||
|
||||
coupons: saved.coupons?.length ? saved.coupons : defaults.coupons,
|
||||
|
||||
couponCenter: saved.couponCenter?.length ? saved.couponCenter : defaults.couponCenter,
|
||||
|
||||
pointsHistory: saved.pointsHistory?.length ? saved.pointsHistory : defaults.pointsHistory,
|
||||
|
||||
pointsRewards: saved.pointsRewards?.length ? saved.pointsRewards : defaults.pointsRewards,
|
||||
|
||||
redeemRecords: saved.redeemRecords || defaults.redeemRecords,
|
||||
|
||||
referralRecords: saved.referralRecords?.length ? saved.referralRecords : defaults.referralRecords,
|
||||
|
||||
myCourses: saved.myCourses ? mergeMyCourses(defaults.myCourses, saved.myCourses) : defaults.myCourses,
|
||||
|
||||
checkInHistory: saved.checkInHistory?.length ? saved.checkInHistory : defaults.checkInHistory
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function mergeMyCourses(defaults, saved) {
|
||||
|
||||
return {
|
||||
|
||||
group: saved.group || defaults.group,
|
||||
|
||||
private: saved.private || defaults.private,
|
||||
|
||||
online: saved.online?.length ? saved.online : defaults.online,
|
||||
|
||||
package: saved.package?.length ? saved.package : defaults.package
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function syncCouponSummary(store) {
|
||||
|
||||
const available = store.modules.coupons.filter((c) => c.status === 'available')
|
||||
|
||||
const top = available[0]
|
||||
|
||||
store.couponPoints = {
|
||||
|
||||
...store.couponPoints,
|
||||
|
||||
amount: top ? `¥${top.amount}` : '暂无',
|
||||
|
||||
couponDesc: top
|
||||
|
||||
? `满${top.minSpend}可用 · ${available.length}张`
|
||||
|
||||
: '暂无可用优惠券',
|
||||
|
||||
couponAction: available.length ? '去使用' : '去领取',
|
||||
|
||||
points: store.stats.pointsBalance,
|
||||
|
||||
pointsLabel: '我的积分',
|
||||
|
||||
pointsAction: '去兑换'
|
||||
|
||||
}
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function finalizeModules(store) {
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
store.checkIns = store.modules.checkInHistory.slice(0, 3).map((item) => ({ ...item }))
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getTrainingReportData(store, period = 'week') {
|
||||
|
||||
const report = store.modules.trainingReport
|
||||
|
||||
const trend = period === 'month' ? report.monthlyHours : report.weeklyHours
|
||||
|
||||
const calTrend = period === 'month' ? report.monthlyCalories : report.weeklyHours.map((w, i) => ({
|
||||
|
||||
label: w.label,
|
||||
|
||||
value: Math.round((report.summary.calories / 7) * (w.value || 0.5))
|
||||
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
...report,
|
||||
|
||||
period,
|
||||
|
||||
summary: {
|
||||
|
||||
...report.summary,
|
||||
|
||||
hours: store.stats.trainingHours ?? report.summary.hours,
|
||||
|
||||
visits: report.summary.visits ?? store.stats.checkInCount ?? 5
|
||||
|
||||
},
|
||||
|
||||
trendHours: trend.map((t) => ({ ...t, id: t.label })),
|
||||
|
||||
trendCalories: calTrend.map((t) => ({ ...t, id: t.label })),
|
||||
|
||||
sessions: report.sessions.map((s) => ({ ...s }))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getTrainingSessionById(store, id) {
|
||||
|
||||
const session = store.modules.trainingReport.sessions.find((s) => s.id === Number(id))
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return {
|
||||
|
||||
...session,
|
||||
|
||||
heartRate: '128 bpm',
|
||||
|
||||
comment: '动作标准,核心发力良好,下次可增加负重。',
|
||||
|
||||
checkInTime: `${session.date} ${session.time.split('-')[0]}`
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function filterTrainingSessions(store, filters = {}) {
|
||||
|
||||
let list = store.modules.trainingReport.sessions.map((s) => ({ ...s }))
|
||||
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
|
||||
list = list.filter((s) => s.type === filters.type)
|
||||
|
||||
}
|
||||
|
||||
return list
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponsByStatus(store, status) {
|
||||
|
||||
return store.modules.coupons.filter((c) => c.status === status).map((c) => ({ ...c }))
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponById(store, id) {
|
||||
|
||||
const c = store.modules.coupons.find((item) => item.id === Number(id))
|
||||
|
||||
return c ? { ...c } : null
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function useCoupon(store, id) {
|
||||
|
||||
const coupon = store.modules.coupons.find((c) => c.id === id)
|
||||
|
||||
if (!coupon || coupon.status !== 'available') return null
|
||||
|
||||
coupon.status = 'used'
|
||||
|
||||
coupon.usedAt = new Date().toISOString().slice(0, 10)
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
return coupon
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function deleteExpiredCoupon(store, id) {
|
||||
|
||||
const idx = store.modules.coupons.findIndex((c) => c.id === id && c.status === 'expired')
|
||||
|
||||
if (idx >= 0) store.modules.coupons.splice(idx, 1)
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponCenterList(store) {
|
||||
|
||||
return store.modules.couponCenter.map((c) => ({ ...c }))
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
memberCenterMock,
|
||||
userInfoMock,
|
||||
memberCardMock,
|
||||
bookingMock
|
||||
} from './mockData.js'
|
||||
import { formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
|
||||
import {
|
||||
getDefaultBodyTestState,
|
||||
mergeBodyTestState,
|
||||
getLatestBodyTestRecord,
|
||||
buildBodyReportSummary
|
||||
} from './bodyTestStore.js'
|
||||
import {
|
||||
getDefaultModuleState,
|
||||
mergeModuleState,
|
||||
finalizeModules
|
||||
} from './moduleStore.js'
|
||||
import {
|
||||
getDefaultCourseCatalog,
|
||||
mergeCourseCatalog,
|
||||
canCancelBooking
|
||||
} from './bookingStore.js'
|
||||
|
||||
const STORAGE_KEY = 'gym_member_info_v1'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function buildCardTip(remainingDays) {
|
||||
return `距离下次到期还有${remainingDays}天,请及时续费`
|
||||
}
|
||||
|
||||
function applyCardInfo(store) {
|
||||
const days = computeRemainingDays(store.card.validityEnd)
|
||||
store.card.remainingDays = days
|
||||
store.cardInfo.remainingDays = days
|
||||
store.cardInfo.tip = buildCardTip(days)
|
||||
return store
|
||||
}
|
||||
|
||||
export function syncStats(store) {
|
||||
store.stats = {
|
||||
...store.stats,
|
||||
pointsBalance: store.stats.pointsBalance ?? 1250
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function finalizeStore(store) {
|
||||
syncStats(store)
|
||||
applyCardInfo(store)
|
||||
if (store.profile?.avatar) {
|
||||
store.memberProfile.avatar = store.profile.avatar
|
||||
}
|
||||
if (store.profile?.phone) {
|
||||
store.memberProfile.phone = formatMemberCenterPhone(store.profile.phone)
|
||||
}
|
||||
const latestBodyTest = getLatestBodyTestRecord(store)
|
||||
if (latestBodyTest) {
|
||||
const previous = store.bodyTest.records[1]
|
||||
store.bodyReport = buildBodyReportSummary(latestBodyTest, previous)
|
||||
}
|
||||
if (store.modules) {
|
||||
finalizeModules(store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function getDefaultStore() {
|
||||
return finalizeStore({
|
||||
profile: { ...userInfoMock, avatar: memberCenterMock.userInfo.avatar },
|
||||
memberProfile: { ...memberCenterMock.userInfo },
|
||||
stats: { ...memberCenterMock.stats },
|
||||
cardInfo: { ...memberCenterMock.cardInfo },
|
||||
card: { ...memberCardMock.card },
|
||||
records: clone(memberCardMock.records),
|
||||
ongoingBookings: clone(bookingMock.ongoing),
|
||||
historyBookings: clone(bookingMock.history),
|
||||
checkIns: clone(memberCenterMock.checkIns),
|
||||
bodyReport: { ...memberCenterMock.bodyReport },
|
||||
bodyTest: getDefaultBodyTestState(),
|
||||
modules: getDefaultModuleState(),
|
||||
courseCatalog: getDefaultCourseCatalog(),
|
||||
couponPoints: { ...memberCenterMock.couponPoints },
|
||||
referral: { ...memberCenterMock.referral }
|
||||
})
|
||||
}
|
||||
|
||||
function mergeDefaults(saved) {
|
||||
const defaults = getDefaultStore()
|
||||
return finalizeStore({
|
||||
profile: { ...defaults.profile, ...(saved.profile || {}) },
|
||||
memberProfile: { ...defaults.memberProfile, ...(saved.memberProfile || {}) },
|
||||
stats: { ...defaults.stats, ...(saved.stats || {}) },
|
||||
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
|
||||
card: { ...defaults.card, ...(saved.card || {}) },
|
||||
records: saved.records?.length ? saved.records : defaults.records,
|
||||
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
|
||||
historyBookings: saved.historyBookings ?? defaults.historyBookings,
|
||||
checkIns: saved.checkIns?.length ? saved.checkIns : defaults.checkIns,
|
||||
bodyReport: { ...defaults.bodyReport, ...(saved.bodyReport || {}) },
|
||||
bodyTest: mergeBodyTestState(saved.bodyTest),
|
||||
modules: mergeModuleState(saved.modules),
|
||||
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
|
||||
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
|
||||
referral: { ...defaults.referral, ...(saved.referral || {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function loadMemberStore() {
|
||||
try {
|
||||
const saved = uni.getStorageSync(STORAGE_KEY)
|
||||
if (saved && typeof saved === 'object') {
|
||||
return mergeDefaults(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[memberStore] load failed', e)
|
||||
}
|
||||
return getDefaultStore()
|
||||
}
|
||||
|
||||
export function saveMemberStore(store) {
|
||||
uni.setStorageSync(STORAGE_KEY, store)
|
||||
}
|
||||
|
||||
/** 解析为本地 0 点,避免 ISO 字符串时区偏差 */
|
||||
export function parseLocalDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const str = String(dateStr).trim()
|
||||
const iso = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (iso) {
|
||||
return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
|
||||
}
|
||||
const cn = str.match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (cn) {
|
||||
return new Date(Number(cn[1]), Number(cn[2]) - 1, Number(cn[3]))
|
||||
}
|
||||
const parsed = new Date(str.replace(/-/g, '/'))
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatChineseDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}年${m}月${day}日`
|
||||
}
|
||||
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
export function computeRemainingDays(endDateStr) {
|
||||
const end = parseLocalDate(endDateStr)
|
||||
if (!end) return 0
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
const diff = Math.ceil((end - now) / 86400000)
|
||||
return Math.max(0, diff)
|
||||
}
|
||||
|
||||
export function formatUpcomingAlert(booking) {
|
||||
if (!booking) return ''
|
||||
const timePart = booking.timeRange || booking.schedule?.split(' ')[1] || ''
|
||||
return `明天 ${timePart} 有一堂${booking.title},请提前 30 分钟到场`
|
||||
}
|
||||
|
||||
export function toBookingPreviewItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
dateDay: item.dateDay,
|
||||
dateMonth: item.dateMonth,
|
||||
desc: `${item.title} · ${item.timeRange}`,
|
||||
coach: item.coachShort || item.coach.replace('教练', ''),
|
||||
location: item.location ? `地点:${item.location}` : '',
|
||||
status: item.status,
|
||||
statusLabel: item.statusLabel
|
||||
}
|
||||
}
|
||||
|
||||
export function getBookingPreview(store, limit = 2) {
|
||||
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
|
||||
}
|
||||
|
||||
export function getCenterPageData(store) {
|
||||
return {
|
||||
userInfo: { ...store.memberProfile },
|
||||
stats: { ...store.stats },
|
||||
cardInfo: { ...store.cardInfo },
|
||||
bookingPreview: getBookingPreview(store),
|
||||
checkIns: store.checkIns.map((item) => ({ ...item })),
|
||||
bodyReport: {
|
||||
...store.bodyReport,
|
||||
weight: store.profile.weight || store.bodyReport.weight
|
||||
},
|
||||
couponPoints: {
|
||||
...store.couponPoints,
|
||||
points: store.stats.pointsBalance
|
||||
},
|
||||
referral: { ...store.referral }
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelOngoingBooking(store, id) {
|
||||
const index = store.ongoingBookings.findIndex((b) => b.id === id)
|
||||
if (index < 0) return { ok: false, message: '预约不存在' }
|
||||
|
||||
const item = store.ongoingBookings[index]
|
||||
if (!canCancelBooking(item)) {
|
||||
return { ok: false, message: '距开课不足2小时,无法取消' }
|
||||
}
|
||||
|
||||
const [removed] = store.ongoingBookings.splice(index, 1)
|
||||
if (removed.courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === removed.courseId)
|
||||
if (course && course.enrolled > 0) course.enrolled -= 1
|
||||
}
|
||||
store.historyBookings.unshift({
|
||||
...removed,
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
})
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return { ok: true, message: '已取消' }
|
||||
}
|
||||
|
||||
export function renewMemberCard(store, addDays = 90) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
|
||||
let base = parseLocalDate(store.card.validityEnd) || new Date(now)
|
||||
base.setHours(0, 0, 0, 0)
|
||||
// 已过期:从今天起续费;未过期:从当前到期日起顺延
|
||||
if (base < now) {
|
||||
base = new Date(now)
|
||||
}
|
||||
|
||||
const end = new Date(base)
|
||||
end.setDate(end.getDate() + addDays)
|
||||
|
||||
const validityEnd = formatIsoDate(end)
|
||||
const validityEndCn = formatChineseDate(end)
|
||||
store.card.validityEnd = validityEnd
|
||||
store.card.validity = store.card.validityStart
|
||||
? `${store.card.validityStart} - ${validityEndCn}`
|
||||
: `2024年01月01日 - ${validityEndCn}`
|
||||
store.cardInfo.expireDate = `有效期至 ${validityEndCn}`
|
||||
|
||||
store.records.unshift({
|
||||
id: nextRecordId(store.records),
|
||||
type: 'consume',
|
||||
title: '会员卡续费',
|
||||
time: formatRecordTime(new Date()),
|
||||
value: `+${addDays}天`,
|
||||
valueType: 'positive',
|
||||
icon: '/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
})
|
||||
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function saveUserProfile(store, profile) {
|
||||
const phone = normalizePhoneForStore(profile.phone ?? store.profile.phone)
|
||||
store.profile = { ...store.profile, ...profile, phone }
|
||||
store.memberProfile = {
|
||||
...store.memberProfile,
|
||||
name: store.profile.name,
|
||||
phone: formatMemberCenterPhone(store.profile.phone),
|
||||
avatar: store.profile.avatar || store.memberProfile.avatar
|
||||
}
|
||||
if (store.profile.weight) {
|
||||
store.bodyReport.weight = store.profile.weight
|
||||
}
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function persistMemberStore(store) {
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$) */
|
||||
|
||||
const PHONE_REG = /^1[3-9]\d{9}$/
|
||||
const MIN_NAME_LEN = 2
|
||||
const MAX_NAME_LEN = 8
|
||||
const NAME_REG = new RegExp(
|
||||
`^[\\u4e00-\\u9fa5a-zA-Z·\\s]{${MIN_NAME_LEN},${MAX_NAME_LEN}}$`
|
||||
)
|
||||
const MEASURE_REG = /^\d+(\.\d)?$/
|
||||
|
||||
const MIN_HEIGHT = 50
|
||||
const MAX_HEIGHT = 250
|
||||
const MIN_WEIGHT = 20
|
||||
const MAX_WEIGHT = 300
|
||||
const MIN_BIRTH_YEAR = 1900
|
||||
const MIN_AGE = 14
|
||||
const MAX_FITNESS_GOALS = 5
|
||||
|
||||
export function isMaskedPhone(phone) {
|
||||
return String(phone || '').includes('****')
|
||||
}
|
||||
|
||||
export function validateName(name) {
|
||||
const value = String(name ?? '').trim()
|
||||
if (!value) {
|
||||
return { ok: false, message: '请输入姓名' }
|
||||
}
|
||||
if (!NAME_REG.test(value)) {
|
||||
return { ok: false, message: `姓名为${MIN_NAME_LEN}-${MAX_NAME_LEN}个汉字或字母` }
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/** 保存时使用:允许保留已脱敏的旧手机号 */
|
||||
export function validatePhone(phone, options = {}) {
|
||||
const { allowMasked = true } = options
|
||||
const raw = String(phone ?? '').trim()
|
||||
if (!raw) {
|
||||
return { ok: false, message: '请绑定手机号' }
|
||||
}
|
||||
if (allowMasked && isMaskedPhone(raw)) {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (digits.length >= 7) {
|
||||
return { ok: true, value: raw }
|
||||
}
|
||||
return { ok: false, message: '手机号格式不正确' }
|
||||
}
|
||||
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (!PHONE_REG.test(digits)) {
|
||||
return { ok: false, message: '请输入11位有效手机号' }
|
||||
}
|
||||
return { ok: true, value: digits }
|
||||
}
|
||||
|
||||
/** 换绑时必须输入完整新号 */
|
||||
export function validatePhoneForRebind(phone) {
|
||||
return validatePhone(phone, { allowMasked: false })
|
||||
}
|
||||
|
||||
function parseMeasure(value) {
|
||||
const str = String(value ?? '').trim()
|
||||
if (!str || !MEASURE_REG.test(str)) {
|
||||
return null
|
||||
}
|
||||
const num = Number(str)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function formatMeasure(num) {
|
||||
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(1)))
|
||||
}
|
||||
|
||||
export function validateHeight(height) {
|
||||
const num = parseMeasure(height)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效身高(单位 cm)' }
|
||||
}
|
||||
if (num < MIN_HEIGHT || num > MAX_HEIGHT) {
|
||||
return { ok: false, message: `身高请在 ${MIN_HEIGHT}-${MAX_HEIGHT} cm 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function validateWeight(weight) {
|
||||
const num = parseMeasure(weight)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效体重(单位 kg)' }
|
||||
}
|
||||
if (num < MIN_WEIGHT || num > MAX_WEIGHT) {
|
||||
return { ok: false, message: `体重请在 ${MIN_WEIGHT}-${MAX_WEIGHT} kg 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function parseBirthdayChinese(birthday) {
|
||||
const match = String(birthday ?? '').match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (!match) return null
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3])
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBirthday(birthday) {
|
||||
const parts = parseBirthdayChinese(birthday)
|
||||
if (!parts) {
|
||||
return { ok: false, message: '请选择生日' }
|
||||
}
|
||||
const { year, month, day } = parts
|
||||
if (year < MIN_BIRTH_YEAR) {
|
||||
return { ok: false, message: '生日年份不合理' }
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return { ok: false, message: '生日日期无效' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (date > today) {
|
||||
return { ok: false, message: '生日不能晚于今天' }
|
||||
}
|
||||
|
||||
const minBirth = new Date(
|
||||
today.getFullYear() - MIN_AGE,
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
)
|
||||
if (date > minBirth) {
|
||||
return { ok: false, message: `需年满 ${MIN_AGE} 周岁` }
|
||||
}
|
||||
|
||||
return { ok: true, value: `${year}年${String(month).padStart(2, '0')}月${String(day).padStart(2, '0')}日` }
|
||||
}
|
||||
|
||||
export function validateGender(gender) {
|
||||
if (gender === 'male' || gender === 'female') {
|
||||
return { ok: true, value: gender }
|
||||
}
|
||||
return { ok: false, message: '请选择性别' }
|
||||
}
|
||||
|
||||
export function validateFitnessGoals(goals, options = []) {
|
||||
const list = Array.isArray(goals) ? goals : []
|
||||
const allowed = new Set(options)
|
||||
const invalid = list.filter((g) => !allowed.has(g))
|
||||
if (invalid.length) {
|
||||
return { ok: false, message: '健身目标选项无效' }
|
||||
}
|
||||
if (list.length > MAX_FITNESS_GOALS) {
|
||||
return { ok: false, message: `最多选择 ${MAX_FITNESS_GOALS} 个健身目标` }
|
||||
}
|
||||
return { ok: true, value: [...list] }
|
||||
}
|
||||
|
||||
export function validateUserProfile(profile, goalOptions = []) {
|
||||
const nameResult = validateName(profile.name)
|
||||
if (!nameResult.ok) return nameResult
|
||||
|
||||
const phoneResult = validatePhone(profile.phone)
|
||||
if (!phoneResult.ok) return phoneResult
|
||||
|
||||
const genderResult = validateGender(profile.gender)
|
||||
if (!genderResult.ok) return genderResult
|
||||
|
||||
const birthdayResult = validateBirthday(profile.birthday)
|
||||
if (!birthdayResult.ok) return birthdayResult
|
||||
|
||||
const heightResult = validateHeight(profile.height)
|
||||
if (!heightResult.ok) return heightResult
|
||||
|
||||
const weightResult = validateWeight(profile.weight)
|
||||
if (!weightResult.ok) return weightResult
|
||||
|
||||
const goalsResult = validateFitnessGoals(profile.fitnessGoals, goalOptions)
|
||||
if (!goalsResult.ok) return goalsResult
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...profile,
|
||||
name: nameResult.value,
|
||||
phone: phoneResult.value,
|
||||
gender: genderResult.value,
|
||||
birthday: birthdayResult.value,
|
||||
height: heightResult.value,
|
||||
weight: weightResult.value,
|
||||
fitnessGoals: goalsResult.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function showValidationError(message) {
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
Reference in New Issue
Block a user