会员个人中心页面初步完成

This commit is contained in:
时舟年
2026-06-04 14:18:53 +08:00
committed by liwentao
parent 1fa2fbd3f3
commit a0026b1da5
170 changed files with 18092 additions and 35 deletions
@@ -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' })
}