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

This commit is contained in:
时舟年
2026-06-04 14:18:53 +08:00
parent 8e7c8f52f6
commit 7350293d0e
170 changed files with 18092 additions and 35 deletions
@@ -0,0 +1,120 @@
/** 与 pages.json 保持一致 */
export const PAGE = {
INDEX: '/pages/index/index',
COURSE: '/pages/course/index',
TRAIN: '/pages/train/index',
DISCOVER: '/pages/discover/index',
MEMBER: '/pages/memberInfo/memberInfo',
BOOKING: '/pages/memberInfo/booking',
MEMBER_CARD: '/pages/memberInfo/memberCard',
USER_INFO: '/pages/memberInfo/userInfo',
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
BODY_TEST_MEASURING: '/pages/memberInfo/bodyTestMeasuring',
BODY_TEST_REPORT: '/pages/memberInfo/bodyTestReport',
BODY_TEST_HISTORY: '/pages/memberInfo/bodyTestHistory',
BODY_TEST_COMPARE: '/pages/memberInfo/bodyTestCompare',
BODY_TEST_SETTINGS: '/pages/memberInfo/bodyTestSettings',
BODY_TEST_TREND: '/pages/memberInfo/bodyTestTrend',
COURSE_LIST: '/pages/memberInfo/courseList',
COURSE_DETAIL: '/pages/memberInfo/courseDetail',
COUPON_DETAIL: '/pages/memberInfo/couponDetail',
COUPON_CENTER: '/pages/memberInfo/couponCenter',
POINTS_MALL: '/pages/memberInfo/pointsMall',
POINTS_HISTORY: '/pages/memberInfo/pointsHistory',
ONLINE_COURSE: '/pages/memberInfo/onlineCourseDetail',
COURSE_EVALUATE: '/pages/memberInfo/courseEvaluate',
TRAIN_SESSION: '/pages/memberInfo/trainSessionDetail',
TRAIN_REPORT: '/pages/memberInfo/trainReport',
COUPONS: '/pages/memberInfo/coupons',
POINTS: '/pages/memberInfo/points',
REFERRAL: '/pages/memberInfo/referral',
MY_COURSES: '/pages/memberInfo/myCourses',
CHECK_IN_HISTORY: '/pages/memberInfo/checkInHistory'
}
/** 底部 TabBar 页面路径,顺序与 TabBar.vue 一致 */
export const TAB_ROUTES = [
PAGE.INDEX,
PAGE.COURSE,
PAGE.TRAIN,
PAGE.DISCOVER,
PAGE.MEMBER
]
const TAB_PAGES = new Set(TAB_ROUTES)
/** 防止 Tab 连点触发并发路由 */
let tabNavigating = false
function normalizePath(url) {
if (!url) return ''
const path = url.split('?')[0]
return path.startsWith('/') ? path : `/${path}`
}
export function getTabIndexByRoute(route) {
const path = normalizePath(route)
const idx = TAB_ROUTES.indexOf(path)
return idx >= 0 ? idx : 0
}
export function getCurrentRoutePath() {
const pages = getCurrentPages()
if (!pages.length) return PAGE.INDEX
const page = pages[pages.length - 1]
const route = page.route || page.$page?.fullPath || ''
return normalizePath(route ? `/${route}` : PAGE.INDEX)
}
export function navigateToPage(url) {
const path = normalizePath(url)
if (TAB_PAGES.has(path)) {
switchToTab(path)
return
}
uni.navigateTo({
url,
fail: (err) => {
console.error('[navigateTo]', url, err)
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
})
}
export function switchToTab(url) {
const path = normalizePath(url)
if (getCurrentRoutePath() === path || tabNavigating) return
tabNavigating = true
const done = () => {
setTimeout(() => {
tabNavigating = false
}, 320)
}
uni.switchTab({
url: path,
complete: done,
fail: (err) => {
console.warn('[switchTab]', path, err)
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
})
}
export function goToMemberCenter() {
switchToTab(PAGE.MEMBER)
}
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
uni.navigateBack({
delta: 1,
fail: () => switchToTab(fallbackUrl)
})
}
/** 子页面返回:统一回到 tab 页「个人中心」 */
export function backToMemberCenter() {
switchToTab(PAGE.MEMBER)
}
@@ -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' })
}
@@ -0,0 +1,12 @@
@import './member-info-page.css';
@import './member-info-status-bar.css';
@import './member-info-header.css';
@import './member-info-member-card.css';
@import './member-info-quick-actions.css';
@import './member-info-booking-list.css';
@import './member-info-check-in-list.css';
@import './member-info-body-report.css';
@import './member-info-coupon-points.css';
@import './member-info-referral.css';
@import './member-info-settings.css';
@import './member-info-logout.css';
@@ -0,0 +1,245 @@
.body-report-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
}
.body-report-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.body-report-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.body-report-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.body-report-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
}
.body-report-section__card {
width: 100%;
display: flex;
flex-direction: column;
border-radius: 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
}
.body-report-section__card-inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: var(--spacing-md, 16px);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.body-report-section__card-head {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.body-report-section__card-head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.body-report-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
}
.body-report-section__view-btn {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
.body-report-section__view-icon {width: 14px;
height: 14px;
display: block;
}
.body-report-section__view-report {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
}
.body-report-section__metrics {
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.body-report-section__metrics-inner {
display: flex;
flex-direction: row;
align-items: flex-start;
width: 100%;
}
.body-report-section__metric {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.body-report-section__metric-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
width: 100%;
}
.body-report-section__text {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
}
.body-report-section__text-2 {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--success-green);
}
.body-report-section__text-4 {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: rgba(243, 156, 18, 1);
}
.body-report-section__num {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--primary-deep);
}
.body-report-section__metric-value,
.body-report-section__text-3,
.body-report-section__metric-label,
.body-report-section__text-5 {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
}
.body-report-section__metric-divider {
width: 1px;
height: 30px;
flex-shrink: 0;
background-color: var(--border-light, #e9edf2);
}
.body-report-section__summary {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
background-color: var(--bg-light, #f9fafe);
box-sizing: border-box;
overflow: hidden;
}
.body-report-section__summary-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
}
.body-report-section__goal {
flex: 1;
min-width: 0;
max-width: 58%;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: center;
padding: 5px 10px;
border-radius: 100px;
background-color: rgba(240, 250, 245, 1);
}
.body-report-section__goal-icon {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.body-report-section__goal-text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--success-green);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body-report-section__change {
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: center;
}
.body-report-section__change-icon {width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
.body-report-section__metric-value-2 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--success-green);
white-space: nowrap;
}
@@ -0,0 +1,215 @@
.booking-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.booking-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.booking-section__header {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.booking-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.booking-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
flex-shrink: 0;
white-space: nowrap;
}
.booking-section__link {
flex-shrink: 0;
}
.booking-section__item {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
}
.booking-section__item-inner {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
padding: 14px;
width: 100%;
position: relative;
}
.booking-section__date {
width: 48px;
height: 56px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 10px;
background-color: rgba(255, 243, 238, 1);
}
.booking-section__date-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.booking-section__num {
font-size: var(--font-size-2xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
line-height: 1;
}
.booking-section__date-sub {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--accent-orange-light);
}
.booking-section__content {
width: 100%;
height: auto;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.booking-section__content-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
width: 100%;
}
.booking-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-dark);
}
.booking-section__meta {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.booking-section__meta-inner {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
}
.booking-section__icon-coach {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.booking-section__coach {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-muted);
}
.booking-section__icon-location {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.booking-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-muted);
}
.booking-section__status-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.booking-section__status-badge {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 6px;
}
.booking-section__status-badge--booked {
background-color: var(--success-green);
}
.booking-section__status-badge--pending {
background-color: rgba(255, 243, 238, 1);
border: 1px solid rgba(212, 166, 74, 1);
}
.booking-section__status-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-inverse);
line-height: 1.2;
}
.booking-section__status-text--pending {
color: var(--accent-orange);
}
@@ -0,0 +1,186 @@
.checkin-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.checkin-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.checkin-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.checkin-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.checkin-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
color: var(--text-dark);
}
.checkin-section__list {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px;
box-shadow: var(--shadow-sm);
background-color: var(--bg-white);
border: 1px solid var(--border-light);
overflow: hidden;
}
.checkin-section__list-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.checkin-section__row {
width: 100%;
display: flex;
flex-direction: column;
}
.checkin-section__item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.checkin-section__item-inner {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
padding: 14px var(--spacing-md);
width: 100%;
box-sizing: border-box;
}
.checkin-section__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--success-green);
}
.checkin-section__dot--group {
background-color: var(--success-green);
}
.checkin-section__dot--free {
background-color: var(--accent-orange);
}
.checkin-section__dot--private {
background-color: var(--primary-deep);
}
.checkin-section__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.checkin-section__content-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
width: 100%;
}
.checkin-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-dark);
line-height: 1.3;
}
.checkin-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-light);
line-height: 1.3;
}
.checkin-section__tag-badge {
flex-shrink: 0;
margin-left: var(--spacing-sm);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 3px var(--spacing-sm);
border-radius: 6px;
background-color: rgba(240, 250, 245, 1);
}
.checkin-section__tag-badge--group {
background-color: rgba(240, 250, 245, 1);
}
.checkin-section__tag-badge--free {
background-color: rgba(255, 243, 238, 1);
}
.checkin-section__tag-badge--private {
background-color: rgba(235, 243, 250, 1);
}
.checkin-section__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--success-green);
line-height: 1.2;
}
.checkin-section__tag-text--group {
color: var(--success-green);
}
.checkin-section__tag-text--free {
color: var(--accent-orange);
}
.checkin-section__tag-text--private {
color: var(--primary-deep);
}
.checkin-section__divider {
height: 1px;
background-color: var(--border-light);
margin-left: calc(var(--spacing-md) + 8px + 12px);
margin-right: var(--spacing-md);
}
@@ -0,0 +1,99 @@
/* 组件根节点:锁定浅色变量 + box-sizing(小程序组件内继承 page 的 theme-light 不稳定) */
.status-bar,
.profile-header,
.member-card-section,
.quick-actions,
.booking-section,
.checkin-section,
.body-report-section,
.coupon-section,
.referral-section,
.settings-section,
.logout-btn__border-wrap,
.logout-section {
box-sizing: border-box;
--primary-dark: #0B2B4B;
--primary-deep: #1A4A6F;
--primary-light: #2C6288;
--accent-orange: #FF6B35;
--accent-orange-light: #FF8C5A;
--accent-orange-dark: #E55A2B;
--bg-light: #F9FAFE;
--bg-white: #FFFFFF;
--bg-gray: #F2F5F9;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--text-light: #8A99B4;
--text-inverse: #FFFFFF;
--border-light: #E9EDF2;
--border-focus: #FF6B35;
--success-green: #2ECC71;
--warning-amber: #F39C12;
--error-red: #E74C3C;
--info-blue: #3498DB;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--radius-sm: 12px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-full: 999px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05);
}
/* 区块标题右侧操作链接:查看全部 / 全部记录 / 历史数据 / 推荐记录 */
.member-card-section__link-text,
.booking-section__view-all,
.checkin-section__view-all,
.coupon-section__view-all,
.body-report-section__history-link,
.referral-section__records-link {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
white-space: nowrap;
flex-shrink: 0;
line-height: 1.4;
}
.member-card-section__link,
.booking-section__link,
.checkin-section__link,
.coupon-section__link,
.body-report-section__link,
.referral-section__link {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.member-card-section__link-arrow,
.booking-section__link-arrow,
.checkin-section__link-arrow,
.coupon-section__link-arrow,
.body-report-section__link-arrow,
.referral-section__link-arrow {
width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
@@ -0,0 +1,243 @@
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.coupon-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.coupon-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.coupon-section__header {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.coupon-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.coupon-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__link {
width: auto;
height: auto;
position: relative;
}
.coupon-section__view-all {
width: auto;
height: auto;
position: relative;
flex-grow: 0;
}
.coupon-section__link-arrow {
position: relative;
}
.coupon-section__cards {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.coupon-section__cards-inner {
display: flex;
flex-direction: row;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.coupon-section__coupon {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
border-radius: var(--radius-sm);
}
.coupon-section__coupon-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
justify-content: center;
padding: 14px 14px 14px 14px;
width: 100%;
position: relative;
}
.coupon-section__amount {
font-size: var(--font-size-3xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__desc {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__coupon-status {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 2px 6px 2px 6px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.coupon-section__status {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
border-radius: var(--radius-sm);
}
.coupon-section__points-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
justify-content: center;
padding: 14px 14px 14px 14px;
width: 100%;
position: relative;
}
.coupon-section__num {
font-size: var(--font-size-3xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points-label {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points-action {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 2px 6px 2px 6px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.coupon-section__text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,31 @@
/**
* 与个人中心首页 coupon-section 一致的渐变卡片背景
* 使用 SVG 背景,兼容微信小程序(CSS 变量渐变在部分端不生效)
*/
.mi-gradient-blue,
.coupon-section__points,
.mi-mod-points-hero,
.bt-hero,
.bt-score-card,
.mi-mod-referral-hero {
background-color: #0B2B4B;
background-position: center;
background-size: cover;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
box-shadow: 0px 4px 8px 0px rgba(11, 43, 75, 0.25);
}
.mi-gradient-orange,
.coupon-section__coupon,
.mi-mod-coupon__left,
.mi-mod-coupon__use,
.mi-center-coupon__btn:not(.mi-center-coupon__btn--done),
.bt-page__action-link--primary,
.bt-btn--primary {
background-color: #FF6B35;
background-position: center;
background-size: cover;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
box-shadow: 0px 4px 8px 0px rgba(255, 107, 53, 0.25);
}
@@ -0,0 +1,271 @@
.profile-header {
width: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
/* 顶栏固定:仅白底导航栏吸顶,下方用户信息可滚动 */
.profile-header__toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
width: 100%;
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
}
.profile-header__toolbar-spacer {
width: 100%;
flex-shrink: 0;
}
.profile-header__nav {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
padding-left: 16px;
box-sizing: border-box;
}
.profile-header__nav-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
min-width: 56px;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.profile-header__nav-right {
min-width: 72px;
flex-shrink: 0;
position: relative;
z-index: 2;
margin-left: auto;
}
.profile-header__title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 42%;
text-align: center;
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 1;
}
.profile-header__icon-bell,
.profile-header__icon-settings {
width: 22px;
height: 22px;
flex-shrink: 0;
display: block;
}
/* settings.png 为白色线稿,白底顶栏需着色后才可见 */
.profile-header__icon-settings {
filter: brightness(0) saturate(100%) invert(52%) sepia(98%) saturate(1800%) hue-rotate(346deg) brightness(102%) contrast(101%);
}
/* 用户信息渐变区 */
.profile-header__hero {
width: 100%;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20390%20239'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.0000%2C%201.0000%2C%20-1.0000%2C%20-0.0000%2C%201.0000%2C%200.0000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.profile-header__inner {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px 20px 28px;
width: 100%;
box-sizing: border-box;
}
.profile-header__user {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.profile-header__user-inner {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
}
.profile-header__avatar-wrap {
position: relative;
width: 72px;
height: 72px;
flex-shrink: 0;
overflow: visible;
}
.profile-header__avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
border: 3px solid #ffffff;
overflow: hidden;
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.12);
}
.profile-header__avatar {
width: 100%;
height: 100%;
display: block;
}
.profile-header__avatar-badge {
position: absolute;
right: -1px;
bottom: -1px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #ffffff;
background-color: #2ecc71;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
box-sizing: border-box;
}
.profile-header__avatar-badge-icon {
width: 12px;
height: 12px;
display: block;
flex-shrink: 0;
filter: brightness(0) invert(1);
}
.profile-header__user-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.profile-header__user-meta-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
align-items: flex-start;
width: 100%;
}
.profile-header__name {
font-size: var(--font-size-xl, 20px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__phone {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
white-space: nowrap;
}
.profile-header__badge {
display: flex;
flex-direction: row;
gap: var(--spacing-xs, 4px);
align-items: center;
padding: 3px 10px;
border-radius: 10px;
background-color: var(--accent-orange);
}
.profile-header__badge-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.profile-header__level {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__stats {
width: 100%;
}
.profile-header__stats-inner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding-top: 16px;
width: 100%;
}
.profile-header__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.profile-header__stat-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
width: 100%;
}
.profile-header__stat-value {
font-size: var(--font-size-2xl, 22px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__stat-label {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
white-space: nowrap;
}
.profile-header__stat-divider {
width: 1px;
height: 32px;
flex-shrink: 0;
background-color: rgba(255, 255, 255, 0.31);
}
@@ -0,0 +1,40 @@
.logout-section {
width: 100%;
box-sizing: border-box;
}
.logout-section__btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 50px;
padding: 14px 16px;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: #ffffff;
box-sizing: border-box;
}
.logout-section__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.logout-section__text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 500;
color: #e74c3c;
line-height: 1;
white-space: nowrap;
}
.mi-tap-btn--hover {
opacity: 0.85;
transform: scale(0.98);
}
@@ -0,0 +1,362 @@
.member-card-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.member-card-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.member-card-section__head {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.member-card-section__head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.member-card-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-section__link {
width: auto;
height: auto;
position: relative;
}
.member-card-section__link-text {
white-space: nowrap;
}
.member-card-section__link-arrow {
position: relative;
}
.member-card-preview {
width: 100%;
min-height: 140px;
height: auto;
overflow: visible;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: 16px;
box-shadow: 0px 8px 16px 0px rgba(255, 107, 53, 0.25);
background-position: center;
background-size: 100% 100%;
box-sizing: border-box;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20140'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'0.6000000238418579'%2F%3E%3Cstop%20stop-color%3D'rgba(229%2C90%2C43%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.member-card-preview__inner {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
padding: 20px 16px 16px;
width: 100%;
box-sizing: border-box;
position: relative;
}
.member-card-preview__head {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.member-card-preview__head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
position: relative;
}
.member-card-preview__type-row {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.member-card-preview__icon-wrap {
width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
}
.member-card-preview__icon-bg {
width: 100%;
height: 100%;
position: relative;
border-radius: 1.5px 1.5px 1.5px 1.5px;
}
.member-card-preview__icon-border {
position: absolute;
left: 8.33%;
top: 20.83%;
right: 8.33%;
bottom: 20.83%;
width: 83.34%;
height: 58.34%;
}
.member-card-preview__icon-stroke {
position: absolute;
inset: -0.75px -0.75px -0.75px -0.75px;
border-radius: 1.5px 1.5px 1.5px 1.5px;
pointer-events: none;
border-width: 1.5px 1.5px 1.5px 1.5px;
border-style: solid;
box-sizing: border-box;
border-color: var(--text-inverse);
}
.member-card-preview__icon-line {width: 83.34%;
height: 8.33%;
position: absolute;
left: 8.33%;
right: 8.33%;
top: 41.67%;
bottom: 50%;
display: block;
}
.member-card-preview__name {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-card-preview__tag {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 3px 10px;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.19);
}
.member-card-preview__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__expire {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__footer {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: transparent;
}
.member-card-preview__footer-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
position: relative;
}
.member-card-preview__days {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: flex-start;
}
.member-card-preview__days-num {
font-size: var(--font-size-4xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__days-unit {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__renew {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 14px;
border-radius: 14px;
background-color: var(--bg-white);
}
.member-card-preview__renew-text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-tip__inner {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.member-card-tip__content {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
padding: 8px 12px 8px 12px;
width: 100%;
position: relative;
}
.member-card-tip {
position: relative;
width: 100%;
height: auto;
display: flex;
flex-shrink: 0;
}
.member-card-tip__border {
position: absolute;
inset: 0px;
border-radius: 10px 10px 10px 10px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(255, 204, 170, 1);
}
.member-card-tip__icon {width: 14px;
height: 14px;
position: relative;
flex-shrink: 0;
display: block;
}
.member-card-tip__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
@@ -0,0 +1,87 @@
.scroll-container {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
}
.scroll-container > view {
width: 100%;
}
.member-page {
width: 100%;
min-height: 100%;
height: auto;
overflow: visible;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
background-color: var(--bg-light);
box-sizing: border-box;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.member-page__body {
width: 100%;
height: auto;
flex: 1;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
.member-page__sections {
display: flex;
flex-direction: column;
gap: 12px;
align-items: stretch;
padding: var(--spacing-md, 16px);
padding-bottom: calc(var(--spacing-md, 16px) + env(safe-area-inset-bottom));
width: 100%;
box-sizing: border-box;
position: relative;
font-family: var(--font-family);
}
.member-page__sections text {
font-family: var(--font-family);
}
/* ????????? */
.status-bar,
.profile-header,
.member-card-section,
.quick-actions,
.booking-section,
.checkin-section,
.body-report-section,
.coupon-section,
.referral-section,
.settings-section,
.logout-btn__border-wrap,
.logout-section {
width: 100%;
box-sizing: border-box;
}
@@ -0,0 +1,226 @@
.quick-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: 16px;
box-shadow: 0px 2px 12px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
}
.quick-actions__inner {
display: flex;
flex-direction: column;
width: 100%;
}
.quick-actions__grid {
width: 100%;
display: flex;
flex-direction: row;
}
.quick-actions__grid-inner {
display: flex;
flex-direction: row;
width: 100%;
}
.quick-actions__item {
flex: 1;
height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-width: 0;
}
.quick-actions__item-inner {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.quick-actions__icon-wrap {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--radius-sm);
}
.quick-actions__icon-wrap-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.quick-actions__icon {
width: 20px;
height: 20px;
position: relative;
}
.quick-actions__icon-part {
position: absolute;
display: block;
}
.quick-actions__icon-part:nth-child(1) {
width: 7.69%;
height: 16.67%;
left: 30.77%;
top: 8.33%;
}
.quick-actions__icon-part:nth-child(2) {
width: 7.69%;
height: 16.67%;
left: 61.54%;
top: 8.33%;
}
.quick-actions__icon-part:nth-child(4) {
width: 7.69%;
height: 16.67%;
left: 30.77%;
top: 58.33%;
}
.quick-actions__icon-part:nth-child(5) {
width: 7.69%;
height: 16.67%;
left: 61.54%;
top: 58.33%;
}
.quick-actions__border-wrap {
position: absolute;
left: 12.5%;
top: 25%;
width: 75%;
height: 50%;
}
.quick-actions__rect {
width: 100%;
height: 100%;
border-radius: 2px;
}
.quick-actions__border {
position: absolute;
inset: -1px;
border-radius: 2px;
border: 1px solid var(--accent-orange);
pointer-events: none;
}
.quick-actions__icon-img {width: 20px;
height: 20px;
display: block;
}
.quick-actions__title,
.quick-actions__title-2,
.quick-actions__title-3,
.quick-actions__title-4,
.quick-actions__coach,
.quick-actions__text,
.quick-actions__text-2,
.quick-actions__points-desc {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
text-align: center;
}
.quick-actions__divider {
width: 100%;
height: 1px;
background-color: var(--border-light, #e9edf2);
}
/* 第?????*/
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
background-color: rgba(240, 250, 245, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
background-color: rgba(235, 243, 250, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
}
/* 第?????*/
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
background-color: rgba(255, 236, 236, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
background-color: rgba(240, 250, 245, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
background-color: rgba(235, 243, 250, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
}
@@ -0,0 +1,211 @@
.referral-section {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
}
.referral-section__inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
width: 100%;
box-sizing: border-box;
}
.referral-section__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.referral-section__title {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
flex-shrink: 0;
}
.referral-section__link {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.referral-section__records-link {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__link-arrow {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
/* 推荐码行:grid 避免小程序 flex 宽度计算异常 */
.referral-section__code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 10px;
width: 100%;
box-sizing: border-box;
}
.referral-section__code-box {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 10px 12px;
border-radius: 8px;
background-color: #f2f5f9;
box-sizing: border-box;
overflow: hidden;
}
.referral-section__code-label {
display: block;
font-size: var(--font-size-xs, 11px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
line-height: 1.4;
}
.referral-section__code-value {
display: block;
width: 100%;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
color: var(--primary-dark);
line-height: 1.3;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.referral-section__copy-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
height: 100%;
min-height: 52px;
padding: 0 14px;
border-radius: 10px;
background-color: var(--accent-orange);
box-sizing: border-box;
}
/* 例图:双矩形复制图标 */
.referral-section__copy-icon {
position: relative;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.referral-section__copy-sheet {
position: absolute;
width: 9px;
height: 9px;
border: 1.5px solid #ffffff;
border-radius: 2px;
box-sizing: border-box;
}
.referral-section__copy-sheet--back {
right: 0;
bottom: 0;
opacity: 0.85;
}
.referral-section__copy-sheet--front {
left: 0;
top: 0;
background-color: rgba(255, 255, 255, 0.2);
}
.referral-section__copy-text {
display: block;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__stats {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.referral-section__stat {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.referral-section__stat-num {
display: block;
font-size: var(--font-size-lg, 18px);
font-family: var(--font-family);
font-weight: 700;
line-height: 1.2;
}
.referral-section__stat-num--orange {
color: var(--accent-orange);
}
.referral-section__stat-num--green {
color: var(--success-green);
}
.referral-section__stat-num--amber {
color: #f39c12;
}
.referral-section__stat-label {
display: block;
font-size: var(--font-size-xs, 11px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__stat-divider {
width: 1px;
height: 28px;
flex-shrink: 0;
background-color: var(--border-light);
}
@@ -0,0 +1,166 @@
.settings-section {
width: 100%;
box-sizing: border-box;
--bg-white: #ffffff;
--text-dark: #1e2a3a;
--text-light: #8a99b4;
--error-red: #e74c3c;
--success-green: #2ecc71;
--border-light: #e9edf2;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-weight-medium: 500;
--font-weight-bold: 700;
}
.settings-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.settings-section__title {
display: block;
font-size: 16px;
font-family: var(--font-family);
font-weight: 700;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__list {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: #ffffff;
overflow: hidden;
}
.settings-section__list-inner {
display: flex;
flex-direction: column;
width: 100%;
}
.settings-section__item {
width: 100%;
min-height: 52px;
display: flex;
flex-direction: row;
align-items: center;
background-color: #ffffff;
}
.settings-section__item--tall {
min-height: 60px;
}
.settings-section__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 16px;
width: 100%;
box-sizing: border-box;
}
.settings-section__item-icon-wrap {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(255, 243, 238, 1);
}
.settings-section__item-icon-wrap--blue {
background-color: rgba(235, 243, 250, 1);
}
.settings-section__item-icon-wrap--green {
background-color: rgba(240, 250, 245, 1);
}
.settings-section__item-icon-wrap--red {
background-color: rgba(255, 236, 236, 1);
}
.settings-section__item-icon-wrap-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.settings-section__item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.settings-section__item-label {
flex: 1;
min-width: 0;
display: block;
font-size: 14px;
font-family: var(--font-family);
font-weight: 500;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__item-label--danger {
color: #e74c3c;
}
.settings-section__item-texts {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.settings-section__item-title {
display: block;
font-size: 14px;
font-family: var(--font-family);
font-weight: 500;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__item-desc {
display: block;
font-size: 11px;
font-family: var(--font-family);
font-weight: 400;
color: #2ecc71;
line-height: 1.3;
}
.settings-section__item-arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.settings-section__item-divider {
width: 100%;
height: 1px;
background-color: #e9edf2;
}
.mi-tap-row--hover {
opacity: 0.72;
}
@@ -0,0 +1,48 @@
.status-bar {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--primary-dark);
}
.status-bar__inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.status-bar__time {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.status-bar__icons {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,123 @@
.sub-nav {
width: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
box-sizing: border-box;
}
.sub-nav__toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
width: 100%;
background-color: #ffffff;
box-sizing: border-box;
border-bottom: 1px solid #e9edf2;
}
.sub-nav__spacer {
width: 100%;
flex-shrink: 0;
}
.sub-nav__nav {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
width: 100%;
}
.sub-nav__back {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 8px;
background-color: #f9fafe;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.sub-nav__back-icon {
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
display: block;
flex-shrink: 0;
}
.sub-nav__title {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: 50%;
text-align: center;
font-size: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 600;
color: #1e2a3a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 1;
line-height: 1.2;
}
.sub-nav__right {
display: flex;
flex-direction: row;
align-items: center;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.sub-nav__action {
flex-shrink: 0;
margin-right: 4px;
}
.sub-nav__action-text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 500;
color: #ff6b35;
white-space: nowrap;
}
.sub-nav__action--button {
padding: 6px 14px;
border-radius: 8px;
background-color: #ff6b35;
}
.sub-nav__action--button .sub-nav__action-text {
color: #ffffff;
font-weight: 600;
}
.sub-nav__capsule {
flex-shrink: 0;
min-width: 0;
}
.sub-nav__capsule--h5 {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
}
@@ -0,0 +1,36 @@
/* 小程序点击反馈(配合 hover-class 使用) */
.mi-tap--hover,
.mi-tap--scale,
.mi-tap-card--hover,
.mi-tap-btn--hover,
.mi-tap-tab--hover,
.mi-tap-save--hover {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.mi-tap--scale {
transform: scale(0.97);
}
.mi-tap-card--hover {
opacity: 0.92;
transform: scale(0.995);
}
.mi-tap-btn--hover {
opacity: 0.85;
transform: scale(0.98);
}
.mi-tap-tab--hover {
opacity: 0.75;
}
.mi-tap-row--hover {
background-color: rgba(249, 250, 254, 0.95);
}
.mi-tap-save--hover {
opacity: 0.88;
transform: scale(0.99);
}
@@ -0,0 +1,860 @@
/* 智能体测模块 - 公共样式(基于 base.css 变量) */
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.bt-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
.bt-page__body {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 12px);
padding: var(--spacing-md, 16px) var(--spacing-md, 16px) 40px;
box-sizing: border-box;
}
.bt-card {
width: 100%;
padding: 16px;
border-radius: var(--radius-md, 20px);
background-color: var(--bg-white, #fff);
box-shadow: var(--shadow-sm, 0 8px 20px rgba(0, 0, 0, 0.03));
box-sizing: border-box;
}
.bt-card__title {
font-size: var(--font-size-md, 16px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
margin-bottom: 12px;
display: block;
}
.bt-card__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
display: block;
}
.bt-hero {
padding: 20px;
border-radius: var(--radius-sm, 12px);
display: flex;
flex-direction: column;
gap: 12px;
}
.bt-hero__top {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.bt-hero__label {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.75);
}
.bt-hero__badge {
padding: 4px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
}
.bt-hero__badge-text {
font-size: var(--font-size-sm, 12px);
color: var(--text-inverse, #fff);
}
.bt-hero__score-row {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.bt-hero__score {
font-size: 48px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.bt-hero__grade {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--accent-orange-light, #FF8C5A);
}
.bt-hero__meta {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 212, 184, 1);
}
.bt-hero__actions {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 4px;
}
.bt-btn {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 16px;
border-radius: var(--radius-full, 999px);
box-sizing: border-box;
}
.bt-btn--primary {
box-shadow: var(--shadow-orange-glow, 0 4px 12px rgba(255, 107, 53, 0.25));
}
.bt-btn--ghost {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.bt-btn--outline {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
}
.bt-btn__text {
font-size: var(--font-size-base, 14px);
font-weight: 600;
}
.bt-btn--primary .bt-btn__text,
.bt-btn--ghost .bt-btn__text {
color: var(--text-inverse, #fff);
}
.bt-btn--outline .bt-btn__text {
color: var(--primary-dark, #0B2B4B);
}
.bt-btn__icon {
width: 16px;
height: 16px;
}
.bt-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.bt-grid__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 4px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
}
.bt-grid__icon {
width: 22px;
height: 22px;
}
.bt-grid__label {
font-size: 11px;
color: var(--text-muted, #5E6F8D);
text-align: center;
}
.bt-device {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.bt-device__icon-wrap {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--bg-gray, #F2F5F9);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-device__icon {
width: 22px;
height: 22px;
}
.bt-device__info {
flex: 1;
min-width: 0;
}
.bt-device__name {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-device__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
display: block;
margin-top: 2px;
}
.bt-device__status--on {
color: var(--success-green, #2ECC71);
}
.bt-device__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-light, #8A99B4);
flex-shrink: 0;
}
.bt-device__dot--on {
background: var(--success-green, #2ECC71);
}
.bt-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.bt-metric {
padding: 14px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
display: flex;
flex-direction: column;
gap: 4px;
}
.bt-metric__value {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
}
.bt-metric__label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-metric__change {
font-size: 11px;
font-weight: 600;
}
.bt-metric__change--down {
color: var(--success-green, #2ECC71);
}
.bt-metric__change--up {
color: var(--warning-amber, #F39C12);
}
.bt-steps {
display: flex;
flex-direction: column;
gap: 0;
}
.bt-step {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 0;
}
.bt-step__num {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary-dark, #0B2B4B);
color: var(--text-inverse, #fff);
font-size: var(--font-size-sm, 12px);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-step__content {
flex: 1;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-step:last-child .bt-step__content {
border-bottom: none;
padding-bottom: 0;
}
.bt-step__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-step__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
line-height: 1.5;
}
.bt-measure {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
gap: 16px;
}
.bt-measure__ring-wrap {
position: relative;
width: 180px;
height: 180px;
}
.bt-measure__ring-bg {
width: 180px;
height: 180px;
border-radius: 50%;
border: 10px solid var(--border-light, #E9EDF2);
box-sizing: border-box;
}
.bt-measure__ring-fill {
position: absolute;
top: 0;
left: 0;
width: 180px;
height: 180px;
border-radius: 50%;
border: 10px solid transparent;
border-top-color: var(--accent-orange, #FF6B35);
border-right-color: var(--accent-orange, #FF6B35);
box-sizing: border-box;
transform: rotate(-90deg);
transition: transform 0.3s ease;
}
.bt-measure__center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.bt-measure__percent {
font-size: 36px;
font-weight: 800;
color: var(--primary-dark, #0B2B4B);
display: block;
}
.bt-measure__hint {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-measure__live {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.bt-measure__live-item {
padding: 12px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
text-align: center;
}
.bt-measure__live-value {
font-size: var(--font-size-lg, 18px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-measure__live-label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 2px;
display: block;
}
.bt-score-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: var(--radius-sm, 12px);
}
.bt-score-card__circle {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-score-card__num {
font-size: 28px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.bt-score-card__grade {
font-size: 11px;
color: var(--accent-orange-light, #FF8C5A);
font-weight: 600;
}
.bt-score-card__info {
flex: 1;
}
.bt-score-card__title {
font-size: var(--font-size-md, 16px);
font-weight: 700;
color: var(--text-inverse, #fff);
display: block;
}
.bt-score-card__date {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
display: block;
}
.bt-body-map {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.bt-body-map__figure {
width: 120px;
height: 200px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.bt-body-map__head {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary-light, #2C6288);
opacity: 0.6;
}
.bt-body-map__torso {
width: 56px;
height: 70px;
border-radius: 12px;
background: var(--primary-deep, #1A4A6F);
margin-top: 4px;
}
.bt-body-map__limbs {
display: flex;
flex-direction: row;
gap: 48px;
margin-top: -60px;
}
.bt-body-map__arm {
width: 16px;
height: 50px;
border-radius: 8px;
background: var(--primary-light, #2C6288);
opacity: 0.7;
}
.bt-body-map__legs {
display: flex;
flex-direction: row;
gap: 12px;
margin-top: 4px;
}
.bt-body-map__leg {
width: 22px;
height: 60px;
border-radius: 8px;
background: var(--primary-light, #2C6288);
opacity: 0.7;
}
.bt-body-map__segments {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.bt-body-map__seg {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-gray, #F2F5F9);
}
.bt-body-map__seg--high {
border-left: 3px solid var(--accent-orange, #FF6B35);
}
.bt-body-map__seg--low {
border-left: 3px solid var(--info-blue, #3498DB);
}
.bt-body-map__seg-name {
font-size: var(--font-size-sm, 12px);
color: var(--text-dark, #1E2A3A);
}
.bt-body-map__seg-val {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.bt-advice-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bt-advice-item {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
}
.bt-advice-item__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-orange, #FF6B35);
margin-top: 6px;
flex-shrink: 0;
}
.bt-advice-item__text {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
flex: 1;
}
.bt-course {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-course:last-child {
border-bottom: none;
padding-bottom: 0;
}
.bt-course__banner {
width: 72px;
height: 72px;
border-radius: 12px;
flex-shrink: 0;
}
.bt-course__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.bt-course__tag {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
.bt-course__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-course__meta {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-history-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-history-item:last-child {
border-bottom: none;
}
.bt-history-item__date {
width: 52px;
text-align: center;
flex-shrink: 0;
}
.bt-history-item__day {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--primary-dark, #0B2B4B);
display: block;
}
.bt-history-item__month {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
}
.bt-history-item__info {
flex: 1;
min-width: 0;
}
.bt-history-item__score-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.bt-history-item__grade {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-history-item__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-history-item__metrics {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 2px;
}
.bt-history-item__arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.bt-compare-header {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.bt-compare-picker {
padding: 12px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
text-align: center;
}
.bt-compare-picker__label {
font-size: 10px;
color: var(--text-light, #8A99B4);
display: block;
}
.bt-compare-picker__date {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
margin-top: 4px;
display: block;
}
.bt-compare-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-compare-row:last-child {
border-bottom: none;
}
.bt-compare-row__label {
flex: 1;
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-compare-row__val {
width: 60px;
text-align: center;
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-compare-row__diff {
width: 56px;
text-align: right;
font-size: 11px;
font-weight: 600;
}
.bt-setting {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-setting:last-child {
border-bottom: none;
}
.bt-setting__label {
font-size: var(--font-size-base, 14px);
color: var(--text-dark, #1E2A3A);
}
.bt-setting__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
}
.bt-footer-actions {
display: flex;
flex-direction: row;
gap: 10px;
padding: 16px;
}
.bt-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.bt-empty__text {
font-size: var(--font-size-base, 14px);
color: var(--text-light, #8A99B4);
}
.bt-tabs {
display: flex;
flex-direction: row;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
overflow-x: auto;
box-sizing: border-box;
}
.bt-tab {
padding: 8px 14px;
border-radius: var(--radius-full, 999px);
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
flex-shrink: 0;
}
.bt-tab--active {
background: var(--primary-dark, #0B2B4B);
border-color: var(--primary-dark, #0B2B4B);
}
.bt-tab__text {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
.bt-tab--active .bt-tab__text {
color: var(--text-inverse, #fff);
font-weight: 600;
}
.bt-trend-link {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 0 0;
margin-top: 8px;
border-top: 1px solid var(--border-light, #E9EDF2);
}
.bt-trend-link__text {
font-size: var(--font-size-sm, 12px);
color: var(--primary-deep, #1A4A6F);
font-weight: 600;
}
.bt-trend-link__arrow {
width: 14px;
height: 14px;
}
@@ -0,0 +1,272 @@
.booking-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
/* Tab 栏 */
.booking-page__tabs {
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
height: 44px;
background-color: var(--bg-white, #ffffff);
border-bottom: 1px solid var(--border-light, #E9EDF2);
box-sizing: border-box;
}
.booking-page__tab {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 16px;
box-sizing: border-box;
}
.booking-page__tab-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.booking-page__tab-text--active {
font-weight: 700;
color: var(--accent-orange, #FF6B35);
}
.booking-page__tab-indicator {
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 2px;
border-radius: 2px;
background-color: var(--accent-orange, #FF6B35);
}
/* 内容区 */
.booking-page__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 40px;
box-sizing: border-box;
}
/* 提醒横幅 */
.booking-page__alert {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 204, 170, 1);
background-color: rgba(255, 243, 238, 1);
box-sizing: border-box;
}
.booking-page__alert-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
.booking-page__alert-text {
flex: 1;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange, #FF6B35);
line-height: 1.4;
}
/* 预约卡片 */
.bk-card {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
box-sizing: border-box;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.bk-card__banner {
width: 100%;
height: 80px;
display: block;
border-radius: 14px 14px 0 0;
}
.bk-card__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
box-sizing: border-box;
}
.bk-card__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.bk-card__title {
flex: 1;
min-width: 0;
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bk-card__status {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 6px;
box-sizing: border-box;
}
.bk-card__status--booked {
background-color: var(--success-green, #2ECC71);
}
.bk-card__status--pending {
background-color: rgba(255, 243, 238, 1);
border: 1px solid rgba(212, 166, 74, 1);
}
.bk-card__status--completed {
background-color: var(--bg-light, #F9FAFE);
border: 1px solid var(--border-light, #E9EDF2);
}
.bk-card__status--cancelled {
background-color: var(--bg-light, #F9FAFE);
border: 1px solid var(--border-light, #E9EDF2);
}
.bk-card__status-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 500;
white-space: nowrap;
}
.bk-card__status-text--booked {
color: var(--text-inverse, #ffffff);
}
.bk-card__status-text--pending {
color: var(--accent-orange, #FF6B35);
}
.bk-card__status-text--completed {
color: var(--text-muted, #5E6F8D);
}
.bk-card__status-text--cancelled {
color: var(--text-light, #8A99B4);
}
/* 时间与教练信息(分两行) */
.bk-card__meta {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.bk-card__meta-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.bk-card__meta-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
display: block;
}
.bk-card__meta-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
/* 底部操作行 */
.bk-card__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.bk-card__footer-info {
flex: 1;
min-width: 0;
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bk-card__cancel {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--border-light, #E9EDF2);
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
}
.bk-card__cancel-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red, #E74C3C);
white-space: nowrap;
}
/* 空状态 */
.booking-page__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
}
.booking-page__empty-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
}
@@ -0,0 +1,774 @@
.Pixso-frame-2_965 {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_965 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_966 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_967 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_968 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_968 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_968 {
position: relative;
width: 100%;
height: 52px;
display: flex;
flex-shrink: 0;
}
.stroke-2_968 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_969 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_969 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_970 {width: 20px;
height: 20px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_972 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
text-align: center;
color: var(--primary-dark);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_973 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
background-color: rgba(249, 250, 254, 0);
}
.Pixso-frame-2_974 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_974 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_974 {
position: relative;
width: 100%;
height: 44px;
display: flex;
flex-shrink: 0;
}
.stroke-2_974 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_975 {
width: 74px;
height: 44px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
}
.frame-content-2_975 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_976 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_977 {
width: 74px;
height: 2px;
position: absolute;
left: 0px;
top: 42px;
border-radius: 2px 2px 2px 2px;
background-color: var(--accent-orange);
}
.Pixso-frame-2_978 {
width: auto;
height: 44px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
}
.frame-content-2_978 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_979 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_980 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_980 {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
padding: 16px 16px 40px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_981 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_981 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
padding: 10px 12px 10px 12px;
width: 100%;
position: relative;
}
.stroke-wrapper-2_981 {
position: relative;
width: 100%;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_981 {
position: absolute;
inset: 0px;
border-radius: 10px 10px 10px 10px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(255, 204, 170, 1);
}
.Pixso-vector-2_982 {width: 14px;
height: 14px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_985 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_986 {
width: 100%;
height: 195px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_986 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-vector-2_987 {width: 100%;
height: 80px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_988 {
width: 358px;
height: 80px;
position: absolute;
left: 0px;
top: 0px;
border-radius: 14px 14px 0px 0px;
background-color: rgba(0, 0, 0, 0.1882352977991104);
}
.Pixso-frame-2_989 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_989 {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 14px 12px 14px;
width: 100%;
position: relative;
}
.Pixso-frame-2_990 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_990 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_991 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_992 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 6px 6px 6px 6px;
background-color: var(--success-green);
}
.Pixso-paragraph-2_993 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_994 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_994 {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
position: relative;
}
.Pixso-vector-2_995 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_998 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-vector-2_999 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1002 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1003 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1003 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1004 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1005 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 6px 14px 6px 14px;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-white);
}
.stroke-wrapper-2_1005 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1005 {
position: absolute;
inset: -1px -1px -1px -1px;
border-radius: 9px 9px 9px 9px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-paragraph-2_1006 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1007 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_1007 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-vector-2_1008 {width: 100%;
height: 80px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_1009 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_1009 {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 14px 12px 14px;
width: 100%;
position: relative;
}
.Pixso-frame-2_1010 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1010 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1011 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1012 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 243, 238, 1);
}
.stroke-wrapper-2_1012 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1012 {
position: absolute;
inset: 0px;
border-radius: 6px 6px 6px 6px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(212, 166, 74, 1);
}
.Pixso-paragraph-2_1013 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1014 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_1014 {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
position: relative;
}
.Pixso-vector-2_1015 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1018 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-vector-2_1019 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1022 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1023 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1023 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1024 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1025 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 6px 14px 6px 14px;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-white);
}
.stroke-wrapper-2_1025 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1025 {
position: absolute;
inset: -1px -1px -1px -1px;
border-radius: 9px 9px 9px 9px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-paragraph-2_1026 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,264 @@
.mi-course-list__filters {
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 10px);
box-sizing: border-box;
}
.mi-course-list__date-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.mi-course-list__mode {
padding: 8px 12px;
border-radius: 999px;
background: var(--primary-dark, #0B2B4B);
flex-shrink: 0;
}
.mi-course-list__mode-text {
font-size: 12px;
color: #fff;
font-weight: 600;
}
.mi-course-list__dates {
flex: 1;
white-space: nowrap;
}
.mi-course-list__date {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 8px 12px;
margin-right: 6px;
border-radius: 12px;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
}
.mi-course-list__date--active {
background: rgba(255, 107, 53, 0.12);
border-color: var(--accent-orange, #FF6B35);
}
.mi-course-list__date-week {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
}
.mi-course-list__date-day {
font-size: 13px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-course-list__chips {
white-space: nowrap;
}
.mi-course-list__row {
display: flex;
flex-direction: row;
gap: 8px;
}
.mi-course-list__picker {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
font-size: 13px;
color: var(--text-dark, #1E2A3A);
}
.mi-course-list__arrow {
width: 14px;
height: 14px;
transform: rotate(90deg);
}
.mi-course-card {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px;
border-radius: 18px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
margin-bottom: 12px;
}
.mi-course-card__banner {
width: 96px;
height: 120px;
border-radius: 12px;
flex-shrink: 0;
}
.mi-course-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.mi-course-card__head {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.mi-course-card__title {
font-size: 15px;
font-weight: 700;
color: var(--text-dark, #1E2A3A);
flex: 1;
}
.mi-course-card__type {
padding: 2px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.mi-course-card__type--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-course-card__type--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-course-card__type text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.mi-course-card__coach {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted, #5E6F8D);
}
.mi-course-card__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.mi-course-card__meta {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
.mi-course-card__capacity {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.mi-course-card__bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-gray, #F2F5F9);
overflow: hidden;
}
.mi-course-card__bar-fill {
height: 100%;
background: var(--gradient-orange);
border-radius: 3px;
}
.mi-course-card__cap-text {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
flex-shrink: 0;
}
.mi-course-card__scarcity {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
flex-shrink: 0;
}
.mi-course-card__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.mi-course-card__price {
font-size: 12px;
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
.mi-course-card__btn {
padding: 6px 16px;
border-radius: 999px;
background: var(--gradient-orange);
}
.mi-course-card__btn text {
font-size: 12px;
font-weight: 600;
color: #fff;
}
.mi-course-card__btn--disabled {
background: var(--bg-gray, #F2F5F9);
}
.mi-course-card__btn--disabled text {
color: var(--text-light, #8A99B4);
}
.mi-course-list__fab {
position: fixed;
right: 16px;
bottom: 32px;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 12px 16px;
border-radius: 999px;
background: var(--primary-dark, #0B2B4B);
box-shadow: var(--shadow-md);
z-index: 10;
}
.mi-course-list__fab-icon {
width: 18px;
height: 18px;
}
.mi-course-list__fab-text {
font-size: 13px;
font-weight: 600;
color: #fff;
}
@@ -0,0 +1,340 @@
.member-card-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
.member-card-page__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 40px;
box-sizing: border-box;
}
/* 会员卡 */
.mc-hero {
width: 100%;
min-height: 160px;
padding: 20px 20px 16px;
border-radius: 18px;
box-shadow: 0 10px 20px rgba(11, 43, 75, 0.31);
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-hero__top {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.mc-hero__title-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.mc-hero__crown {
width: 18px;
height: 18px;
flex-shrink: 0;
display: block;
}
.mc-hero__name {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.mc-hero__badge {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.19);
}
.mc-hero__badge-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.mc-hero__validity {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
display: block;
}
.mc-hero__bottom {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
width: 100%;
margin-top: 4px;
}
.mc-hero__days {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 4px;
}
.mc-hero__days-num {
font-size: var(--font-size-5xl, 32px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse, #ffffff);
line-height: 1;
}
.mc-hero__days-unit {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
padding-top: 8px;
}
.mc-hero__renew {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 8px 18px;
border-radius: 16px;
background-color: var(--bg-white, #ffffff);
flex-shrink: 0;
}
.mc-hero__renew-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
display: block;
}
.mc-hero__renew-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange, #FF6B35);
white-space: nowrap;
}
/* 使用记录 */
.mc-records {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
box-sizing: border-box;
}
.mc-records__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
box-sizing: border-box;
}
.mc-records__title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
white-space: nowrap;
flex-shrink: 0;
}
.mc-records__tabs {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 8px;
background-color: var(--bg-light, #F9FAFE);
flex-shrink: 0;
}
.mc-records__tab {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
border-radius: 6px;
box-sizing: border-box;
}
.mc-records__tab--active {
background-color: var(--bg-white, #ffffff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.mc-records__tab-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.mc-records__tab-text--active {
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mc-records__divider {
width: 100%;
height: 1px;
background-color: var(--bg-light, #F9FAFE);
}
.mc-records__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 14px 16px;
box-sizing: border-box;
}
.mc-records__icon-wrap {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mc-records__icon-wrap--orange {
background-color: rgba(255, 243, 238, 1);
}
.mc-records__icon-wrap--green {
background-color: rgba(240, 250, 245, 1);
}
.mc-records__icon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.mc-records__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mc-records__item-title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark, #1E2A3A);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mc-records__item-time {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
display: block;
}
.mc-records__value {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
}
.mc-records__value--negative {
color: var(--error-red, #E74C3C);
}
.mc-records__value--positive {
color: var(--success-green, #2ECC71);
}
/* 使用规则 */
.mc-rules {
width: 100%;
padding: 16px;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
}
.mc-rules__title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mc-rules__item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
}
.mc-rules__bullet {
width: 6px;
height: 6px;
margin-top: 6px;
border-radius: 1px;
background-color: var(--accent-orange, #FF6B35);
flex-shrink: 0;
}
.mc-rules__text {
flex: 1;
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
}
@@ -0,0 +1,982 @@
.Pixso-frame-2_878 {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_878 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_879 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_880 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_881 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_881 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_881 {
position: relative;
width: 100%;
height: 52px;
display: flex;
flex-shrink: 0;
}
.stroke-2_881 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_882 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_882 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_883 {width: 20px;
height: 20px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_885 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
text-align: center;
color: var(--primary-dark);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_886 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
background-color: rgba(249, 250, 254, 0);
}
.Pixso-frame-2_887 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_887 {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
padding: 16px 16px 40px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_888 {
width: 100%;
height: 160px;
overflow: hidden;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 18px 18px 18px 18px;
box-shadow: 0px 10px 20px 0px rgba(11, 43, 75, 0.3137254901960784);
background-position: center;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20160'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.frame-content-2_888 {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 20px 20px 16px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-frame-2_889 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_889 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-frame-2_890 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.Pixso-vector-2_891 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_894 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_895 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.Pixso-paragraph-2_896 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_897 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_898 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.frame-content-2_898 {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-frame-2_899 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 4px;
align-items: flex-start;
}
.Pixso-paragraph-2_900 {
font-size: var(--font-size-5xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_901 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_902 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
padding: 8px 18px 8px 18px;
border-radius: 16px 16px 16px 16px;
background-color: var(--bg-white);
}
.Pixso-vector-2_903 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_908 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_909 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_909 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-frame-2_910 {
width: 100%;
height: 48px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_910 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_911 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_912 {
width: auto;
height: 28px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_912 {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
padding: 2px 2px 2px 2px;
height: 100%;
position: relative;
}
.Pixso-frame-2_913 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.06274509803921569);
background-color: var(--bg-white);
}
.frame-content-2_913 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_914 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_915 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
background-color: rgba(249, 250, 254, 0);
}
.frame-content-2_915 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_916 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_917 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
background-color: rgba(249, 250, 254, 0);
}
.frame-content-2_917 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_918 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_919 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_920 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_920 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_921 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_921 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_922 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_928 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_928 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_929 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_930 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_931 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_932 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_933 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_933 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_934 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(240, 250, 245, 1);
}
.frame-content-2_934 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_935 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_938 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_938 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_939 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_940 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_941 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_942 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_943 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_943 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_944 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_944 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_945 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_949 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_949 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_950 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_951 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_952 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--success-green);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_953 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_953 {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
padding: 16px 16px 16px 16px;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_954 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_955 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_955 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_956 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_957 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_958 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_958 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_959 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_960 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_961 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_961 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_962 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_963 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
@@ -0,0 +1,584 @@
/* 个人中心其它模块页面样式 */
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.mi-mod-tabs {
display: flex;
flex-direction: row;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
overflow-x: auto;
box-sizing: border-box;
}
/* 训练明细 */
.mi-mod-session {
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-session:last-child {
border-bottom: none;
padding-bottom: 0;
}
.mi-mod-session__head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mi-mod-session__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
flex: 1;
}
.mi-mod-session__tag {
padding: 2px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.mi-mod-session__tag--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-session__tag--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-session__tag--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-session__tag-text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.mi-mod-session__meta {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-session__footer {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 8px;
}
.mi-mod-session__stat {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
/* 优惠券 */
.mi-mod-coupon {
display: flex;
flex-direction: row;
border-radius: var(--radius-md, 20px);
overflow: hidden;
margin-bottom: 12px;
box-shadow: var(--shadow-sm);
}
.mi-mod-coupon--expired,
.mi-mod-coupon--used {
opacity: 0.65;
}
.mi-mod-coupon__left {
width: 100px;
padding: 16px 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mi-mod-coupon--used .mi-mod-coupon__left,
.mi-mod-coupon--expired .mi-mod-coupon__left {
background: var(--bg-gray, #F2F5F9);
}
.mi-mod-coupon__amount {
font-size: 28px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.mi-mod-coupon--used .mi-mod-coupon__amount,
.mi-mod-coupon--expired .mi-mod-coupon__amount {
color: var(--text-muted, #5E6F8D);
}
.mi-mod-coupon__min {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
margin-top: 4px;
}
.mi-mod-coupon--used .mi-mod-coupon__min,
.mi-mod-coupon--expired .mi-mod-coupon__min {
color: var(--text-light, #8A99B4);
}
.mi-mod-coupon__right {
flex: 1;
padding: 14px 16px;
background: var(--bg-white, #fff);
position: relative;
}
.mi-mod-coupon__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-coupon__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-coupon__expire {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 8px;
display: block;
}
.mi-mod-coupon__tag {
position: absolute;
top: 14px;
right: 14px;
padding: 2px 8px;
border-radius: 6px;
background: var(--bg-gray, #F2F5F9);
}
.mi-mod-coupon__tag-text {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
/* 积分 */
.mi-mod-points-hero {
padding: 24px 20px;
border-radius: var(--radius-sm, 12px);
text-align: center;
}
.mi-mod-points-hero__label {
font-size: var(--font-size-xs, 12px);
color: rgba(255, 212, 184, 1);
display: block;
}
.mi-mod-points-hero__value {
font-size: var(--font-size-3xl, 2rem);
font-weight: 700;
color: var(--text-inverse, #fff);
line-height: 1.2;
display: block;
margin: 8px 0;
}
.mi-mod-points-hero__tip {
font-size: var(--font-size-xs, 12px);
color: rgba(255, 212, 184, 1);
line-height: 1.5;
}
.mi-mod-rewards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.mi-mod-reward {
padding: 14px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.mi-mod-reward__icon {
width: 28px;
height: 28px;
}
.mi-mod-reward__name {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-mod-reward__cost {
font-size: 11px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
.mi-mod-reward__stock {
font-size: 10px;
color: var(--text-light, #8A99B4);
}
.mi-mod-points-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-points-row:last-child {
border-bottom: none;
}
.mi-mod-points-row__info {
flex: 1;
}
.mi-mod-points-row__title {
font-size: var(--font-size-base, 14px);
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-points-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
display: block;
}
.mi-mod-points-row__right {
text-align: right;
}
.mi-mod-points-row__amount {
font-size: var(--font-size-md, 16px);
font-weight: 700;
display: block;
}
.mi-mod-points-row__amount--earn {
color: var(--success-green, #2ECC71);
}
.mi-mod-points-row__amount--spend {
color: var(--warning-amber, #F39C12);
}
.mi-mod-points-row__balance {
font-size: 10px;
color: var(--text-light, #8A99B4);
}
/* 邀请 */
.mi-mod-referral-hero {
padding: 20px;
border-radius: var(--radius-sm, 12px);
}
.mi-mod-referral-hero__title {
font-size: var(--font-size-lg, 18px);
font-weight: 700;
color: var(--text-inverse, #fff);
display: block;
}
.mi-mod-referral-hero__desc {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 212, 184, 1);
margin-top: 6px;
display: block;
}
.mi-mod-referral-code {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.15);
text-align: center;
}
.mi-mod-referral-code__label {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.75);
display: block;
}
.mi-mod-referral-code__value {
font-size: 24px;
font-weight: 800;
color: var(--text-inverse, #fff);
letter-spacing: 2px;
margin-top: 6px;
display: block;
}
.mi-mod-referral-stats {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
.mi-mod-referral-stat {
text-align: center;
}
.mi-mod-referral-stat__num {
font-size: var(--font-size-xl, 20px);
font-weight: 800;
display: block;
}
.mi-mod-referral-stat__num--orange {
color: var(--accent-orange, #FF6B35);
}
.mi-mod-referral-stat__num--green {
color: var(--success-green, #2ECC71);
}
.mi-mod-referral-stat__num--amber {
color: var(--warning-amber, #F39C12);
}
.mi-mod-referral-stat__label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-referral-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-referral-row:last-child {
border-bottom: none;
}
.mi-mod-referral-row__info {
flex: 1;
}
.mi-mod-referral-row__name {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-referral-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
display: block;
}
.mi-mod-referral-row__right {
text-align: right;
}
.mi-mod-referral-row__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
display: block;
}
.mi-mod-referral-row__reward {
font-size: 11px;
font-weight: 600;
color: var(--accent-orange, #FF6B35);
margin-top: 2px;
display: block;
}
/* 我的课程 */
.mi-mod-course-card {
display: flex;
flex-direction: row;
gap: 12px;
padding: 14px;
border-radius: var(--radius-md, 20px);
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
margin-bottom: 12px;
}
.mi-mod-course-card__banner {
width: 88px;
height: 88px;
border-radius: 12px;
flex-shrink: 0;
}
.mi-mod-course-card__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.mi-mod-course-card__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-mod-course-card__coach {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.mi-mod-course-card__progress {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.mi-mod-course-card__progress-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-gray, #F2F5F9);
overflow: hidden;
}
.mi-mod-course-card__progress-fill {
height: 100%;
border-radius: 3px;
background: var(--gradient-orange);
}
.mi-mod-course-card__progress-text {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
flex-shrink: 0;
}
.mi-mod-course-card__meta {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
.mi-mod-course-card__next {
font-size: 11px;
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
/* 签到记录 */
.mi-mod-checkin-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-checkin-row:last-child {
border-bottom: none;
}
.mi-mod-checkin-row__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mi-mod-checkin-row__icon--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-checkin-row__icon--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-checkin-row__icon--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-checkin-row__icon-img {
width: 20px;
height: 20px;
}
.mi-mod-checkin-row__info {
flex: 1;
min-width: 0;
}
.mi-mod-checkin-row__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-checkin-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 2px;
display: block;
}
.mi-mod-checkin-row__tag {
padding: 4px 10px;
border-radius: 8px;
flex-shrink: 0;
}
.mi-mod-checkin-row__tag--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-checkin-row__tag--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-checkin-row__tag--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-checkin-row__tag-text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
@@ -0,0 +1,32 @@
/* 子页面根容器:锁定变量,与 H5 theme-light 一致 */
.scroll-container {
box-sizing: border-box;
--primary-dark: #0B2B4B;
--primary-deep: #1A4A6F;
--accent-orange: #FF6B35;
--accent-orange-light: #FF8C5A;
--bg-light: #F9FAFE;
--bg-white: #FFFFFF;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--text-light: #8A99B4;
--text-inverse: #FFFFFF;
--border-light: #E9EDF2;
--success-green: #2ECC71;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
}
@@ -0,0 +1,83 @@
.scroll-container {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
.scroll-container > view {
width: 100%;
}
/* ========== 子页面统一布局(参考 base.css 间距变量) ========== */
.bt-page,
.booking-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
/* 导航栏下方首个区块:与固定顶栏留出间距 */
.bt-page > .sub-nav + .mi-mod-tabs,
.bt-page > .sub-nav + .mi-course-list__filters,
.bt-page > .sub-nav + .bt-page__action-bar,
.bt-page > .sub-nav + .bt-page__body,
.booking-page > .sub-nav + .booking-page__tabs {
margin-top: var(--spacing-md, 16px);
}
/* tabs / 筛选栏下方内容区:避免重复过大顶边距 */
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__action-bar + .bt-page__body,
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__body,
.bt-page > .sub-nav + .mi-course-list__filters + .bt-page__body,
.booking-page > .sub-nav + .booking-page__tabs + .bt-page__action-bar + .booking-page__body,
.booking-page > .sub-nav + .booking-page__tabs + .booking-page__body {
padding-top: var(--spacing-sm, 8px);
}
/* 导航栏下直接跟操作栏(无 tabs) */
.bt-page > .sub-nav + .bt-page__action-bar + .bt-page__body {
padding-top: var(--spacing-sm, 8px);
}
/* 页面内次级操作栏(原导航栏右侧按钮下移至此) */
.bt-page__action-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
box-sizing: border-box;
}
.bt-page__action-bar--end {
justify-content: flex-end;
}
.bt-page__action-bar-text {
flex: 1;
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.4;
}
.bt-page__action-link {
flex-shrink: 0;
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
padding: 6px 12px;
border-radius: var(--radius-full, 999px);
background-color: var(--bg-white, #FFFFFF);
border: 1px solid var(--border-light, #E9EDF2);
}
.bt-page__action-link--primary {
color: var(--text-inverse, #FFFFFF);
border-color: transparent;
}
@@ -0,0 +1,192 @@
.Pixso-frame-2_791 {
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: visible;
background-color: var(--bg-light, #F9FAFE);
}
.frame-content-2_802 {
box-sizing: border-box;
padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important;
}
.user-info-save-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background-color: var(--bg-white, #ffffff);
box-shadow: 0 -2px 12px rgba(26, 25, 24, 0.06);
box-sizing: border-box;
}
.user-info-save-bar__btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
border-radius: 12px;
background-color: var(--accent-orange, #FF6B35);
}
.user-info-save-bar__text {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse, #ffffff);
line-height: 1;
}
.avatar-block {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white);
flex-shrink: 0;
overflow: hidden;
box-sizing: border-box;
}
.avatar-block__inner {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background-color: var(--bg-light, #f9fafe);
}
.avatar-block__photo {
width: 100%;
height: 100%;
display: block;
}
.avatar-block__change {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 28px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
background-color: rgba(0, 0, 0, 0.55);
z-index: 2;
box-sizing: border-box;
}
.avatar-block__icon {
width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
filter: brightness(0) invert(1);
}
.avatar-block__text {
font-size: var(--font-size-xs, 11px);
line-height: 1;
color: #ffffff;
white-space: nowrap;
}
.frame-content-2_811,
.frame-content-2_817,
.frame-content-2_826,
.frame-content-2_842,
.frame-content-2_848,
.frame-content-2_859 {
min-width: 0;
}
.Pixso-paragraph-2_813,
.Pixso-paragraph-2_844,
.Pixso-paragraph-2_861 {
display: block;
min-width: 0;
}
.gender-btn {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
background-color: var(--bg-light, #F9FAFE);
flex-shrink: 0;
}
.gender-btn__icon {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
.gender-btn__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.gender-btn--active {
background-color: var(--accent-orange, #FF6B35);
}
.gender-btn--active .gender-btn__text {
color: var(--text-inverse, #ffffff);
}
.goal-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.goal-tag {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid rgba(209, 208, 205, 1);
background-color: var(--bg-white, #ffffff);
flex-shrink: 0;
box-sizing: border-box;
}
.goal-tag__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
.goal-tag--selected {
border-color: var(--accent-orange, #FF6B35);
background-color: var(--accent-orange, #FF6B35);
}
.goal-tag--selected .goal-tag__text {
color: var(--text-inverse, #ffffff);
}
File diff suppressed because it is too large Load Diff
+61 -32
View File
@@ -1,59 +1,91 @@
<template>
<!-- 底部导航栏容器 -->
<view class="tab-bar">
<!-- 导航栏项 -->
<view
v-for="(tab, index) in tabs"
:key="index"
:class="['tab-item', { active: activeTab === index }]"
@click="activeTab = index"
:key="tab.path"
:class="['tab-item', { active: currentIndex === index }]"
hover-class="tab-item--hover"
@tap="onTabTap(index)"
>
<!-- 导航栏图标 -->
<image :src="activeTab === index ? tab.iconActive : tab.icon" mode="aspectFit" class="tab-icon" />
<!-- 导航栏标签文字 -->
<image
:src="currentIndex === index ? tab.iconActive : tab.icon"
mode="aspectFit"
class="tab-icon"
/>
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import {
PAGE,
TAB_ROUTES,
getCurrentRoutePath,
getTabIndexByRoute,
switchToTab
} from '@/common/constants/routes.js'
// 当前激活的导航栏索引
const activeTab = ref(0)
const props = defineProps({
/** 当前 Tab 索引,由 Tab 页传入以保证高亮准确 */
active: {
type: Number,
default: -1
}
})
const tapping = ref(false)
// 导航栏数据列表
const tabs = [
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/home.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/home.png',
label: '首页',
path: PAGE.INDEX,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/home.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/home.png',
label: '首页'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/course.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/course.png',
label: '课程'
path: PAGE.COURSE,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/course.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/course.png',
label: '课程'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/train.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/train.png',
label: '训练' ,
path: PAGE.TRAIN,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/train.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/train.png',
label: '训练'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/discover.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/discover.png',
label: '发现',
path: PAGE.DISCOVER,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/discover.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/discover.png',
label: '发现'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/profile.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/profile.png',
label: '我的',
path: PAGE.MEMBER,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/profile.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/profile.png',
label: '我的'
}
]
const currentIndex = computed(() => {
if (props.active >= 0) return props.active
return getTabIndexByRoute(getCurrentRoutePath())
})
function onTabTap(index) {
if (tapping.value || index === currentIndex.value) return
tapping.value = true
switchToTab(TAB_ROUTES[index])
setTimeout(() => {
tapping.value = false
}, 350)
}
</script>
<style lang="scss" scoped>
/* 底部导航栏容器样式 */
.tab-bar {
position: fixed;
bottom: 0;
@@ -68,9 +100,9 @@ const tabs = [
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
border-radius: 32rpx 32rpx 0 0;
z-index: 999;
}
/* 导航栏项样式 */
.tab-item {
display: flex;
flex-direction: column;
@@ -79,19 +111,16 @@ const tabs = [
padding: 12rpx 24rpx;
}
/* 导航栏图标样式 */
.tab-icon {
width: 40rpx;
height: 40rpx;
}
/* 导航栏标签文字样式 */
.tab-label {
font-size: 22rpx;
color: #94a3b8;
}
/* 导航栏激活状态文字样式 */
.tab-item.active .tab-label {
color: #f97316;
font-weight: 600;
@@ -0,0 +1,79 @@
<template>
<view class="bt-radar">
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
class="bt-radar__canvas"
:style="{ width: width + 'px', height: height + 'px' }"
/>
</view>
</template>
<script>
import { drawRadarChart } from '@/common/memberInfo/bodyTestChart.js'
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] },
width: { type: Number, default: 280 },
height: { type: Number, default: 240 }
},
data() {
return {
canvasId: `bt-radar-${Math.random().toString(36).slice(2, 9)}`
}
},
watch: {
values: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query
.select(`#${this.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawRadarChart(node, {
width: this.width,
height: this.height,
labels: this.labels,
values: this.values,
dpr
})
})
})
}
}
}
</script>
<style>
.bt-radar {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.bt-radar__canvas {
display: block;
}
</style>
@@ -0,0 +1,79 @@
<template>
<view class="bt-trend">
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
class="bt-trend__canvas"
:style="{ width: width + 'px', height: height + 'px' }"
/>
</view>
</template>
<script>
import { drawTrendChart } from '@/common/memberInfo/bodyTestChart.js'
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
points: { type: Array, default: () => [] },
unit: { type: String, default: '' },
width: { type: Number, default: 300 },
height: { type: Number, default: 160 }
},
data() {
return {
canvasId: `bt-trend-${Math.random().toString(36).slice(2, 9)}`
}
},
watch: {
points: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query
.select(`#${this.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawTrendChart(node, {
width: this.width,
height: this.height,
points: this.points,
unit: this.unit,
dpr
})
})
})
}
}
}
</script>
<style>
.bt-trend {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.bt-trend__canvas {
display: block;
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="body-report-section">
<view class="body-report-section__inner">
<view class="body-report-section__header">
<view class="body-report-section__header-inner">
<text class="body-report-section__title">体测报告</text>
<view
class="body-report-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-history')"
>
<text class="body-report-section__history-link">历史记录</text>
<image
class="body-report-section__link-arrow"
src="/static/images/chevronright3.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="body-report-section__card">
<view class="body-report-section__card-inner">
<view class="body-report-section__card-head">
<view class="body-report-section__card-head-inner">
<text class="body-report-section__desc">
最新数据 · {{ report.date }}
</text>
<view
class="body-report-section__view-btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="$emit('view-report')"
>
<image
class="body-report-section__view-icon"
src="/static/images/filetext.png"
mode="aspectFit"
/>
<text class="body-report-section__view-report">查看报告</text>
</view>
</view>
</view>
<view class="body-report-section__metrics">
<view class="body-report-section__metrics-inner">
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text">{{ report.weight }}</text>
<text class="body-report-section__metric-value">体重(kg)</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text-2">{{ report.bmi }}</text>
<text class="body-report-section__text-3">BMI</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text-4">{{ report.bodyFat }}</text>
<text class="body-report-section__metric-label">体脂率</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__num">{{ report.bmr }}</text>
<text class="body-report-section__text-5">BMR</text>
</view>
</view>
</view>
</view>
<view class="body-report-section__summary">
<view class="body-report-section__summary-inner">
<view class="body-report-section__goal">
<image
class="body-report-section__goal-icon"
src="/static/images/target.png"
mode="aspectFit"
/>
<text class="body-report-section__goal-text">
状态{{ report.status }}
</text>
</view>
<view class="body-report-section__change">
<image
class="body-report-section__change-icon"
src="/static/images/trendingdown.png"
mode="aspectFit"
/>
<text class="body-report-section__metric-value-2">
较上次 {{ report.change }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
report: {
type: Object,
default: () => ({
date: '2024-07-01',
weight: '63.5',
bmi: '22.1',
bodyFat: '24.8%',
bmr: '165',
status: '比较健康',
change: '-1.2kg'
})
}
},
emits: ['view-history', 'view-report']
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,117 @@
<template>
<view class="booking-section">
<view class="booking-section__inner">
<view class="booking-section__header">
<view class="booking-section__header-inner">
<text class="booking-section__title">我的预约</text>
<view
class="booking-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="booking-section__view-all">预约记录</text>
<image
class="booking-section__link-arrow"
src="/static/images/chevronright4.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view
v-for="item in previewItems"
:key="item.id"
class="booking-section__item"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="$emit('item-tap', item)"
>
<view class="booking-section__item-inner">
<view class="booking-section__date">
<view class="booking-section__date-inner">
<text class="booking-section__num">{{ item.dateDay }}</text>
<text class="booking-section__date-sub">{{ item.dateMonth }}</text>
</view>
</view>
<view class="booking-section__content">
<view class="booking-section__content-inner">
<text class="booking-section__desc">{{ item.desc }}</text>
<view class="booking-section__meta">
<view class="booking-section__meta-inner">
<image
class="booking-section__icon-coach"
src="/static/images/user2.png"
mode="aspectFit"
/>
<text class="booking-section__coach">教练{{ item.coach }}</text>
<image
class="booking-section__icon-location"
src="/static/images/mappin1.png"
mode="aspectFit"
/>
<text class="booking-section__text">{{ item.location }}</text>
</view>
</view>
</view>
</view>
<view class="booking-section__status-wrap">
<view
class="booking-section__status-badge"
:class="'booking-section__status-badge--' + item.status"
>
<text
class="booking-section__status-text"
:class="{ 'booking-section__status-text--pending': item.status === 'pending' }"
>
{{ item.statusLabel }}
</text>
</view>
</view>
</view>
</view>
<view v-if="!previewItems.length" class="booking-section__empty">
<text class="booking-section__empty-text">暂无进行中的预约</text>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
items: {
type: Array,
default: () => []
}
},
emits: ['view-all', 'item-tap'],
computed: {
previewItems() {
return this.items
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-tap.css';
.booking-section__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.booking-section__empty-text {
font-size: 14px;
color: #8A99B4;
}
</style>
@@ -0,0 +1,87 @@
<template>
<view class="checkin-section">
<view class="checkin-section__inner">
<view class="checkin-section__header">
<view class="checkin-section__header-inner">
<text class="checkin-section__title">签到记录</text>
<view
class="checkin-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="checkin-section__view-all">查看全部</text>
<image
class="checkin-section__link-arrow"
src="/static/images/chevronright2.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="checkin-section__list">
<view class="checkin-section__list-inner">
<view
v-for="(item, index) in items"
:key="item.id"
class="checkin-section__row"
>
<view v-if="index > 0" class="checkin-section__divider"></view>
<view
class="checkin-section__item"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="$emit('item-tap', item)"
>
<view class="checkin-section__item-inner">
<view
class="checkin-section__dot"
:class="'checkin-section__dot--' + item.tagTheme"
></view>
<view class="checkin-section__content">
<view class="checkin-section__content-inner">
<text class="checkin-section__desc">{{ item.title }}</text>
<text class="checkin-section__text">{{ item.time }}</text>
</view>
</view>
<view
class="checkin-section__tag-badge"
:class="'checkin-section__tag-badge--' + item.tagTheme"
>
<text
class="checkin-section__tag-text"
:class="'checkin-section__tag-text--' + item.tagTheme"
>
{{ item.tag }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
items: {
type: Array,
default: () => []
}
},
emits: ['view-all', 'item-tap']
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,85 @@
<template>
<view class="coupon-section">
<view class="coupon-section__inner">
<view class="coupon-section__header">
<view class="coupon-section__header-inner">
<text class="coupon-section__title">优惠券 & 积分</text>
<view
class="coupon-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="coupon-section__view-all">更多详情</text>
<image
class="coupon-section__link-arrow"
src="/static/images/chevronright5.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="coupon-section__cards">
<view class="coupon-section__cards-inner">
<view
class="coupon-section__coupon"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('use-coupon')"
>
<view class="coupon-section__coupon-inner">
<text class="coupon-section__amount">{{ data.amount }}</text>
<text class="coupon-section__desc">{{ data.couponDesc }}</text>
<view class="coupon-section__coupon-status">
<text class="coupon-section__status">{{ data.couponAction }}</text>
</view>
</view>
</view>
<view
class="coupon-section__points"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('redeem-points')"
>
<view class="coupon-section__points-inner">
<text class="coupon-section__num">{{ data.points }}</text>
<text class="coupon-section__points-label">{{ data.pointsLabel }}</text>
<view class="coupon-section__points-action">
<text class="coupon-section__text">{{ data.pointsAction }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
data: {
type: Object,
default: () => ({
amount: '¥50',
couponDesc: '满500可用 · 1张',
couponAction: '去使用',
points: 1250,
pointsLabel: '我的积分',
pointsAction: '去兑换'
})
}
},
emits: ['view-all', 'use-coupon', 'redeem-points']
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,151 @@
<template>
<view class="profile-header">
<!-- 顶栏白底居中标题左侧放通知/设置右侧留胶囊安全区 -->
<view class="profile-header__toolbar" :style="toolbarStyle">
<view class="profile-header__nav">
<view class="profile-header__nav-left">
<image
class="profile-header__icon-bell"
src="/static/images/bell.png"
mode="aspectFit"
/>
<image
class="profile-header__icon-settings"
src="/static/images/settings.png"
mode="aspectFit"
/>
</view>
<text class="profile-header__title">个人中心</text>
<view class="profile-header__nav-right" :style="navRightStyle"></view>
</view>
</view>
<view class="profile-header__toolbar-spacer" :style="toolbarSpacerStyle"></view>
<!-- 用户信息区深蓝渐变 -->
<view class="profile-header__hero">
<view class="profile-header__inner">
<view class="profile-header__user" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="$emit('user-info')">
<view class="profile-header__user-inner">
<view class="profile-header__avatar-wrap">
<view class="profile-header__avatar-ring">
<image
class="profile-header__avatar"
:key="userInfo.avatar"
:src="displayAvatar"
mode="aspectFill"
/>
</view>
<view class="profile-header__avatar-badge">
<image
class="profile-header__avatar-badge-icon"
src="/static/images/camera.png"
mode="aspectFit"
/>
</view>
</view>
<view class="profile-header__user-meta">
<view class="profile-header__user-meta-inner">
<text class="profile-header__name">{{ userInfo.name }}</text>
<text class="profile-header__phone">{{ userInfo.phone }}</text>
<view class="profile-header__badge">
<image
class="profile-header__badge-icon"
src="/static/images/crown0.png"
mode="aspectFit"
/>
<text class="profile-header__level">{{ userInfo.memberLevel }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="profile-header__stats">
<view class="profile-header__stats-inner">
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.checkInCount }}</text>
<text class="profile-header__stat-label">累计签到</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.trainingHours }}</text>
<text class="profile-header__stat-label">训练时长</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.pointsBalance }}</text>
<text class="profile-header__stat-label">累计积分</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
userInfo: { type: Object, required: true },
stats: { type: Object, required: true }
},
emits: ['user-info'],
computed: {
displayAvatar() {
return this.userInfo.avatar || '/static/images/AvatarEditWrap.png'
}
},
data() {
return {
toolbarStyle: {},
toolbarSpacerStyle: {},
navRightStyle: {}
}
},
mounted() {
this.syncNavSafeArea()
},
methods: {
syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const menu = uni.getMenuButtonBoundingClientRect?.()
this.toolbarStyle = {
paddingTop: `${statusBarHeight}px`
}
this.toolbarSpacerStyle = {
height: `${statusBarHeight + navHeight}px`
}
if (menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
this.navRightStyle = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
}
} catch (e) {
this.toolbarSpacerStyle = { height: '44px' }
}
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,31 @@
<template>
<view class="logout-section">
<view
class="logout-section__btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="$emit('logout')"
>
<image
class="logout-section__icon"
src="/static/images/logout.png"
mode="aspectFit"
/>
<text class="logout-section__text">退出登录</text>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['logout']
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-logout.css';
</style>
@@ -0,0 +1,135 @@
<template>
<view class="member-card-section">
<view class="member-card-section__inner">
<view class="member-card-section__head">
<view class="member-card-section__head-inner">
<text class="member-card-section__title">
我的会员卡
</text>
<view
class="member-card-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text
class="member-card-section__link-text"
>
查看全部
</text>
<image class="member-card-section__link-arrow" src="/static/images/chevronright12.png" mode="aspectFit" />
</view>
</view>
</view>
<view
class="member-card-preview"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<view class="member-card-preview__inner">
<view class="member-card-preview__head">
<view class="member-card-preview__head-inner">
<view
class="member-card-preview__type-row"
>
<view
class="member-card-preview__icon-wrap"
>
<view
class="member-card-preview__icon-border"
>
<view
class="member-card-preview__icon-bg"
></view>
<view
class="member-card-preview__icon-stroke"
></view>
</view>
<image class="member-card-preview__icon-line" src="/static/images/Line_2_468.png" mode="aspectFill" />
</view>
<text
class="member-card-preview__name"
>
{{ cardInfo.name }}
</text>
</view>
<view
class="member-card-preview__tag"
>
<text class="member-card-preview__tag-text">
{{ cardInfo.detailTag || '详情' }}
</text>
</view>
</view>
</view>
<text class="member-card-preview__expire">
{{ cardInfo.expireDate }}
</text>
<view class="member-card-preview__footer">
<view class="member-card-preview__footer-inner">
<view
class="member-card-preview__days"
>
<text
class="member-card-preview__days-num"
>
{{ cardInfo.remainingDays }}
</text>
<text
class="member-card-preview__days-unit"
>
天剩余
</text>
</view>
<view
class="member-card-preview__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="$emit('renew')"
>
<text
class="member-card-preview__renew-text"
>
续费
</text>
</view>
</view>
</view>
</view>
</view>
<view class="member-card-tip">
<view class="member-card-tip__inner">
<view class="member-card-tip__content">
<image class="member-card-tip__icon" src="/static/images/clock1.png" mode="aspectFit" />
<text
class="member-card-tip__text"
>
{{ cardInfo.tip }}
</text>
</view>
</view>
<view class="member-card-tip__border"></view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
cardInfo: { type: Object, required: true }
},
emits: ['view-all', 'renew'],
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,116 @@
<template>
<view class="quick-actions">
<view class="quick-actions__inner">
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view
v-for="item in row1"
:key="item.key"
class="quick-actions__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('action', item.key)"
>
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<view v-if="item.key === 'booking'" class="quick-actions__icon">
<image
class="quick-actions__icon-part"
src="/static/images/Vector_2_490.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="/static/images/Vector_2_491.png"
mode="aspectFit"
/>
<view class="quick-actions__border-wrap">
<view class="quick-actions__rect"></view>
<view class="quick-actions__border"></view>
</view>
<image
class="quick-actions__icon-part"
src="/static/images/Vector_2_493.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="/static/images/Vector_2_494.png"
mode="aspectFit"
/>
</view>
<image
v-else
class="quick-actions__icon-img"
:src="item.icon"
mode="aspectFit"
/>
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
<view class="quick-actions__divider"></view>
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view
v-for="item in row2"
:key="item.key"
class="quick-actions__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('action', item.key)"
>
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<image
class="quick-actions__icon-img"
:src="item.icon"
mode="aspectFit"
/>
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['action'],
data() {
return {
row1: [
{ key: 'booking', label: '预约课程', textClass: 'quick-actions__title', icon: '' },
{ key: 'bodyTest', label: '智能体测', textClass: 'quick-actions__title-2', icon: '/static/images/mappin2.png' },
{ key: 'bodyReport', label: '体测报告', textClass: 'quick-actions__title-3', icon: '/static/images/activity.png' },
{ key: 'trainReport', label: '训练报告', textClass: 'quick-actions__coach', icon: '/static/images/usercheck.png' }
],
row2: [
{ key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__text', icon: '/static/images/ticket.png' },
{ key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: '/static/images/star.png' },
{ key: 'referral', label: '邀请好友', textClass: 'quick-actions__title-4', icon: '/static/images/share2.png' },
{ key: 'course', label: '我的课程', textClass: 'quick-actions__text-2', icon: '/static/images/play.png' }
]
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,101 @@
<template>
<view class="referral-section">
<view class="referral-section__inner">
<view class="referral-section__header">
<text class="referral-section__title">推荐奖励</text>
<view
class="referral-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-rules')"
>
<text class="referral-section__records-link">规则说明</text>
<image
class="referral-section__link-arrow"
src="/static/images/chevronright11.png"
mode="aspectFit"
/>
</view>
</view>
<view class="referral-section__code-row">
<view class="referral-section__code-box">
<text class="referral-section__code-label">我的邀请码</text>
<text class="referral-section__code-value">{{ data.code }}</text>
</view>
<view
class="referral-section__copy-btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="copyCode"
>
<view class="referral-section__copy-icon">
<view class="referral-section__copy-sheet referral-section__copy-sheet--back"></view>
<view class="referral-section__copy-sheet referral-section__copy-sheet--front"></view>
</view>
<text class="referral-section__copy-text">复制</text>
</view>
</view>
<view class="referral-section__stats">
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--orange">
{{ data.invited }}
</text>
<text class="referral-section__stat-label">已推荐</text>
</view>
<view class="referral-section__stat-divider"></view>
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--green">
{{ data.registered }}
</text>
<text class="referral-section__stat-label">已注册</text>
</view>
<view class="referral-section__stat-divider"></view>
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--amber">
{{ data.purchased }}
</text>
<text class="referral-section__stat-label">已购课</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
data: {
type: Object,
default: () => ({
code: 'FIT-ZXF-2024',
invited: 5,
registered: 3,
purchased: 2
})
}
},
emits: ['view-rules'],
methods: {
copyCode() {
uni.setClipboardData({
data: this.data.code,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -0,0 +1,109 @@
<template>
<view class="settings-section">
<view class="settings-section__inner">
<text class="settings-section__title">设置与安全</text>
<view class="settings-section__list">
<view class="settings-section__list-inner">
<view
v-for="(item, index) in items"
:key="item.key"
>
<view v-if="index > 0" class="settings-section__item-divider"></view>
<view
class="settings-section__item"
:class="{ 'settings-section__item--tall': item.subtitle }"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="$emit('setting', item.key)"
>
<view class="settings-section__item-inner">
<view
class="settings-section__item-icon-wrap"
:class="item.iconWrapClass"
>
<image
class="settings-section__item-icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<view
v-if="item.subtitle"
class="settings-section__item-texts"
>
<text class="settings-section__item-title">{{ item.label }}</text>
<text class="settings-section__item-desc">{{ item.subtitle }}</text>
</view>
<text
v-else
class="settings-section__item-label"
:class="item.labelClass"
>
{{ item.label }}
</text>
<image
class="settings-section__item-arrow"
src="/static/images/chevronright10.png"
mode="aspectFit"
/>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['setting'],
data() {
return {
items: [
{
key: 'notify',
label: '通知设置',
icon: '/static/images/bell.png',
iconWrapClass: ''
},
{
key: 'password',
label: '修改密码',
icon: '/static/images/Vector_2_727.png',
iconWrapClass: 'settings-section__item-icon-wrap--blue'
},
{
key: 'privacy',
label: '隐私政策',
icon: '/static/images/shield.png',
iconWrapClass: 'settings-section__item-icon-wrap--green'
},
{
key: 'nfc',
label: 'NFC 门禁卡',
subtitle: '已绑定',
icon: '/static/images/ticket.png',
iconWrapClass: ''
},
{
key: 'delete',
label: '注销账户',
icon: '/static/images/userx.png',
iconWrapClass: 'settings-section__item-icon-wrap--red',
labelClass: 'settings-section__item-label--danger'
}
]
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-settings.css';
</style>
@@ -0,0 +1,20 @@
<template>
<view class="status-bar">
<view class="status-bar__inner">
<text class="status-bar__time">{{ statusBarTime }}</text>
<text class="status-bar__icons">...</text>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: true,
styleIsolation: 'apply-shared'
},
props: {
statusBarTime: { type: String, default: '9:41' }
},
}
</script>
@@ -0,0 +1,106 @@
<template>
<view class="sub-nav">
<view class="sub-nav__toolbar" :style="toolbarStyle">
<view class="sub-nav__nav">
<view class="sub-nav__back" @tap.stop="$emit('back')">
<image
class="sub-nav__back-icon"
src="/static/images/chevronleft.png"
mode="aspectFit"
/>
</view>
<text class="sub-nav__title">{{ title }}</text>
<view class="sub-nav__right">
<view
v-if="rightText"
class="sub-nav__action"
:class="{ 'sub-nav__action--button': actionButton }"
@tap.stop="$emit('right-action')"
>
<text class="sub-nav__action-text">{{ rightText }}</text>
</view>
<view class="sub-nav__capsule" :class="{ 'sub-nav__capsule--h5': isH5 }" :style="capsuleStyle"></view>
</view>
</view>
</view>
<view class="sub-nav__spacer" :style="toolbarSpacerStyle"></view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
title: { type: String, required: true },
rightText: { type: String, default: '' },
actionButton: { type: Boolean, default: false }
},
emits: ['back', 'right-action'],
data() {
return {
toolbarStyle: {},
toolbarSpacerStyle: {},
capsuleStyle: {},
isH5: false
}
},
mounted() {
this.syncNavSafeArea()
this.$nextTick(() => {
setTimeout(() => this.syncNavSafeArea(), 50)
})
},
methods: {
syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const extraGap = 4
const menu = uni.getMenuButtonBoundingClientRect?.()
// #ifdef H5
this.isH5 = true
// #endif
// #ifndef H5
this.isH5 = false
// #endif
if (!this.isH5 && typeof window !== 'undefined' && !menu?.width) {
this.isH5 = sys.uniPlatform === 'web' || sys.platform === 'web'
}
this.toolbarStyle = {
paddingTop: `${statusBarHeight}px`
}
this.toolbarSpacerStyle = {
height: `${statusBarHeight + navHeight + extraGap}px`
}
if (!this.isH5 && menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
this.capsuleStyle = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
} else {
this.capsuleStyle = {
width: '0px',
minWidth: '0px'
}
}
} catch (e) {
this.toolbarSpacerStyle = { height: '44px' }
this.isH5 = true
this.capsuleStyle = { width: '0px', minWidth: '0px' }
}
}
}
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-sub-nav.css';
</style>
+252 -2
View File
@@ -1,17 +1,267 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "健身房"
}
},
{
"path": "pages/course/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "课程"
}
},
{
"path": "pages/train/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "训练"
}
},
{
"path": "pages/discover/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "发现"
}
},
{
"path": "pages/memberInfo/memberInfo",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/memberInfo/memberCard",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的会员卡"
}
},
{
"path": "pages/memberInfo/userInfo",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/memberInfo/booking",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的预约"
}
},
{
"path": "pages/memberInfo/bodyTestHome",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "智能体测"
}
},
{
"path": "pages/memberInfo/bodyTestConnect",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "连接设备"
}
},
{
"path": "pages/memberInfo/bodyTestMeasuring",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "测量中"
}
},
{
"path": "pages/memberInfo/bodyTestReport",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "体测报告"
}
},
{
"path": "pages/memberInfo/bodyTestHistory",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "体测记录"
}
},
{
"path": "pages/memberInfo/bodyTestCompare",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "历史对比"
}
},
{
"path": "pages/memberInfo/bodyTestSettings",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "体测设置"
}
},
{
"path": "pages/memberInfo/bodyTestTrend",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "趋势分析"
}
},
{
"path": "pages/memberInfo/trainReport",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "训练报告"
}
},
{
"path": "pages/memberInfo/trainSessionDetail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "训练详情"
}
},
{
"path": "pages/memberInfo/coupons",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的优惠券"
}
},
{
"path": "pages/memberInfo/couponDetail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "优惠券详情"
}
},
{
"path": "pages/memberInfo/couponCenter",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "领券中心"
}
},
{
"path": "pages/memberInfo/points",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的积分"
}
},
{
"path": "pages/memberInfo/pointsMall",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "积分商城"
}
},
{
"path": "pages/memberInfo/pointsHistory",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "积分明细"
}
},
{
"path": "pages/memberInfo/referral",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "邀请好友"
}
},
{
"path": "pages/memberInfo/myCourses",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的课程"
}
},
{
"path": "pages/memberInfo/checkInHistory",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "签到记录"
}
},
{
"path": "pages/memberInfo/courseList",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "预约课程"
}
},
{
"path": "pages/memberInfo/courseDetail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "课程详情"
}
},
{
"path": "pages/memberInfo/onlineCourseDetail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "线上课程"
}
},
{
"path": "pages/memberInfo/courseEvaluate",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "课程评价"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarTitleText": "健身房",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"custom": true,
"color": "#94a3b8",
"selectedColor": "#f97316",
"backgroundColor": "#1A4A6F",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/images/home.png",
"selectedIconPath": "static/images/home.png"
},
{
"pagePath": "pages/course/index",
"text": "课程",
"iconPath": "static/images/bookmark.png",
"selectedIconPath": "static/images/bookmark.png"
},
{
"pagePath": "pages/train/index",
"text": "训练",
"iconPath": "static/images/dumbbell.png",
"selectedIconPath": "static/images/dumbbell.png"
},
{
"pagePath": "pages/discover/index",
"text": "发现",
"iconPath": "static/images/activity.png",
"selectedIconPath": "static/images/activity.png"
},
{
"pagePath": "pages/memberInfo/memberInfo",
"text": "我的",
"iconPath": "static/images/user.png",
"selectedIconPath": "static/images/user.png"
}
]
},
"uniIdRouter": {}
}
+97
View File
@@ -0,0 +1,97 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">课程</text>
<text class="tab-page__subtitle">精品团课 · 私教 · 线上课</text>
</view>
<RecommendCourses />
<view class="tab-page__actions">
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
<text class="tab-page__btn-text">预约课程</text>
</view>
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="1" />
</view>
</template>
<script setup>
import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
}
function goMyCourses() {
navigateToPage(PAGE.MY_COURSES)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.tab-page__actions {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.tab-page__btn {
flex: 1;
height: 80rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 100%);
display: flex;
align-items: center;
justify-content: center;
}
.tab-page__btn--ghost {
background: #fff;
border: 1px solid #e2e8f0;
}
.tab-page__btn-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
.tab-page__btn-text--ghost {
color: #1a4a6f;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">发现</text>
<text class="tab-page__subtitle">活动 · 资讯 · 今日推荐</text>
</view>
<TodayRecommend />
<view class="discover-links">
<view class="discover-link" hover-class="discover-link--hover" @tap="goReferral">
<text class="discover-link__title">邀请好友</text>
<text class="discover-link__desc">邀请注册/购课双方得积分</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goCouponCenter">
<text class="discover-link__title">领券中心</text>
<text class="discover-link__desc">限时优惠券先到先得</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goPointsMall">
<text class="discover-link__title">积分商城</text>
<text class="discover-link__desc">积分兑换好礼</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="3" />
</view>
</template>
<script setup>
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goReferral() {
navigateToPage(PAGE.REFERRAL)
}
function goCouponCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
function goPointsMall() {
navigateToPage(PAGE.POINTS_MALL)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.discover-links {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 24rpx 32rpx;
}
.discover-link {
padding: 24rpx 28rpx;
border-radius: 20rpx;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.discover-link__title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #1e2a3a;
}
.discover-link__desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #64748b;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@
<view class="bottom-placeholder"></view>
<!-- 底部导航 -->
<TabBar />
<TabBar :active="0" />
</view>
</template>
@@ -0,0 +1,162 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="历史对比" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">选择对比记录</text>
<view class="bt-compare-header">
<view
class="bt-compare-picker"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="pickRecord('a')"
>
<text class="bt-compare-picker__label">记录 A较新</text>
<text class="bt-compare-picker__date">{{ recordA?.date || '点击选择' }}</text>
</view>
<view
class="bt-compare-picker"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="pickRecord('b')"
>
<text class="bt-compare-picker__label">记录 B较旧</text>
<text class="bt-compare-picker__date">{{ recordB?.date || '点击选择' }}</text>
</view>
</view>
</view>
<view v-if="compareData" class="bt-card">
<text class="bt-card__title">指标对比</text>
<view class="bt-compare-row">
<text class="bt-compare-row__label">指标</text>
<text class="bt-compare-row__val">A</text>
<text class="bt-compare-row__val">B</text>
<text class="bt-compare-row__diff">差值</text>
</view>
<view
v-for="row in compareData.metrics"
:key="row.key"
class="bt-compare-row"
>
<text class="bt-compare-row__label">{{ row.label }}</text>
<text class="bt-compare-row__val">{{ row.valueA }}</text>
<text class="bt-compare-row__val">{{ row.valueB }}</text>
<text
class="bt-compare-row__diff"
:style="{ color: diffColor(row) }"
>
{{ formatDiff(row.diff, row.key) }}
</text>
</view>
</view>
<view v-if="compareData" class="bt-card">
<text class="bt-card__title">评分变化</text>
<view class="bt-metrics">
<view class="bt-metric">
<text class="bt-metric__value">{{ compareData.recordA.score }}</text>
<text class="bt-metric__label">记录 A 评分</text>
</view>
<view class="bt-metric">
<text class="bt-metric__value">{{ compareData.recordB.score }}</text>
<text class="bt-metric__label">记录 B 评分</text>
</view>
</view>
<text class="bt-card__desc" style="margin-top: 12px;">
综合评分变化 {{ scoreDiff > 0 ? '+' : '' }}{{ scoreDiff }}
{{ scoreDiff > 0 ? ',整体趋势向好' : scoreDiff < 0 ? ',建议加强训练' : '' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getCompareData } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
records: [],
recordA: null,
recordB: null,
compareData: null
}
},
computed: {
scoreDiff() {
if (!this.compareData) return 0
return this.compareData.recordA.score - this.compareData.recordB.score
}
},
onShow() {
const store = loadMemberStore()
this.records = getBodyTestHistory(store)
if (this.records.length >= 2 && !this.recordA) {
this.recordA = this.records[0]
this.recordB = this.records[1]
this.refreshCompare()
}
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HISTORY)
},
pickRecord(which) {
const labels = this.records.map(
(r) => `${r.date} · ${r.score}分 · ${r.gradeLabel}`
)
uni.showActionSheet({
itemList: labels,
success: (res) => {
const picked = this.records[res.tapIndex]
if (which === 'a') this.recordA = picked
else this.recordB = picked
this.refreshCompare()
}
})
},
refreshCompare() {
if (!this.recordA || !this.recordB) {
this.compareData = null
return
}
if (this.recordA.id === this.recordB.id) {
uni.showToast({ title: '请选择两条不同记录', icon: 'none' })
this.compareData = null
return
}
const store = loadMemberStore()
this.compareData = getCompareData(store, this.recordA.id, this.recordB.id)
},
formatDiff(diff, key) {
const sign = diff > 0 ? '+' : ''
const units = { bodyFat: '%', weight: '', bmi: '', muscleMass: '', visceralFat: '', bmr: '' }
return `${sign}${diff}${units[key] || ''}`
},
diffColor(row) {
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(row.key)
const good = lowerBetter ? row.diff < 0 : row.diff > 0
if (row.diff === 0) return '#8A99B4'
return good ? '#2ECC71' : '#F39C12'
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,146 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="连接设备" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ device.name }}</text>
<text class="bt-card__desc">型号 {{ device.model }} · 请按以下步骤完成蓝牙配对</text>
</view>
<view class="bt-card">
<text class="bt-card__title">连接引导</text>
<view class="bt-steps">
<view
v-for="step in steps"
:key="step.step"
class="bt-step"
>
<view class="bt-step__num">{{ step.step }}</view>
<view class="bt-step__content">
<text class="bt-step__title">{{ step.title }}</text>
<text class="bt-step__desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
<view v-if="searching" class="bt-card">
<view class="bt-measure">
<text class="bt-measure__hint">正在搜索附近设备</text>
<text class="bt-measure__hint">{{ searchHint }}</text>
</view>
</view>
<view v-if="connected" class="bt-card">
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/shield.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">连接成功</text>
<text class="bt-device__status bt-device__status--on">
{{ device.name }} · 电量 {{ device.battery }}%
</text>
</view>
<view class="bt-device__dot bt-device__dot--on"></view>
</view>
</view>
<view class="bt-footer-actions">
<view
v-if="!connected"
class="bt-btn bt-btn--primary"
:class="{ 'bt-btn--outline': searching }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="searchDevice"
>
<text class="bt-btn__text">{{ searching ? '搜索中…' : '搜索并连接' }}</text>
</view>
<view
v-else
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="goMeasuring"
>
<text class="bt-btn__text">开始测量</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
connectBodyTestDevice,
bodyTestMock
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
device: {},
steps: bodyTestMock.connectSteps,
searching: false,
connected: false,
searchHint: '请保持手机蓝牙已开启'
}
},
onShow() {
const store = loadMemberStore()
this.device = { ...store.bodyTest.device }
this.connected = store.bodyTest.device.connected
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
searchDevice() {
if (this.searching) return
this.searching = true
this.searchHint = '发现 InBody 270…'
setTimeout(() => {
const store = loadMemberStore()
connectBodyTestDevice(store)
persistMemberStore(store)
this.device = { ...store.bodyTest.device }
this.connected = true
this.searching = false
uni.showToast({ title: '设备已连接', icon: 'success' })
}, 1800)
},
goMeasuring() {
navigateToPage(PAGE.BODY_TEST_MEASURING)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.bt-footer-actions .bt-btn--primary.bt-btn--outline {
background: var(--bg-gray, #F2F5F9);
box-shadow: none;
}
.bt-footer-actions .bt-btn--primary.bt-btn--outline .bt-btn__text {
color: var(--text-muted, #5E6F8D);
}
</style>
@@ -0,0 +1,210 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
<view class="mi-mod-tabs">
<view
v-for="y in years"
:key="y"
class="bt-tab"
:class="{ 'bt-tab--active': activeYear === y }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeYear = y"
>
<text class="bt-tab__text">{{ y === 'all' ? '全部' : y + '年' }}</text>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCompare"
>
历史对比
</text>
</view>
<view class="bt-page__body">
<view
v-for="(item, index) in records"
:key="item.id"
class="mi-timeline-card"
hover-class="mi-tap-row--hover"
@tap="viewReport(item)"
>
<view class="mi-timeline-card__line">
<view class="mi-timeline-card__dot"></view>
<view v-if="index < records.length - 1" class="mi-timeline-card__bar"></view>
</view>
<view class="mi-timeline-card__content">
<view class="mi-timeline-card__head">
<text class="mi-timeline-card__date">{{ item.date }} {{ item.time }}</text>
<text class="mi-timeline-card__score">{{ item.score }}</text>
</view>
<text class="mi-timeline-card__grade">{{ item.grade }} {{ item.gradeLabel }} · {{ item.status }}</text>
<text class="mi-timeline-card__metrics">
体脂 {{ item.metrics.bodyFat }}% · 肌肉 {{ item.metrics.muscleMass }}kg · BMI {{ item.metrics.bmi }}
</text>
<view v-if="item.changeBadge" class="mi-timeline-card__badge" :class="item.changeBadge.good ? 'mi-timeline-card__badge--good' : 'mi-timeline-card__badge--warn'">
<text>{{ item.changeBadge.text }}</text>
</view>
</view>
</view>
<view v-if="!records.length" class="bt-empty">
<text class="bt-empty__text">暂无体测报告</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getBodyTestYears, getBodyTestChangeBadge } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { activeYear: 'all', years: [], allRecords: [] }
},
computed: {
records() {
const list = this.activeYear === 'all'
? this.allRecords
: this.allRecords.filter((r) => r.date.startsWith(this.activeYear))
return list.map((item, index) => {
const previous = list[index + 1]
return {
...item,
changeBadge: getBodyTestChangeBadge(item, previous)
}
})
}
},
watch: {
activeYear() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
loadList() {
const store = loadMemberStore()
this.years = getBodyTestYears(store)
this.allRecords = getBodyTestHistory(store)
},
onBack() { goBackOrTab(PAGE.MEMBER) },
viewReport(item) {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${item.id}`)
},
goCompare() {
navigateToPage(PAGE.BODY_TEST_COMPARE)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-timeline-card {
display: flex;
flex-direction: row;
gap: 12px;
margin-bottom: 4px;
}
.mi-timeline-card__line {
width: 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.mi-timeline-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-orange, #FF6B35);
flex-shrink: 0;
}
.mi-timeline-card__bar {
flex: 1;
width: 2px;
background: var(--border-light, #E9EDF2);
margin: 4px 0;
}
.mi-timeline-card__content {
flex: 1;
padding: 12px 14px;
margin-bottom: 10px;
border-radius: 14px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
}
.mi-timeline-card__head {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.mi-timeline-card__date {
font-size: 13px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-timeline-card__score {
font-size: 18px;
font-weight: 800;
color: var(--primary-dark, #0B2B4B);
}
.mi-timeline-card__grade {
font-size: 12px;
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-timeline-card__metrics {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 4px;
display: block;
}
.mi-timeline-card__badge {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
border-radius: 6px;
}
.mi-timeline-card__badge--good {
background: rgba(46, 204, 113, 0.12);
}
.mi-timeline-card__badge--good text {
font-size: 10px;
color: var(--success-green, #2ECC71);
font-weight: 600;
}
.mi-timeline-card__badge--warn text {
font-size: 10px;
color: var(--warning-amber, #F39C12);
font-weight: 600;
}
</style>
@@ -0,0 +1,196 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="智能体测" @back="goBack" />
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goSettings"
>
体测设置
</text>
</view>
<view class="bt-page__body">
<view class="bt-hero">
<view class="bt-hero__top">
<text class="bt-hero__label">最新体测评分</text>
<view class="bt-hero__badge">
<text class="bt-hero__badge-text">{{ latest?.status || '暂无数据' }}</text>
</view>
</view>
<view class="bt-hero__score-row">
<text class="bt-hero__score">{{ latest?.score ?? '--' }}</text>
<text class="bt-hero__grade">{{ latest?.grade ?? '' }} {{ latest?.gradeLabel ?? '' }}</text>
</view>
<text class="bt-hero__meta">
{{ latest ? `最近测量 · ${latest.date} ${latest.time}` : '完成首次体测,获取健康画像' }}
</text>
<view class="bt-hero__actions">
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="startMeasure"
>
<image class="bt-btn__icon" src="/static/images/activity.png" mode="aspectFit" />
<text class="bt-btn__text">开始体测</text>
</view>
<view
v-if="latest"
class="bt-btn bt-btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="viewLatestReport"
>
<text class="bt-btn__text">查看报告</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">设备状态</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/mappin2.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text
class="bt-device__status"
:class="{ 'bt-device__status--on': device.connected }"
>
{{ deviceStatusText }}
</text>
</view>
<view
class="bt-device__dot"
:class="{ 'bt-device__dot--on': device.connected }"
></view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">快捷入口</text>
<view class="bt-grid">
<view
v-for="item in quickLinks"
:key="item.key"
class="bt-grid__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="onQuickLink(item.key)"
>
<image class="bt-grid__icon" :src="item.icon" mode="aspectFit" />
<text class="bt-grid__label">{{ item.label }}</text>
</view>
</view>
</view>
<view v-if="latest" class="bt-card">
<text class="bt-card__title">核心指标概览</text>
<view class="bt-metrics">
<view
v-for="m in previewMetrics"
:key="m.key"
class="bt-metric"
>
<text class="bt-metric__value">{{ m.value }}</text>
<text class="bt-metric__label">{{ m.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getLatestBodyTestRecord } from '@/common/memberInfo/bodyTestStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
latest: null,
device: {},
quickLinks: [
{ key: 'history', label: '历史记录', icon: '/static/images/clock.png' },
{ key: 'compare', label: '历史对比', icon: '/static/images/trendingdown.png' },
{ key: 'trend', label: '趋势分析', icon: '/static/images/activity.png' },
{ key: 'report', label: '体测报告', icon: '/static/images/filetext.png' }
]
}
},
computed: {
deviceStatusText() {
if (this.device.connected) {
return `已连接 · 电量 ${this.device.battery}%`
}
return '未连接 · 点击开始体测进行配对'
},
previewMetrics() {
if (!this.latest?.metrics) return []
const m = this.latest.metrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight },
{ key: 'bmi', label: 'BMI', value: m.bmi },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass }
]
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.latest = getLatestBodyTestRecord(store)
this.device = { ...store.bodyTest.device }
},
startMeasure() {
const store = loadMemberStore()
if (store.bodyTest.device.connected) {
navigateToPage(PAGE.BODY_TEST_MEASURING)
} else {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}
},
viewLatestReport() {
if (!this.latest) return
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`)
},
goSettings() {
navigateToPage(PAGE.BODY_TEST_SETTINGS)
},
onQuickLink(key) {
const routes = {
history: PAGE.BODY_TEST_HISTORY,
compare: PAGE.BODY_TEST_COMPARE,
trend: PAGE.BODY_TEST_TREND,
report: this.latest
? `${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`
: PAGE.BODY_TEST_HISTORY
}
navigateToPage(routes[key] || PAGE.BODY_TEST_HOME)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,169 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="测量中" @back="onCancel" />
<view class="bt-page__body">
<view class="bt-card">
<view class="bt-measure">
<view class="bt-measure__ring-wrap">
<view class="bt-measure__ring-bg"></view>
<view
class="bt-measure__ring-fill"
:style="{ transform: `rotate(${ringRotation}deg)` }"
></view>
<view class="bt-measure__center">
<text class="bt-measure__percent">{{ progress }}%</text>
<text class="bt-measure__hint">{{ phaseHint }}</text>
</view>
</view>
<text class="bt-card__desc">{{ statusText }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">实时数据</text>
<view class="bt-measure__live">
<view
v-for="item in liveDisplay"
:key="item.key"
class="bt-measure__live-item"
>
<text class="bt-measure__live-value">{{ item.value }}</text>
<text class="bt-measure__live-label">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
interpolateMeasuringMetrics,
saveSimulatedBodyTestRecord
} from '@/common/memberInfo/bodyTestStore.js'
const PHASES = [
{ until: 20, hint: '校准中', text: '请保持站立姿势,双手自然下垂' },
{ until: 50, hint: '阻抗测量', text: '请勿移动,正在进行生物电阻抗分析' },
{ until: 80, hint: '数据分析', text: '正在计算体脂与肌肉分布' },
{ until: 100, hint: '即将完成', text: '生成健康报告中…' }
]
export default {
components: { MemberInfoSubNav },
data() {
return {
progress: 0,
liveMetrics: {},
timer: null,
finished: false
}
},
computed: {
ringRotation() {
return -90 + (this.progress / 100) * 360
},
phaseHint() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.hint || '完成'
},
statusText() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.text || '测量完成'
},
liveDisplay() {
const m = this.liveMetrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight ?? '--' },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat ?? '--' },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass ?? '--' },
{ key: 'bmr', label: '基础代谢', value: m.bmr ?? '--' }
]
}
},
onLoad() {
const store = loadMemberStore()
if (!store.bodyTest.device.connected) {
uni.showToast({ title: '请先连接设备', icon: 'none' })
setTimeout(() => {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}, 800)
return
}
this.startMeasurement()
},
onUnload() {
this.clearTimer()
},
methods: {
clearTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
startMeasurement() {
const store = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(0, store.profile)
this.timer = setInterval(() => {
if (this.progress >= 100) {
this.completeMeasurement()
return
}
this.progress = Math.min(100, this.progress + 2)
const s = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(this.progress, s.profile)
}, 120)
},
completeMeasurement() {
if (this.finished) return
this.finished = true
this.clearTimer()
const store = loadMemberStore()
const record = saveSimulatedBodyTestRecord(store, {
...this.liveMetrics,
visceralFat: 6,
boneMass: 2.42,
bodyWater: this.liveMetrics.bodyWater || 52.8,
protein: 16.4
})
persistMemberStore(store)
uni.showToast({ title: '测量完成', icon: 'success' })
setTimeout(() => {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${record.id}&new=1`)
}, 600)
},
onCancel() {
if (this.finished) return
uni.showModal({
title: '取消测量',
content: '确定要中断当前体测吗?',
success: (res) => {
if (res.confirm) {
this.clearTimer()
goBackOrTab(PAGE.BODY_TEST_HOME)
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,315 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
<view v-if="record" class="bt-page__body">
<view class="bt-score-card">
<view class="bt-score-card__circle">
<text class="bt-score-card__num">{{ record.score }}</text>
<text class="bt-score-card__grade">{{ record.grade }}</text>
</view>
<view class="bt-score-card__info">
<text class="bt-score-card__title">{{ record.gradeLabel }} · {{ record.status }}</text>
<text class="bt-score-card__date">{{ record.date }} {{ record.time }}</text>
<text v-if="record.bodyAge" class="bt-score-card__date">
身体年龄 {{ record.bodyAge }} · 实际 {{ record.realAge }}
</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">核心指标</text>
<view class="bt-metrics">
<view
v-for="m in metricCards"
:key="m.key"
class="bt-metric"
>
<text class="bt-metric__value">{{ m.display }}</text>
<text class="bt-metric__label">{{ m.label }}</text>
<text
v-if="m.changeText"
class="bt-metric__change"
:class="m.changeClass"
>
较上次 {{ m.changeText }}
</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">体成分雷达图</text>
<BodyTestRadarChart
:labels="radarLabels"
:values="radarValues"
:width="chartWidth"
:height="220"
/>
</view>
<view class="bt-card">
<text class="bt-card__title">人体成分分布</text>
<view class="bt-body-map">
<view class="bt-body-map__figure">
<view class="bt-body-map__head"></view>
<view class="bt-body-map__limbs">
<view class="bt-body-map__arm"></view>
<view class="bt-body-map__arm"></view>
</view>
<view class="bt-body-map__torso"></view>
<view class="bt-body-map__legs">
<view class="bt-body-map__leg"></view>
<view class="bt-body-map__leg"></view>
</view>
</view>
<view class="bt-body-map__segments">
<view
v-for="seg in record.bodySegments"
:key="seg.part"
class="bt-body-map__seg"
:class="'bt-body-map__seg--' + seg.level"
>
<text class="bt-body-map__seg-name">{{ seg.part }}</text>
<text class="bt-body-map__seg-val">{{ seg.value }}</text>
</view>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">指标变化趋势</text>
<BodyTestTrendChart
:points="trendPreview"
unit="kg"
:width="chartWidth"
:height="140"
/>
<view
class="bt-trend-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goTrend"
>
<text class="bt-trend-link__text">查看完整趋势分析</text>
<image
class="bt-trend-link__arrow"
src="/static/images/chevronright3.png"
mode="aspectFit"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">健康建议</text>
<view class="bt-advice-list">
<view
v-for="(tip, idx) in record.advice"
:key="idx"
class="bt-advice-item"
>
<view class="bt-advice-item__dot"></view>
<text class="bt-advice-item__text">{{ tip }}</text>
</view>
</view>
</view>
<view v-if="courses.length" class="bt-card">
<text class="bt-card__title">推荐课程</text>
<view
v-for="course in courses"
:key="course.id"
class="bt-course"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="onCourseTap(course)"
>
<image class="bt-course__banner" :src="course.banner" mode="aspectFill" />
<view class="bt-course__info">
<text class="bt-course__tag">{{ course.tag }}</text>
<text class="bt-course__title">{{ course.title }}</text>
<text class="bt-course__meta">{{ course.coach }} · {{ course.schedule }}</text>
</view>
</view>
</view>
<view class="bt-footer-actions">
<view
class="bt-btn bt-btn--outline"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="exportReport"
>
<image class="bt-btn__icon" src="/static/images/filetext.png" mode="aspectFit" />
<text class="bt-btn__text">导出 PDF</text>
</view>
<view
class="bt-btn bt-btn--outline"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="shareReport"
>
<image class="bt-btn__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-btn__text">分享</text>
</view>
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="retest"
>
<text class="bt-btn__text">再次体测</text>
</view>
</view>
</view>
<view v-else class="bt-empty">
<text class="bt-empty__text">未找到体测报告</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestRadarChart from '@/components/memberInfo/BodyTestRadarChart.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import {
getBodyTestRecordById,
getBodyTestTrendData,
getRecommendedCourses,
computeChanges,
formatChangeValue,
bodyTestMock
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestRadarChart, BodyTestTrendChart },
data() {
return {
recordId: null,
record: null,
previous: null,
chartWidth: 300
}
},
computed: {
radarLabels() {
return bodyTestMock.radarLabels.map((l) => l.label)
},
radarValues() {
if (!this.record?.radar) return []
const keys = bodyTestMock.radarLabels.map((l) => l.key)
return keys.map((k) => this.record.radar[k] || 0)
},
metricCards() {
if (!this.record?.metrics) return []
const changes = this.changes
const defs = bodyTestMock.metricDefs.slice(0, 8)
return defs.map((def) => {
const val = this.record.metrics[def.key]
const diff = changes[def.key]
let changeText = ''
let changeClass = ''
if (diff !== undefined && diff !== null) {
changeText = formatChangeValue(def.key, diff)
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(def.key)
const isGood = lowerBetter ? diff < 0 : diff > 0
changeClass = isGood ? 'bt-metric__change--down' : 'bt-metric__change--up'
}
const display = def.unit ? `${val}${def.unit === '%' ? '%' : def.unit === 'kg' ? '' : ` ${def.unit}`}` : val
return {
key: def.key,
label: def.label + (def.unit && def.unit !== '%' && def.unit !== 'kg' ? `(${def.unit})` : def.unit === 'kg' ? '(kg)' : def.unit === '%' ? '(%)' : ''),
display: def.key === 'bodyFat' ? `${val}%` : def.key === 'bodyWater' ? `${val}%` : def.unit === 'kg' ? val : def.unit ? `${val}` : val,
changeText,
changeClass
}
})
},
changes() {
if (this.record?.changes) return this.record.changes
if (this.previous) return computeChanges(this.record, this.previous)
return {}
},
trendPreview() {
const store = loadMemberStore()
return getBodyTestTrendData(store, 'weight', 4)
},
courses() {
return getRecommendedCourses(this.record)
}
},
onLoad(options) {
this.recordId = options?.id ? Number(options.id) : null
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadRecord()
},
methods: {
loadRecord() {
const store = loadMemberStore()
const records = store.bodyTest.records
if (this.recordId) {
this.record = getBodyTestRecordById(store, this.recordId)
} else {
this.record = records.length ? { ...records[0] } : null
}
if (this.record) {
const idx = records.findIndex((r) => r.id === this.record.id)
this.previous = idx >= 0 && records[idx + 1] ? records[idx + 1] : null
}
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
goTrend() {
navigateToPage(`${PAGE.BODY_TEST_TREND}?metric=weight`)
},
exportReport() {
uni.showLoading({ title: '生成中…' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '报告已保存到相册', icon: 'success' })
}, 1200)
},
shareReport() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成分享海报', '复制报告链接'],
success: (res) => {
const msgs = ['已唤起微信分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
},
onCourseTap(course) {
uni.showModal({
title: course.title,
content: `${course.coach}\n${course.schedule}\n\n是否前往预约?`,
success: (res) => {
if (res.confirm) {
navigateToPage(PAGE.COURSE_LIST)
}
}
})
},
retest() {
navigateToPage(PAGE.BODY_TEST_HOME)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.bt-footer-actions .bt-btn {
flex: 1;
}
</style>
@@ -0,0 +1,182 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测设置" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">连接与同步</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">蓝牙自动连接</text>
<text class="bt-setting__desc">进入体测页时自动搜索已配对设备</text>
</view>
<switch
:checked="settings.bluetoothEnabled"
color="#FF6B35"
@change="onSwitch('bluetoothEnabled', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成自动同步</text>
<text class="bt-setting__desc">结果自动保存至云端与本地</text>
</view>
<switch
:checked="settings.autoSync"
color="#FF6B35"
@change="onSwitch('autoSync', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">通知与隐私</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成通知</text>
<text class="bt-setting__desc">体测结束后推送报告摘要</text>
</view>
<switch
:checked="settings.notifyOnComplete"
color="#FF6B35"
@change="onSwitch('notifyOnComplete', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">分享时匿名化</text>
<text class="bt-setting__desc">隐藏姓名与手机号</text>
</view>
<switch
:checked="settings.shareAnonymous"
color="#FF6B35"
@change="onSwitch('shareAnonymous', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">单位制</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">度量单位</text>
<text class="bt-setting__desc">{{ unitLabel }}</text>
</view>
<view
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="toggleUnit"
>
<text style="font-size: 14px; color: #1A4A6F; font-weight: 600;">切换</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">设备管理</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="/static/images/mappin2.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text class="bt-device__status">
{{ device.connected ? '已连接' : '未连接' }}
· 上次 {{ device.lastConnected || '--' }}
</text>
</view>
</view>
<view
class="bt-btn bt-btn--outline"
style="margin-top: 12px;"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="disconnect"
>
<text class="bt-btn__text">解除设备配对</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import {
loadMemberStore,
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
updateBodyTestSettings,
disconnectBodyTestDevice
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
settings: {},
device: {}
}
},
computed: {
unitLabel() {
return this.settings.unitSystem === 'imperial' ? '英制 (lb / in)' : '公制 (kg / cm)'
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.settings = { ...store.bodyTest.settings }
this.device = { ...store.bodyTest.device }
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
onSwitch(key, e) {
const store = loadMemberStore()
updateBodyTestSettings(store, { [key]: e.detail.value })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: '已保存', icon: 'success' })
},
toggleUnit() {
const store = loadMemberStore()
const next = this.settings.unitSystem === 'metric' ? 'imperial' : 'metric'
updateBodyTestSettings(store, { unitSystem: next })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: `已切换为${this.unitLabel}`, icon: 'none' })
},
disconnect() {
uni.showModal({
title: '解除配对',
content: '解除后下次体测需重新连接设备,确定继续?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
disconnectBodyTestDevice(store)
persistMemberStore(store)
this.refreshFromStore()
uni.showToast({ title: '已解除配对', icon: 'success' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,151 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="趋势分析" @back="onBack" />
<view class="bt-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeMetric === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="switchMetric(tab.key)"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ activeLabel }}变化趋势</text>
<text class="bt-card__desc">基于最近 {{ trendPoints.length }} 次体测数据</text>
<BodyTestTrendChart
:points="trendPoints"
:unit="activeUnit"
:width="chartWidth"
:height="200"
/>
</view>
<view class="bt-card">
<text class="bt-card__title">数据明细</text>
<view
v-for="(pt, idx) in trendPointsReversed"
:key="pt.id"
class="bt-compare-row"
>
<text class="bt-compare-row__label">{{ pt.date }}</text>
<text class="bt-compare-row__val">{{ pt.value }}{{ activeUnit }}</text>
<text
v-if="idx < trendPointsReversed.length - 1"
class="bt-compare-row__diff"
:style="{ color: rowDiffColor(pt, trendPointsReversed[idx + 1]) }"
>
{{ rowDiffText(pt, trendPointsReversed[idx + 1]) }}
</text>
<text v-else class="bt-compare-row__diff">--</text>
</view>
</view>
<view v-if="summaryText" class="bt-card">
<text class="bt-card__title">趋势解读</text>
<text class="bt-card__desc">{{ summaryText }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestTrendData, bodyTestMock } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
data() {
return {
tabs: bodyTestMock.trendMetrics,
activeMetric: 'weight',
trendPoints: [],
chartWidth: 300
}
},
computed: {
activeLabel() {
return this.tabs.find((t) => t.key === this.activeMetric)?.label || ''
},
activeUnit() {
const units = {
weight: 'kg',
bodyFat: '%',
muscleMass: 'kg',
bmi: ''
}
return units[this.activeMetric] || ''
},
trendPointsReversed() {
return [...this.trendPoints].reverse()
},
summaryText() {
if (this.trendPoints.length < 2) return ''
const first = this.trendPoints[0].value
const last = this.trendPoints[this.trendPoints.length - 1].value
const diff = Math.round((last - first) * 10) / 10
const sign = diff > 0 ? '上升' : diff < 0 ? '下降' : '持平'
const abs = Math.abs(diff)
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
let advice = ''
if (diff !== 0) {
const good = lowerBetter ? diff < 0 : diff > 0
advice = good ? ',变化方向符合健康目标' : ',建议关注饮食与训练计划'
}
return `期间${this.activeLabel}${sign} ${abs}${this.activeUnit}${advice}`
}
},
onLoad(options) {
if (options?.metric) {
this.activeMetric = options.metric
}
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadTrend()
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
switchMetric(key) {
this.activeMetric = key
this.loadTrend()
},
loadTrend() {
const store = loadMemberStore()
this.trendPoints = getBodyTestTrendData(store, this.activeMetric, 6)
},
rowDiffText(current, older) {
const diff = Math.round((current.value - older.value) * 10) / 10
const sign = diff > 0 ? '+' : ''
return `${sign}${diff}${this.activeUnit}`
},
rowDiffColor(current, older) {
const diff = current.value - older.value
if (diff === 0) return '#8A99B4'
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
const good = lowerBetter ? diff < 0 : diff > 0
return good ? '#2ECC71' : '#F39C12'
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,209 @@
<template>
<view class="scroll-container theme-light">
<view class="booking-page">
<MemberInfoSubNav title="我的预约" @back="goBack" />
<view class="booking-page__tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="booking-page__tab"
:class="{ 'booking-page__tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="setActiveTab(tab.key)"
>
<text
class="booking-page__tab-text"
:class="{ 'booking-page__tab-text--active': activeTab === tab.key }"
>
{{ tab.label }}
</text>
<view
v-if="activeTab === tab.key"
class="booking-page__tab-indicator"
></view>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCourseList"
>
预约课程
</text>
</view>
<view class="booking-page__body">
<view
v-if="activeTab === 'ongoing' && upcomingAlert"
class="booking-page__alert"
>
<image
class="booking-page__alert-icon"
src="/static/images/clock1.png"
mode="aspectFit"
/>
<text class="booking-page__alert-text">{{ upcomingAlert }}</text>
</view>
<view
v-for="item in displayedBookings"
:key="item.id"
class="bk-card"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
>
<image
class="bk-card__banner"
:src="item.banner"
mode="aspectFill"
/>
<view class="bk-card__content">
<view class="bk-card__header">
<text class="bk-card__title">{{ item.title }}</text>
<view
class="bk-card__status"
:class="'bk-card__status--' + item.status"
>
<text
class="bk-card__status-text"
:class="'bk-card__status-text--' + item.status"
>
{{ item.statusLabel }}
</text>
</view>
</view>
<view class="bk-card__meta">
<view class="bk-card__meta-row">
<image
class="bk-card__meta-icon"
src="/static/images/clock0.png"
mode="aspectFit"
/>
<text class="bk-card__meta-text">{{ item.schedule }}</text>
</view>
<view class="bk-card__meta-row">
<image
class="bk-card__meta-icon"
src="/static/images/user0.png"
mode="aspectFit"
/>
<text class="bk-card__meta-text">{{ item.coach }}</text>
</view>
</view>
<view class="bk-card__footer">
<text class="bk-card__footer-info">{{ item.footerText }}</text>
<view
v-if="item.canCancel"
class="bk-card__cancel"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="cancelBooking(item)"
>
<text class="bk-card__cancel-text">取消预约</text>
</view>
</view>
</view>
</view>
<view v-if="!displayedBookings.length" class="booking-page__empty">
<text class="booking-page__empty-text">
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { bookingMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
cancelOngoingBooking,
formatUpcomingAlert
} from '@/common/memberInfo/store.js'
import { canCancelBooking } from '@/common/memberInfo/bookingStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: bookingMock.tabs,
ongoingBookings: [],
historyBookings: [],
activeTab: 'ongoing'
}
},
computed: {
displayedBookings() {
return this.activeTab === 'ongoing'
? this.ongoingBookings
: this.historyBookings
},
upcomingAlert() {
return formatUpcomingAlert(this.ongoingBookings[0])
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.ongoingBookings = store.ongoingBookings.map((item) => ({
...item,
canCancel: canCancelBooking(item)
}))
this.historyBookings = store.historyBookings.map((item) => ({ ...item }))
},
goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
},
setActiveTab(tab) {
this.activeTab = tab
},
cancelBooking(item) {
if (!canCancelBooking(item)) {
uni.showToast({ title: '距开课不足2小时,无法取消', icon: 'none' })
return
}
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = cancelOngoingBooking(store, item.id)
if (!result.ok) {
uni.showToast({ title: result.message, icon: 'none' })
return
}
this.refreshFromStore()
uni.showToast({ title: '已取消', icon: 'success' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/booking-page.css';
</style>
@@ -0,0 +1,96 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="签到记录" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeFilter === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeFilter = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title"> {{ filteredList.length }} 条记录</text>
<view
v-for="item in filteredList"
:key="item.id"
class="mi-mod-checkin-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="showDetail(item)"
>
<view class="mi-mod-checkin-row__icon" :class="'mi-mod-checkin-row__icon--' + item.tagTheme">
<image class="mi-mod-checkin-row__icon-img" src="/static/images/usercheck.png" mode="aspectFit" />
</view>
<view class="mi-mod-checkin-row__info">
<text class="mi-mod-checkin-row__title">{{ item.title }}</text>
<text class="mi-mod-checkin-row__time">{{ item.time }} · {{ item.location }}</text>
</view>
<view class="mi-mod-checkin-row__tag" :class="'mi-mod-checkin-row__tag--' + item.tagTheme">
<text class="mi-mod-checkin-row__tag-text">{{ item.tag }}</text>
</view>
</view>
<view v-if="!filteredList.length" class="bt-empty">
<text class="bt-empty__text">暂无签到记录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { moduleMock, getCheckInHistory } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.checkInTabs,
activeFilter: 'all',
list: []
}
},
computed: {
filteredList() {
if (this.activeFilter === 'all') return this.list
return this.list.filter((i) => i.tagTheme === this.activeFilter)
}
},
onShow() {
const store = loadMemberStore()
this.list = getCheckInHistory(store, 'all')
},
methods: {
showDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n地点:${item.location}\n类型:${item.tag}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,134 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="领券中心" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">可领取优惠券</text>
<text class="bt-card__desc">领取后自动放入可用列表</text>
</view>
<view
v-for="item in list"
:key="item.id"
class="mi-center-coupon"
>
<view class="mi-center-coupon__info">
<text class="mi-center-coupon__amount">¥{{ item.amount }}</text>
<view>
<text class="mi-center-coupon__title">{{ item.title }}</text>
<text class="mi-center-coupon__desc">{{ item.desc }} · {{ item.expireDays }}天有效</text>
</view>
</view>
<view
class="mi-center-coupon__btn"
:class="{ 'mi-center-coupon__btn--done': item.claimed }"
hover-class="mi-tap-btn--hover"
@tap="claim(item)"
>
<text>{{ item.claimed ? '已领取' : '立即领取' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getCouponCenterList, claimCouponFromCenter } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { list: [] }
},
onShow() {
const store = loadMemberStore()
this.list = getCouponCenterList(store)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
claim(item) {
if (item.claimed) return
const store = loadMemberStore()
const result = claimCouponFromCenter(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
this.list = getCouponCenterList(store)
}
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-center-coupon {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
margin-bottom: 10px;
border-radius: 16px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
}
.mi-center-coupon__info {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
flex: 1;
}
.mi-center-coupon__amount {
font-size: 28px;
font-weight: 800;
color: var(--accent-orange, #FF6B35);
}
.mi-center-coupon__title {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-center-coupon__desc {
font-size: 11px;
color: var(--text-muted, #5E6F8D);
display: block;
margin-top: 2px;
}
.mi-center-coupon__btn {
padding: 8px 14px;
border-radius: 999px;
}
.mi-center-coupon__btn text {
font-size: 12px;
font-weight: 600;
color: #fff;
}
.mi-center-coupon__btn--done {
background: var(--bg-gray, #F2F5F9);
}
.mi-center-coupon__btn--done text {
color: var(--text-light, #8A99B4);
}
</style>
@@ -0,0 +1,74 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="coupon">
<MemberInfoSubNav title="优惠券详情" @back="onBack" />
<view class="bt-page__body">
<view class="mi-mod-coupon mi-mod-coupon--detail">
<view class="mi-mod-coupon__left">
<text class="mi-mod-coupon__amount">¥{{ coupon.amount }}</text>
<text class="mi-mod-coupon__min">{{ coupon.minSpend }}可用</text>
</view>
<view class="mi-mod-coupon__right">
<text class="mi-mod-coupon__title">{{ coupon.title }}</text>
<text class="mi-mod-coupon__desc">{{ coupon.desc }}</text>
<text class="mi-mod-coupon__expire">有效期至 {{ coupon.expire }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">使用规则</text>
<text class="bt-card__desc" style="white-space: pre-line;">{{ coupon.rules }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">适用范围</text>
<text class="bt-card__desc">{{ coupon.scope }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">使用流程</text>
<text class="bt-card__desc">{{ coupon.flow }}</text>
</view>
<view v-if="coupon.status === 'available'" class="bt-footer-actions">
<view class="bt-btn bt-btn--primary" hover-class="mi-tap-btn--hover" @tap="useNow">
<text class="bt-btn__text">立即使用</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getCouponById } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { coupon: null }
},
onLoad(options) {
const store = loadMemberStore()
this.coupon = getCouponById(store, options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
useNow() {
navigateToPage(PAGE.COURSE_LIST)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-coupon--detail { margin-bottom: 12px; }
.bt-footer-actions .bt-btn { flex: 1; }
</style>
@@ -0,0 +1,169 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的优惠券" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}({{ countByTab[tab.key] }})</text>
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCenter"
>
领券中心
</text>
</view>
<view class="bt-page__body">
<view
v-for="item in displayedCoupons"
:key="item.id"
class="mi-mod-coupon"
:class="'mi-mod-coupon--' + item.status"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="onCouponTap(item)"
>
<view class="mi-mod-coupon__left">
<text class="mi-mod-coupon__amount">¥{{ item.amount }}</text>
<text class="mi-mod-coupon__min">{{ item.minSpend }}可用</text>
</view>
<view class="mi-mod-coupon__right">
<text class="mi-mod-coupon__title">{{ item.title }}</text>
<text class="mi-mod-coupon__desc">{{ item.desc }}</text>
<text class="mi-mod-coupon__expire">
{{ item.status === 'used' ? `已于 ${item.usedAt} 使用` : `有效期至 ${item.expire}` }}
</text>
<view v-if="item.status === 'available'" class="mi-mod-coupon__use" @tap.stop="useNow(item)">
<text>立即使用</text>
</view>
<view v-if="item.status === 'expired'" class="mi-mod-coupon__del" @tap.stop="removeExpired(item)">
<text>删除</text>
</view>
</view>
</view>
<view v-if="!displayedCoupons.length" class="bt-empty">
<text class="bt-empty__text">暂无{{ activeTabLabel }}优惠券</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getCouponsByStatus, useCoupon, deleteExpiredCoupon } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.couponTabs,
activeTab: 'available',
coupons: []
}
},
computed: {
displayedCoupons() {
return this.coupons.filter((c) => c.status === this.activeTab)
},
countByTab() {
return {
available: this.coupons.filter((c) => c.status === 'available').length,
used: this.coupons.filter((c) => c.status === 'used').length,
expired: this.coupons.filter((c) => c.status === 'expired').length
}
},
activeTabLabel() {
return this.tabs.find((t) => t.key === this.activeTab)?.label || ''
}
},
onShow() {
this.refreshList()
},
methods: {
refreshList() {
const store = loadMemberStore()
this.coupons = [
...getCouponsByStatus(store, 'available'),
...getCouponsByStatus(store, 'used'),
...getCouponsByStatus(store, 'expired')
]
},
onCouponTap(item) {
navigateToPage(`${PAGE.COUPON_DETAIL}?id=${item.id}`)
},
useNow(item) {
uni.showModal({
title: '使用优惠券',
content: `确认使用「${item.title}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
useCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
navigateToPage(PAGE.COURSE_LIST)
}
})
},
removeExpired(item) {
const store = loadMemberStore()
deleteExpiredCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
},
goCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-coupon__use {
margin-top: 8px;
align-self: flex-start;
padding: 4px 12px;
border-radius: 999px;
}
.mi-mod-coupon__use text {
font-size: 11px;
font-weight: 600;
color: #fff;
}
.mi-mod-coupon__del {
margin-top: 8px;
}
.mi-mod-coupon__del text {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
</style>
@@ -0,0 +1,175 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="课程详情" @back="onBack" />
<image class="mi-detail-hero" :src="course.banner" mode="aspectFill" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ course.title }}</text>
<text class="bt-card__desc">{{ course.startTime }}-{{ course.endTime }} · {{ course.location }}</text>
<view class="mi-detail-coach">
<image class="mi-detail-coach__avatar" :src="course.coachAvatar" mode="aspectFill" />
<view>
<text class="mi-detail-coach__name">{{ course.coach }}</text>
<text class="mi-detail-coach__rating">评分 {{ course.coachRating }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程介绍</text>
<text class="bt-card__desc">{{ course.intro }}</text>
<text class="bt-card__title" style="margin-top:12px;">适合人群</text>
<text class="bt-card__desc">{{ course.suitable }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">教练介绍</text>
<text class="bt-card__desc">{{ course.coachBio }}</text>
</view>
<view v-if="course.reviews?.length" class="bt-card">
<text class="bt-card__title">会员评价</text>
<view v-for="(r, i) in course.reviews" :key="i" class="mi-detail-review">
<text class="mi-detail-review__user">{{ r.user }} · {{ r.score }}</text>
<text class="mi-detail-review__text">{{ r.text }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">取消规则</text>
<text class="bt-card__desc">{{ course.cancelRule }}</text>
<text class="bt-card__desc" style="margin-top:8px;">费用{{ course.price }}</text>
</view>
<view class="bt-footer-actions">
<view
class="bt-btn bt-btn--primary"
:class="{ 'bt-btn--outline': course.full }"
hover-class="mi-tap-btn--hover"
@tap="confirmBook"
>
<text class="bt-btn__text">{{ course.full ? '已约满' : '确认预约' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getCourseById, enrichCourseForDisplay, bookCourse } from '@/common/memberInfo/bookingStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { course: null, courseId: null }
},
onLoad(options) {
this.courseId = options?.id
this.loadCourse()
},
methods: {
loadCourse() {
const store = loadMemberStore()
const raw = getCourseById(store, this.courseId)
this.course = raw ? enrichCourseForDisplay(raw) : null
},
onBack() {
goBackOrTab(PAGE.COURSE_LIST)
},
confirmBook() {
if (!this.course || this.course.full) return
uni.showModal({
title: '确认预约',
content: `预约「${this.course.title}\n${this.course.date} ${this.course.startTime}-${this.course.endTime}`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = bookCourse(store, this.courseId)
if (!result.ok) {
uni.showToast({ title: result.message, icon: 'none' })
return
}
persistMemberStore(store)
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => navigateToPage(PAGE.BOOKING), 800)
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-detail-hero {
width: 100%;
height: 180px;
display: block;
}
.mi-detail-coach {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.mi-detail-coach__avatar {
width: 44px;
height: 44px;
border-radius: 50%;
}
.mi-detail-coach__name {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-detail-coach__rating {
font-size: 12px;
color: var(--accent-orange, #FF6B35);
}
.mi-detail-review {
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-detail-review:last-child {
border-bottom: none;
}
.mi-detail-review__user {
font-size: 12px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-detail-review__text {
font-size: 12px;
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.bt-footer-actions .bt-btn {
flex: 1;
}
</style>
@@ -0,0 +1,88 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="课程评价" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ title }}</text>
<text class="bt-card__desc">请为本次课程打分并填写评价</text>
</view>
<view class="bt-card">
<text class="bt-card__title">评分</text>
<view class="mi-eval-stars">
<text
v-for="n in 5"
:key="n"
class="mi-eval-star"
:class="{ 'mi-eval-star--on': n <= score }"
@tap="score = n"
></text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">评价内容</text>
<textarea
v-model="comment"
class="mi-eval-textarea"
placeholder="分享您的上课体验…"
maxlength="200"
/>
</view>
<view class="bt-footer-actions">
<view class="bt-btn bt-btn--primary" hover-class="mi-tap-btn--hover" @tap="submit">
<text class="bt-btn__text">提交评价</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
export default {
components: { MemberInfoSubNav },
data() {
return { title: '', score: 5, comment: '' }
},
onLoad(options) {
this.title = decodeURIComponent(options?.title || '课程评价')
},
methods: {
onBack() { goBackOrTab(PAGE.MY_COURSES) },
submit() {
if (!this.comment.trim()) {
uni.showToast({ title: '请填写评价', icon: 'none' })
return
}
uni.showToast({ title: '评价已提交', icon: 'success' })
setTimeout(() => goBackOrTab(PAGE.MY_COURSES), 800)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
.mi-eval-stars { display: flex; flex-direction: row; gap: 8px; margin-top: 8px; }
.mi-eval-star { font-size: 32px; color: #E9EDF2; }
.mi-eval-star--on { color: #FF6B35; }
.mi-eval-textarea {
width: 100%;
min-height: 120px;
margin-top: 8px;
padding: 12px;
border-radius: 12px;
background: var(--bg-gray, #F2F5F9);
font-size: 14px;
box-sizing: border-box;
}
.bt-footer-actions .bt-btn { flex: 1; }
</style>
@@ -0,0 +1,217 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page mi-course-list">
<MemberInfoSubNav title="预约课程" @back="goBack" />
<view class="mi-course-list__filters">
<view class="mi-course-list__date-bar">
<view
class="mi-course-list__mode"
hover-class="mi-tap--hover"
@tap="toggleDateMode"
>
<text class="mi-course-list__mode-text">{{ dateMode === 'day' ? '按天' : '按周' }}</text>
</view>
<scroll-view scroll-x class="mi-course-list__dates">
<view
v-for="d in dateOptions"
:key="d.value"
class="mi-course-list__date"
:class="{ 'mi-course-list__date--active': selectedDate === d.value }"
@tap="selectedDate = d.value"
>
<text class="mi-course-list__date-week">{{ d.week }}</text>
<text class="mi-course-list__date-day">{{ d.day }}</text>
</view>
</scroll-view>
</view>
<scroll-view scroll-x class="mi-course-list__chips">
<view
v-for="t in typeOptions"
:key="t.key"
class="bt-tab"
:class="{ 'bt-tab--active': courseType === t.key }"
@tap="courseType = t.key"
>
<text class="bt-tab__text">{{ t.label }}</text>
</view>
</scroll-view>
<view class="mi-course-list__row">
<picker :range="coaches" @change="onCoachChange">
<view class="mi-course-list__picker">
<text>{{ coach }}</text>
<image class="mi-course-list__arrow" src="/static/images/chevronright3.png" mode="aspectFit" />
</view>
</picker>
<picker :range="periodLabels" @change="onPeriodChange">
<view class="mi-course-list__picker">
<text>{{ periodLabel }}</text>
<image class="mi-course-list__arrow" src="/static/images/chevronright3.png" mode="aspectFit" />
</view>
</picker>
</view>
</view>
<view class="bt-page__body">
<view
v-for="course in displayCourses"
:key="course.id"
class="mi-course-card"
hover-class="mi-tap-card--hover"
@tap="openDetail(course)"
>
<image class="mi-course-card__banner" :src="course.banner" mode="aspectFill" />
<view class="mi-course-card__body">
<view class="mi-course-card__head">
<text class="mi-course-card__title">{{ course.title }}</text>
<view class="mi-course-card__type" :class="'mi-course-card__type--' + course.type">
<text>{{ course.type === 'group' ? '团课' : '私教' }}</text>
</view>
</view>
<view class="mi-course-card__coach">
<image class="mi-course-card__avatar" :src="course.coachAvatar" mode="aspectFill" />
<text>{{ course.coach }}</text>
</view>
<text class="mi-course-card__meta">
{{ course.startTime }}-{{ course.endTime }} · {{ course.location }}
</text>
<view class="mi-course-card__capacity">
<view class="mi-course-card__bar">
<view class="mi-course-card__bar-fill" :style="{ width: course.percent + '%' }"></view>
</view>
<text class="mi-course-card__cap-text">{{ course.enrolled }}/{{ course.capacity }}</text>
<text v-if="course.scarcityLabel" class="mi-course-card__scarcity">{{ course.scarcityLabel }}</text>
</view>
<view class="mi-course-card__footer">
<text class="mi-course-card__price">{{ course.price }}</text>
<view
class="mi-course-card__btn"
:class="{ 'mi-course-card__btn--disabled': course.full }"
@tap.stop="quickBook(course)"
>
<text>{{ course.full ? '已约满' : '预约' }}</text>
</view>
</view>
</view>
</view>
<view v-if="!displayCourses.length" class="bt-empty">
<text class="bt-empty__text">暂无符合条件的课程</text>
</view>
</view>
<view
class="mi-course-list__fab"
hover-class="mi-tap-btn--hover"
@tap="goMyBooking"
>
<image class="mi-course-list__fab-icon" src="/static/images/clock.png" mode="aspectFit" />
<text class="mi-course-list__fab-text">我的预约</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import {
courseCatalogMock,
filterCourses,
enrichCourseForDisplay,
getWeekDates,
bookCourse
} from '@/common/memberInfo/bookingStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
const WEEK = ['日', '一', '二', '三', '四', '五', '六']
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
dateMode: 'day',
selectedDate: '2024-07-15',
courseType: 'all',
coach: '全部',
period: 'all',
coaches: courseCatalogMock.coaches,
typeOptions: courseCatalogMock.typeOptions,
periodOptions: courseCatalogMock.periodOptions,
courses: []
}
},
computed: {
periodLabels() {
return this.periodOptions.map((p) => p.label)
},
periodLabel() {
return this.periodOptions.find((p) => p.key === this.period)?.label || '全部时段'
},
weekDates() {
return getWeekDates(this.selectedDate)
},
dateOptions() {
const dates = this.dateMode === 'week' ? this.weekDates : this.weekDates
return dates.map((value) => {
const d = new Date(value.replace(/-/g, '/'))
return {
value,
week: WEEK[d.getDay()],
day: `${d.getMonth() + 1}/${d.getDate()}`
}
})
},
displayCourses() {
const filters = {
date: this.dateMode === 'day' ? this.selectedDate : '',
weekDates: this.dateMode === 'week' ? this.weekDates : [],
type: this.courseType,
coach: this.coach,
period: this.period
}
return filterCourses(this.courses, filters).map(enrichCourseForDisplay)
}
},
onShow() {
const store = loadMemberStore()
this.courses = store.courseCatalog.map((c) => ({ ...c }))
if (!this.selectedDate && this.courses.length) {
this.selectedDate = this.courses[0].date
}
},
methods: {
toggleDateMode() {
this.dateMode = this.dateMode === 'day' ? 'week' : 'day'
},
onCoachChange(e) {
this.coach = this.coaches[e.detail.value]
},
onPeriodChange(e) {
this.period = this.periodOptions[e.detail.value].key
},
openDetail(course) {
navigateToPage(`${PAGE.COURSE_DETAIL}?id=${course.id}`)
},
quickBook(course) {
if (course.full) return
navigateToPage(`${PAGE.COURSE_DETAIL}?id=${course.id}`)
},
goMyBooking() {
navigateToPage(PAGE.BOOKING)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
@import '@/common/style/memberInfo/pages/course-list-page.css';
</style>
@@ -0,0 +1,210 @@
<template>
<view class="scroll-container theme-light">
<view class="member-card-page">
<MemberInfoSubNav title="我的会员卡" @back="goBack" />
<view class="member-card-page__body">
<view class="mc-hero">
<view class="mc-hero__top">
<view class="mc-hero__title-row">
<image
class="mc-hero__crown"
src="/static/images/crown.png"
mode="aspectFit"
/>
<text class="mc-hero__name">{{ card.name }}</text>
</view>
<view class="mc-hero__badge">
<text class="mc-hero__badge-text">{{ card.status }}</text>
</view>
</view>
<text class="mc-hero__validity">有效期{{ card.validity }}</text>
<view class="mc-hero__bottom">
<view class="mc-hero__days">
<text class="mc-hero__days-num">{{ remainingDays }}</text>
<text class="mc-hero__days-unit"></text>
</view>
<view
class="mc-hero__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="renewCard"
>
<image
class="mc-hero__renew-icon"
src="/static/images/refreshcw.png"
mode="aspectFit"
/>
<text class="mc-hero__renew-text">立即续费</text>
</view>
</view>
</view>
<view class="mc-records">
<view class="mc-records__header">
<text class="mc-records__title">使用记录</text>
<view class="mc-records__tabs">
<view
v-for="tab in recordTabs"
:key="tab.key"
class="mc-records__tab"
:class="{ 'mc-records__tab--active': activeFilter === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="switchFilter(tab.key)"
>
<text
class="mc-records__tab-text"
:class="{ 'mc-records__tab-text--active': activeFilter === tab.key }"
>
{{ tab.label }}
</text>
</view>
</view>
</view>
<view class="mc-records__divider"></view>
<view
v-for="(item, index) in filteredRecords"
:key="item.id"
class="mc-records__item"
>
<view v-if="index > 0" class="mc-records__divider"></view>
<view
class="mc-records__item-inner"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="showRecordDetail(item)"
>
<view
class="mc-records__icon-wrap"
:class="'mc-records__icon-wrap--' + item.iconTheme"
>
<image
class="mc-records__icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<view class="mc-records__info">
<text class="mc-records__item-title">{{ item.title }}</text>
<text class="mc-records__item-time">{{ item.time }}</text>
</view>
<text
class="mc-records__value"
:class="'mc-records__value--' + item.valueType"
>
{{ item.value }}
</text>
</view>
</view>
<view v-if="!filteredRecords.length" class="mc-records__empty">
<text class="mc-records__empty-text">暂无记录</text>
</view>
</view>
<view class="mc-rules">
<text class="mc-rules__title">使用规则</text>
<view
v-for="(rule, index) in rules"
:key="index"
class="mc-rules__item"
>
<view class="mc-rules__bullet"></view>
<text class="mc-rules__text">{{ rule }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { memberCardMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
computeRemainingDays,
renewMemberCard
} from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
card: {},
recordTabs: memberCardMock.recordTabs,
records: [],
rules: memberCardMock.rules,
activeFilter: 'all'
}
},
computed: {
filteredRecords() {
if (this.activeFilter === 'all') {
return this.records
}
return this.records.filter((item) => item.type === this.activeFilter)
},
remainingDays() {
return computeRemainingDays(this.card.validityEnd)
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.card = { ...store.card }
this.records = store.records.map((item) => ({ ...item }))
},
switchFilter(filter) {
this.activeFilter = filter
},
renewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.activeFilter = 'all'
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
showRecordDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n变动:${item.value}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/member-card-page.css';
.mc-records__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.mc-records__empty-text {
font-size: 14px;
color: #8A99B4;
}
</style>
@@ -0,0 +1,240 @@
<template>
<view class="scroll-container theme-light">
<view class="member-page">
<MemberInfoHeader
:user-info="userInfo"
:stats="stats"
@user-info="goUserInfo"
/>
<view class="member-page__body">
<view class="member-page__sections">
<MemberInfoMemberCard
:card-info="cardInfo"
@view-all="goMemberCard"
@renew="onRenewCard"
/>
<MemberInfoQuickActions @action="onQuickAction" />
<MemberInfoBookingList
:items="bookingPreview"
@view-all="goBooking"
@item-tap="goBooking"
/>
<MemberInfoCheckInList
:items="checkIns"
@view-all="onCheckInViewAll"
@item-tap="onCheckInTap"
/>
<MemberInfoBodyReport
:report="bodyReport"
@view-history="onBodyReportHistory"
@view-report="onBodyReportView"
/>
<MemberInfoCouponPoints
:data="couponPoints"
@view-all="onCouponViewAll"
@use-coupon="onUseCoupon"
@redeem-points="onRedeemPoints"
/>
<MemberInfoReferral
:data="referral"
@view-rules="onReferralRules"
/>
<MemberInfoSettings @setting="onSetting" />
<MemberInfoLogout @logout="handleLogout" />
</view>
</view>
</view>
</view>
<TabBar :active="4" />
</template>
<script>
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import {
loadMemberStore,
getCenterPageData,
renewMemberCard
} from '@/common/memberInfo/store.js'
import MemberInfoHeader from '@/components/memberInfo/MemberInfoHeader.vue'
import MemberInfoMemberCard from '@/components/memberInfo/MemberInfoMemberCard.vue'
import MemberInfoQuickActions from '@/components/memberInfo/MemberInfoQuickActions.vue'
import MemberInfoBookingList from '@/components/memberInfo/MemberInfoBookingList.vue'
import MemberInfoCheckInList from '@/components/memberInfo/MemberInfoCheckInList.vue'
import MemberInfoBodyReport from '@/components/memberInfo/MemberInfoBodyReport.vue'
import MemberInfoCouponPoints from '@/components/memberInfo/MemberInfoCouponPoints.vue'
import MemberInfoReferral from '@/components/memberInfo/MemberInfoReferral.vue'
import MemberInfoSettings from '@/components/memberInfo/MemberInfoSettings.vue'
import MemberInfoLogout from '@/components/memberInfo/MemberInfoLogout.vue'
import TabBar from '@/components/TabBar.vue'
export default {
components: {
MemberInfoHeader,
MemberInfoMemberCard,
MemberInfoQuickActions,
MemberInfoBookingList,
MemberInfoCheckInList,
MemberInfoBodyReport,
MemberInfoCouponPoints,
MemberInfoReferral,
MemberInfoSettings,
MemberInfoLogout,
TabBar
},
data() {
return {
userInfo: {},
stats: {},
cardInfo: {},
bookingPreview: [],
checkIns: [],
bodyReport: {},
couponPoints: {},
referral: {}
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
this.userInfo = pageData.userInfo
this.stats = pageData.stats
this.cardInfo = pageData.cardInfo
this.bookingPreview = pageData.bookingPreview
this.checkIns = pageData.checkIns
this.bodyReport = pageData.bodyReport
this.couponPoints = pageData.couponPoints
this.referral = pageData.referral
},
goMemberCard() {
navigateToPage(PAGE.MEMBER_CARD)
},
goBooking() {
navigateToPage(PAGE.BOOKING)
},
goUserInfo() {
navigateToPage(PAGE.USER_INFO)
},
onRenewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
onQuickAction(type) {
const routes = {
booking: PAGE.COURSE_LIST,
bodyTest: PAGE.BODY_TEST_HOME,
bodyReport: PAGE.BODY_TEST_HISTORY,
trainReport: PAGE.TRAIN_REPORT,
coupon: PAGE.COUPONS,
points: PAGE.POINTS,
referral: PAGE.REFERRAL,
course: PAGE.MY_COURSES
}
if (routes[type]) {
navigateToPage(routes[type])
}
},
onCheckInViewAll() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
},
onCheckInTap(item) {
uni.showModal({
title: item.title,
content: item.time,
showCancel: false
})
},
onBodyReportHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
},
onBodyReportView() {
const id = this.bodyReport?.recordId
const url = id
? `${PAGE.BODY_TEST_REPORT}?id=${id}`
: PAGE.BODY_TEST_HISTORY
navigateToPage(url)
},
onCouponViewAll() {
navigateToPage(PAGE.COUPONS)
},
onUseCoupon() {
navigateToPage(PAGE.COUPONS)
},
onRedeemPoints() {
navigateToPage(PAGE.POINTS)
},
onReferralRules() {
navigateToPage(PAGE.REFERRAL)
},
onSetting(key) {
const labels = {
notify: '通知设置',
password: '修改密码',
privacy: '隐私政策',
nfc: 'NFC 门禁卡',
delete: '注销账户'
}
if (key === 'delete') {
uni.showModal({
title: '注销账户',
content: '注销后数据将无法恢复,确定继续吗?',
confirmColor: '#E74C3C'
})
return
}
if (key === 'privacy') {
uni.showModal({
title: '隐私政策',
content: '我们重视您的隐私,详细政策请前往官网查看。',
showCancel: false
})
return
}
uni.showToast({
title: `${labels[key] || '设置'}开发中`,
icon: 'none'
})
},
handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已退出', icon: 'success' })
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/member-info-page.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-settings.css';
@import '@/common/style/memberInfo/member-info-logout.css';
</style>
@@ -0,0 +1,225 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的课程" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<!-- 团课 -->
<template v-if="activeTab === 'group'">
<view class="mi-mod-subtabs">
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'ongoing' }"
@tap="groupSub = 'ongoing'"
>进行中</text>
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'completed' }"
@tap="groupSub = 'completed'"
>已完成</text>
</view>
<view
v-for="item in groupList"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="onGroupCourse(item)"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }}</text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }} · {{ item.location }}</text>
<view v-if="groupSub === 'ongoing' && item.canCancel" class="mi-mod-course-card__action" @tap.stop="goBooking">
<text>取消预约</text>
</view>
<view v-if="groupSub === 'completed' && item.canEvaluate" class="mi-mod-course-card__action" @tap.stop="evaluate(item)">
<text>去评价</text>
</view>
</view>
</view>
</template>
<!-- 私教 -->
<template v-else-if="activeTab === 'private'">
<view class="bt-card">
<text class="bt-card__title">剩余课时 {{ privateData.remaining }} </text>
<view class="mi-detail-coach">
<image class="mi-detail-coach__avatar" :src="privateData.coachAvatar" mode="aspectFill" />
<view>
<text class="mi-detail-coach__name">{{ privateData.coach }}</text>
<text class="mi-detail-coach__rating">下次 {{ privateData.nextClass }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">已约课程</text>
<view v-for="b in privateData.bookings" :key="b.id" class="mi-mod-session">
<text class="mi-mod-session__title">{{ b.title }}</text>
<text class="mi-mod-session__meta">{{ b.time }} · {{ b.location }} · {{ b.status }}</text>
</view>
</view>
</template>
<!-- 线上课 -->
<template v-else-if="activeTab === 'online'">
<view
v-for="item in onlineList"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="goOnline(item)"
>
<image class="mi-mod-course-card__banner" :src="item.cover" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__meta">{{ item.duration }} · 进度 {{ item.progress }}%</text>
<text v-if="item.type === 'live'" class="mi-mod-course-card__next">直播 {{ item.liveTime }}</text>
</view>
</view>
</template>
<!-- 训练营 -->
<template v-else>
<view
v-for="item in packageList"
:key="item.id"
class="mi-mod-course-card"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }} </text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }}</text>
</view>
</view>
</template>
<view v-if="isEmpty" class="bt-empty">
<text class="bt-empty__text">暂无课程</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getMyCoursesData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.myCourseTabs,
activeTab: 'group',
groupSub: 'ongoing',
privateData: {},
onlineList: [],
packageList: [],
groupData: { ongoing: [], completed: [] }
}
},
computed: {
groupList() {
return this.groupData[this.groupSub] || []
},
isEmpty() {
if (this.activeTab === 'group') return !this.groupList.length
if (this.activeTab === 'private') return !this.privateData.bookings?.length
if (this.activeTab === 'online') return !this.onlineList.length
return !this.packageList.length
}
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.groupData = getMyCoursesData(store, 'group')
this.privateData = getMyCoursesData(store, 'private')
this.onlineList = getMyCoursesData(store, 'online').list || []
this.packageList = getMyCoursesData(store, 'package').list || []
},
pct(item) {
return item.total ? Math.min(100, Math.round((item.progress / item.total) * 100)) : 0
},
goBooking() { navigateToPage(PAGE.BOOKING) },
goOnline(item) { navigateToPage(`${PAGE.ONLINE_COURSE}?id=${item.id}`) },
evaluate(item) {
navigateToPage(`${PAGE.COURSE_EVALUATE}?title=${encodeURIComponent(item.title)}`)
},
onGroupCourse(item) {
uni.showModal({
title: item.title,
content: `${item.coach}\n${item.schedule}\n${item.location}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-subtabs {
display: flex;
flex-direction: row;
gap: 16px;
margin-bottom: 12px;
}
.mi-mod-subtab {
font-size: 14px;
color: var(--text-muted, #5E6F8D);
}
.mi-mod-subtab--on {
color: var(--primary-dark, #0B2B4B);
font-weight: 700;
}
.mi-mod-course-card__action {
margin-top: 6px;
}
.mi-mod-course-card__action text {
font-size: 12px;
font-weight: 600;
color: var(--accent-orange, #FF6B35);
}
</style>
@@ -0,0 +1,119 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="线上课程" @back="onBack" />
<image class="mi-detail-hero" :src="course.cover" mode="aspectFill" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">{{ course.title }}</text>
<text class="bt-card__desc">时长 {{ course.duration }} · 进度 {{ course.progress }}%</text>
<view class="mi-mod-course-card__progress" style="margin-top:12px;">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: course.progress + '%' }"></view>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">视频播放</text>
<view class="mi-online-player">
<image class="mi-online-player__play" src="/static/images/play.png" mode="aspectFit" />
<text class="mi-online-player__hint">点击播放支持倍速与拖拽</text>
</view>
<view class="mi-online-controls">
<text class="mi-online-controls__btn" @tap="seek(-10)">-10s</text>
<text class="mi-online-controls__btn" @tap="togglePlay">{{ playing ? '暂停' : '播放' }}</text>
<text class="mi-online-controls__btn" @tap="seek(10)">+10s</text>
<text class="mi-online-controls__btn" @tap="changeSpeed">倍速 {{ speed }}x</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程目录</text>
<view v-for="ch in course.chapters" :key="ch.id" class="mi-online-chapter">
<text class="mi-online-chapter__title">{{ ch.title }}</text>
<text class="mi-online-chapter__meta">{{ ch.duration }} · {{ ch.done ? '已学完' : '未学习' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getOnlineCourseById, updateOnlineProgress } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { course: null, playing: false, speed: 1 }
},
onLoad(options) {
this.course = getOnlineCourseById(loadMemberStore(), options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.MY_COURSES) },
togglePlay() {
this.playing = !this.playing
if (this.playing && this.course.progress < 100) {
const store = loadMemberStore()
updateOnlineProgress(store, this.course.id, Math.min(100, this.course.progress + 5))
persistMemberStore(store)
this.course.progress = Math.min(100, this.course.progress + 5)
}
},
seek(sec) { uni.showToast({ title: `跳转 ${sec > 0 ? '+' : ''}${sec}s`, icon: 'none' }) },
changeSpeed() {
const speeds = [1, 1.25, 1.5, 2]
const idx = (speeds.indexOf(this.speed) + 1) % speeds.length
this.speed = speeds[idx]
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-online-player {
height: 180px;
border-radius: 12px;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.mi-online-player__play { width: 48px; height: 48px; }
.mi-online-player__hint { font-size: 12px; color: rgba(255,255,255,0.7); }
.mi-online-controls {
display: flex;
flex-direction: row;
justify-content: space-around;
margin-top: 12px;
}
.mi-online-controls__btn {
font-size: 12px;
color: var(--primary-deep, #1A4A6F);
font-weight: 600;
}
.mi-online-chapter {
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-online-chapter__title { font-size: 14px; color: var(--text-dark, #1E2A3A); display: block; }
.mi-online-chapter__meta { font-size: 11px; color: var(--text-muted, #5E6F8D); }
</style>
@@ -0,0 +1,92 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的积分" @back="goBack" />
<view class="bt-page__body">
<view class="mi-mod-points-hero">
<text class="mi-mod-points-hero__label">当前积分</text>
<text class="mi-mod-points-hero__value">{{ balance }}</text>
<text class="mi-mod-points-hero__tip">{{ config.rate }} · {{ config.rule }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">快捷入口</text>
<view class="bt-grid">
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goMall">
<image class="bt-grid__icon" src="/static/images/star.png" mode="aspectFit" />
<text class="bt-grid__label">积分商城</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goHistory">
<image class="bt-grid__icon" src="/static/images/clock.png" mode="aspectFit" />
<text class="bt-grid__label">积分明细</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="checkIn">
<image class="bt-grid__icon" src="/static/images/usercheck.png" mode="aspectFit" />
<text class="bt-grid__label">签到赚积分</text>
</view>
<view class="bt-grid__item" hover-class="mi-tap--hover" @tap="goReferral">
<image class="bt-grid__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-grid__label">邀请赚积分</text>
</view>
</view>
</view>
<view class="bt-card">
<view class="bt-trend-link" style="border:none;margin:0;padding:0;" @tap="goHistory">
<text class="bt-card__title">最近明细</text>
<text class="bt-trend-link__text">查看全部</text>
</view>
<view v-for="item in historyPreview" :key="item.id" class="mi-mod-points-row">
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ item.title }}</text>
<text class="mi-mod-points-row__time">{{ item.time }}</text>
</view>
<text
class="mi-mod-points-row__amount"
:class="item.amount > 0 ? 'mi-mod-points-row__amount--earn' : 'mi-mod-points-row__amount--spend'"
>{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getPointsPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return { balance: 0, config: {}, historyPreview: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.config = data.config
this.historyPreview = data.history.slice(0, 5)
},
methods: {
goMall() { navigateToPage(PAGE.POINTS_MALL) },
goHistory() { navigateToPage(PAGE.POINTS_HISTORY) },
goReferral() { navigateToPage(PAGE.REFERRAL) },
checkIn() { uni.showToast({ title: '签到成功 +10 积分', icon: 'success' }) }
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,86 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分明细" @back="onBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': filter === tab.key }"
@tap="filter = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view
v-for="item in list"
:key="item.id"
class="mi-mod-points-row"
>
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ item.title }}</text>
<text class="mi-mod-points-row__time">{{ item.time }}</text>
</view>
<view class="mi-mod-points-row__right">
<text
class="mi-mod-points-row__amount"
:class="item.amount > 0 ? 'mi-mod-points-row__amount--earn' : 'mi-mod-points-row__amount--spend'"
>
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
</text>
<text class="mi-mod-points-row__balance">余额 {{ item.balance }}</text>
</view>
</view>
<view v-if="!list.length" class="bt-empty">
<text class="bt-empty__text">暂无明细</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { filterPointsHistory } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
filter: 'all',
tabs: [
{ key: 'all', label: '全部' },
{ key: 'earn', label: '获取' },
{ key: 'spend', label: '消耗' }
],
list: []
}
},
watch: {
filter() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
loadList() {
const store = loadMemberStore()
this.list = filterPointsHistory(store, this.filter)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,93 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分商城" @back="onBack" />
<view class="bt-page__body">
<view class="mi-mod-points-hero">
<text class="mi-mod-points-hero__label">可用积分</text>
<text class="mi-mod-points-hero__value">{{ balance }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">兑换商品</text>
<view class="mi-mod-rewards">
<view
v-for="item in rewards"
:key="item.id"
class="mi-mod-reward"
hover-class="mi-tap-card--hover"
@tap="redeem(item)"
>
<image class="mi-mod-reward__icon" :src="item.icon" mode="aspectFit" />
<text class="mi-mod-reward__name">{{ item.name }}</text>
<text class="mi-mod-reward__cost">{{ item.cost }} 积分</text>
<text class="mi-mod-reward__stock">剩余 {{ item.stock }}</text>
</view>
</view>
</view>
<view v-if="redeemRecords.length" class="bt-card">
<text class="bt-card__title">兑换记录</text>
<view v-for="r in redeemRecords" :key="r.id" class="mi-mod-points-row">
<view class="mi-mod-points-row__info">
<text class="mi-mod-points-row__title">{{ r.name }}</text>
<text class="mi-mod-points-row__time">{{ r.time }}</text>
</view>
<text class="mi-mod-points-row__amount mi-mod-points-row__amount--spend">-{{ r.cost }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getPointsPageData, redeemPointsReward } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { balance: 0, rewards: [], redeemRecords: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
},
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
redeem(item) {
uni.showModal({
title: '确认兑换',
content: `使用 ${item.cost} 积分兑换「${item.name}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = redeemPointsReward(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
const data = getPointsPageData(store)
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
}
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,158 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="邀请好友" @back="goBack" />
<view class="bt-page__body">
<view class="mi-mod-referral-hero">
<text class="mi-mod-referral-hero__title">邀请好友一起健身</text>
<text class="mi-mod-referral-hero__desc">好友注册/购课双方均可获得积分奖励</text>
<view class="mi-mod-referral-code">
<text class="mi-mod-referral-code__label">我的邀请码</text>
<text class="mi-mod-referral-code__value">{{ data.code }}</text>
</view>
<view class="bt-hero__actions">
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="copyCode"
>
<text class="bt-btn__text">复制邀请码</text>
</view>
<view
class="bt-btn bt-btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="shareInvite"
>
<image class="bt-btn__icon" src="/static/images/share2.png" mode="aspectFit" />
<text class="bt-btn__text">分享给好友</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">邀请成果</text>
<view class="mi-mod-referral-stats">
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--orange">{{ data.invited }}</text>
<text class="mi-mod-referral-stat__label">已推荐</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--green">{{ data.registered }}</text>
<text class="mi-mod-referral-stat__label">已注册</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--amber">{{ data.purchased }}</text>
<text class="mi-mod-referral-stat__label">已购课</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">已获得奖励</text>
<view class="mi-mod-referral-stats">
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--orange">{{ data.rewardSummary?.totalPoints || 0 }}</text>
<text class="mi-mod-referral-stat__label">积分</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--green">{{ data.rewardSummary?.totalCoupons || 0 }}</text>
<text class="mi-mod-referral-stat__label">优惠券</text>
</view>
<view class="mi-mod-referral-stat">
<text class="mi-mod-referral-stat__num mi-mod-referral-stat__num--amber">{{ data.rewardSummary?.pendingCount || 0 }}</text>
<text class="mi-mod-referral-stat__label">待发放</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">邀请记录</text>
<view
v-for="item in data.records"
:key="item.id"
class="mi-mod-referral-row"
>
<view class="mi-mod-referral-row__info">
<text class="mi-mod-referral-row__name">{{ item.name }}</text>
<text class="mi-mod-referral-row__time">{{ item.time }}</text>
</view>
<view class="mi-mod-referral-row__right">
<text class="mi-mod-referral-row__status">{{ item.statusLabel }}</text>
<text class="mi-mod-referral-row__reward">{{ item.reward }}</text>
<text class="mi-mod-referral-row__reward-status">{{ item.rewardStatus }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">活动规则</text>
<view class="bt-advice-list">
<view
v-for="(rule, idx) in data.rules"
:key="idx"
class="bt-advice-item"
>
<view class="bt-advice-item__dot"></view>
<text class="bt-advice-item__text">{{ rule }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { getReferralPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
data: { code: '', invited: 0, registered: 0, purchased: 0, records: [], rules: [] }
}
},
onShow() {
const store = loadMemberStore()
this.data = getReferralPageData(store)
},
methods: {
copyCode() {
uni.setClipboardData({
data: this.data.code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' })
})
},
shareInvite() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成邀请海报', '复制分享链接'],
success: (res) => {
const msgs = ['已唤起分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
.mi-mod-referral-hero .bt-hero__actions {
margin-top: 16px;
}
</style>
@@ -0,0 +1,138 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="训练报告" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="p in periods"
:key="p.key"
class="bt-tab"
:class="{ 'bt-tab--active': period === p.key }"
@tap="switchPeriod(p.key)"
>
<text class="bt-tab__text">{{ p.label }}</text>
</view>
</view>
<view class="bt-page__body">
<view class="bt-metrics">
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.sessions }}</text><text class="bt-metric__label">完成课程</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.hours }}</text><text class="bt-metric__label">运动时长(h)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.calories }}</text><text class="bt-metric__label">消耗(kcal)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ report.summary.visits }}</text><text class="bt-metric__label">到店次数</text></view>
</view>
<view class="bt-card">
<text class="bt-card__title">运动时长趋势</text>
<BodyTestTrendChart :points="report.trendHours" unit="h" :width="chartWidth" :height="150" />
</view>
<view class="bt-card">
<text class="bt-card__title">消耗卡路里趋势</text>
<BodyTestTrendChart :points="report.trendCalories" unit="" :width="chartWidth" :height="150" />
</view>
<view class="mi-mod-tabs">
<view
v-for="t in typeFilters"
:key="t.key"
class="bt-tab"
:class="{ 'bt-tab--active': typeFilter === t.key }"
@tap="typeFilter = t.key"
>
<text class="bt-tab__text">{{ t.label }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">课程完成列表</text>
<view
v-for="item in sessions"
:key="item.id"
class="mi-mod-session"
hover-class="mi-tap-row--hover"
@tap="goSession(item)"
>
<view class="mi-mod-session__head">
<text class="mi-mod-session__title">{{ item.title }}</text>
<view class="mi-mod-session__tag" :class="'mi-mod-session__tag--' + item.type">
<text class="mi-mod-session__tag-text">{{ item.typeLabel }}</text>
</view>
</view>
<text class="mi-mod-session__meta">{{ item.date }} · {{ item.time }} · {{ item.coach }}</text>
<view class="mi-mod-session__footer">
<text class="mi-mod-session__stat">{{ item.duration }}</text>
<text class="mi-mod-session__stat">{{ item.calories }} kcal</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getTrainingReportData, filterTrainingSessions } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
mixins: [subPageMixin],
data() {
return {
period: 'week',
periods: [
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' }
],
typeFilter: 'all',
typeFilters: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
],
report: { summary: {}, trendHours: [], trendCalories: [], sessions: [] },
sessions: [],
chartWidth: 300
}
},
onLoad() {
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.refresh()
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.report = getTrainingReportData(store, this.period)
this.sessions = filterTrainingSessions(store, { type: this.typeFilter })
},
switchPeriod(key) {
this.period = key
this.refresh()
},
goSession(item) {
navigateToPage(`${PAGE.TRAIN_SESSION}?id=${item.id}`)
}
},
watch: {
typeFilter() {
this.sessions = filterTrainingSessions(loadMemberStore(), { type: this.typeFilter })
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
</style>
@@ -0,0 +1,58 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="session">
<MemberInfoSubNav title="训练详情" @back="onBack" />
<view class="bt-page__body">
<view class="bt-score-card">
<view class="bt-score-card__info">
<text class="bt-score-card__title">{{ session.title }}</text>
<text class="bt-score-card__date">{{ session.date }} {{ session.time }}</text>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">训练数据</text>
<view class="bt-metrics">
<view class="bt-metric"><text class="bt-metric__value">{{ session.duration }}</text><text class="bt-metric__label">时长</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.calories }}</text><text class="bt-metric__label">消耗(kcal)</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.heartRate }}</text><text class="bt-metric__label">平均心率</text></view>
<view class="bt-metric"><text class="bt-metric__value">{{ session.typeLabel }}</text><text class="bt-metric__label">类型</text></view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">签到时间</text>
<text class="bt-card__desc">{{ session.checkInTime }}</text>
</view>
<view class="bt-card">
<text class="bt-card__title">教练评语</text>
<text class="bt-card__desc">{{ session.comment }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getTrainingSessionById } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() { return { session: null } },
onLoad(options) {
this.session = getTrainingSessionById(loadMemberStore(), options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.TRAIN_REPORT) }
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
</style>
@@ -0,0 +1,483 @@
<template>
<view class="scroll-container theme-light">
<view class="Pixso-frame-2_791">
<MemberInfoSubNav title="个人信息" @back="goBack" />
<view class="Pixso-frame-2_802">
<view class="frame-content-2_802">
<view class="avatar-block">
<view class="avatar-block__inner">
<image
class="avatar-block__photo"
:key="avatarKey"
:src="avatarSrc"
mode="aspectFill"
@tap="previewAvatar"
/>
<view
class="avatar-block__change"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap.stop="changeAvatar"
>
<image
class="avatar-block__icon"
src="/static/images/camera.png"
mode="aspectFit"
/>
<text class="avatar-block__text">更换</text>
</view>
</view>
</view>
<view class="Pixso-frame-2_810">
<view class="frame-content-2_810">
<view
class="Pixso-frame-2_811 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editName"
>
<view class="frame-content-2_811">
<text class="Pixso-paragraph-2_812">姓名</text>
<text class="Pixso-paragraph-2_813">{{ name }}</text>
<image
class="Pixso-vector-2_814"
src="/static/images/chevronright1.png"
mode="aspectFit"
/>
</view>
</view>
<view class="Pixso-frame-2_816"></view>
<view
class="Pixso-frame-2_817 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="rebindPhone"
>
<view class="frame-content-2_817">
<text class="Pixso-paragraph-2_818">手机号</text>
<view class="Pixso-frame-2_819">
<view class="frame-content-2_819">
<text class="Pixso-paragraph-2_820">{{ displayPhone }}</text>
<view
class="Pixso-frame-2_821"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="rebindPhone"
>
<text class="Pixso-paragraph-2_822">换绑</text>
</view>
</view>
</view>
<image
class="Pixso-vector-2_823"
src="/static/images/chevronright.png"
mode="aspectFit"
/>
</view>
</view>
<view class="Pixso-frame-2_825"></view>
<view class="Pixso-frame-2_826">
<view class="frame-content-2_826">
<text class="Pixso-paragraph-2_827">性别</text>
<view class="Pixso-frame-2_828">
<view class="frame-content-2_828">
<view
class="gender-btn"
:class="{ 'gender-btn--active': gender === 'female' }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="selectGender('female')"
>
<image
class="gender-btn__icon"
src="/static/images/venus.png"
mode="aspectFit"
/>
<text class="gender-btn__text"></text>
</view>
<view
class="gender-btn"
:class="{ 'gender-btn--active': gender === 'male' }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="selectGender('male')"
>
<image
class="gender-btn__icon"
src="/static/images/mars.png"
mode="aspectFit"
/>
<text class="gender-btn__text"></text>
</view>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_841"></view>
<picker mode="date" :value="birthdayValue" @change="onBirthdayChange">
<view
class="Pixso-frame-2_842 user-info-row"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
>
<view class="frame-content-2_842">
<text class="Pixso-paragraph-2_843">生日</text>
<text class="Pixso-paragraph-2_844">{{ birthday }}</text>
<image
class="Pixso-vector-2_845"
src="/static/images/chevronright0.png"
mode="aspectFit"
/>
</view>
</view>
</picker>
<view class="Pixso-frame-2_847"></view>
<view class="Pixso-frame-2_848">
<view class="frame-content-2_848">
<text class="Pixso-paragraph-2_849">身高</text>
<view
class="Pixso-frame-2_850 user-info-measure"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editHeight"
>
<view class="frame-content-2_850">
<text class="Pixso-paragraph-2_851">{{ height }}</text>
<text class="Pixso-paragraph-2_852">cm</text>
</view>
</view>
<view class="Pixso-frame-2_853"></view>
<text class="Pixso-paragraph-2_854">体重</text>
<view
class="Pixso-frame-2_855 user-info-measure"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="editWeight"
>
<view class="frame-content-2_855">
<text class="Pixso-paragraph-2_856">{{ weight }}</text>
<text class="Pixso-paragraph-2_857">kg</text>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_858"></view>
<view class="Pixso-frame-2_859">
<view class="frame-content-2_859">
<text class="Pixso-paragraph-2_860">微信</text>
<text class="Pixso-paragraph-2_861">已授权绑定</text>
<view class="stroke-wrapper-2_862">
<view class="Pixso-frame-2_862">
<text class="Pixso-paragraph-2_863">已绑定</text>
</view>
<view class="stroke-2_862"></view>
</view>
</view>
</view>
</view>
</view>
<view class="Pixso-frame-2_864">
<view class="frame-content-2_864">
<text class="Pixso-paragraph-2_865">健身目标</text>
<view class="goal-tags">
<view
v-for="goal in fitnessGoalOptions"
:key="goal"
class="goal-tag"
:class="{ 'goal-tag--selected': isGoalSelected(goal) }"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="toggleGoal(goal)"
>
<text class="goal-tag__text">{{ goal }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="user-info-save-bar">
<view
class="user-info-save-bar__btn"
hover-class="mi-tap-save--hover"
:hover-stay-time="150"
@tap="handleSave"
>
<text class="user-info-save-bar__text">保存</text>
</view>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { fitnessGoalOptions } from '@/common/memberInfo/mockData.js'
import { loadMemberStore, saveUserProfile } from '@/common/memberInfo/store.js'
import { previewImage, persistChosenImage } from '@/common/memberInfo/media.js'
import { maskPhone, normalizePhoneForStore } from '@/common/memberInfo/format.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
import {
validateName,
validatePhoneForRebind,
validateHeight,
validateWeight,
validateBirthday,
validateFitnessGoals,
validateUserProfile,
showValidationError
} from '@/common/memberInfo/validate.js'
const DEFAULT_AVATAR = '/static/images/AvatarEditWrap.png'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
name: '',
phone: '',
gender: 'female',
birthday: '',
height: '',
weight: '',
fitnessGoals: [],
avatar: DEFAULT_AVATAR,
avatarKey: 0,
avatarDirty: false,
fitnessGoalOptions
}
},
computed: {
avatarSrc() {
return this.avatar || DEFAULT_AVATAR
},
displayPhone() {
return maskPhone(this.phone)
},
birthdayValue() {
const match = String(this.birthday).match(/(\d{4})年(\d{2})月(\d{2})日/)
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`
}
return '1995-06-15'
}
},
onShow() {
// 从相册/相机返回会再次触发 onShow,不能覆盖刚选未保存的头像
this.loadProfile({ preserveLocalAvatar: true })
},
methods: {
loadProfile(options = {}) {
const store = loadMemberStore()
const profile = store.profile
this.name = profile.name
this.phone = profile.phone
this.gender = profile.gender
this.birthday = profile.birthday
this.height = profile.height
this.weight = profile.weight
this.fitnessGoals = [...(profile.fitnessGoals || [])]
const storedAvatar = profile.avatar || DEFAULT_AVATAR
const hasUnsavedLocalAvatar =
options.preserveLocalAvatar &&
this.avatarDirty &&
this.avatar &&
this.avatar !== storedAvatar
if (!hasUnsavedLocalAvatar) {
this.setAvatar(storedAvatar)
this.avatarDirty = false
}
},
setAvatar(path) {
const next = path || DEFAULT_AVATAR
if (this.avatar !== next) {
this.avatar = next
this.avatarKey += 1
}
},
getProfilePayload() {
return {
name: this.name,
phone: normalizePhoneForStore(this.phone),
gender: this.gender,
birthday: this.birthday,
height: this.height,
weight: this.weight,
fitnessGoals: [...this.fitnessGoals],
avatar: this.avatar
}
},
handleSave() {
const result = validateUserProfile(
this.getProfilePayload(),
this.fitnessGoalOptions
)
if (!result.ok) {
showValidationError(result.message)
return
}
const store = loadMemberStore()
saveUserProfile(store, result.value)
this.applyValidatedProfile(result.value)
this.avatarDirty = false
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => this.goBack(), 600)
},
applyValidatedProfile(profile) {
this.name = profile.name
this.phone = profile.phone
this.gender = profile.gender
this.birthday = profile.birthday
this.height = profile.height
this.weight = profile.weight
this.fitnessGoals = [...profile.fitnessGoals]
},
changeAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempPath =
res.tempFilePaths?.[0] || res.tempFiles?.[0]?.tempFilePath
if (!tempPath) return
// 真机先用 tempFilePath 立即展示,避免 saveFile 异步期间被 onShow 覆盖
this.setAvatar(tempPath)
this.avatarDirty = true
uni.showToast({ title: '头像已选择', icon: 'success' })
persistChosenImage(tempPath).then((savedPath) => {
if (savedPath && savedPath !== this.avatar) {
this.setAvatar(savedPath)
}
})
}
})
},
previewAvatar() {
previewImage(this.avatarSrc, DEFAULT_AVATAR)
},
editName() {
uni.showModal({
title: '修改姓名',
editable: true,
placeholderText: '请输入姓名(2-8字)',
content: this.name,
success: (res) => {
if (!res.confirm) return
const result = validateName(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.name = result.value
}
})
},
rebindPhone() {
uni.showModal({
title: '换绑手机号',
editable: true,
placeholderText: '请输入11位手机号',
content: normalizePhoneForStore(this.phone) || this.phone,
success: (res) => {
if (!res.confirm) return
const result = validatePhoneForRebind(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.phone = result.value
uni.showToast({ title: '手机号已更新', icon: 'success' })
}
})
},
selectGender(gender) {
this.gender = gender
},
onBirthdayChange(e) {
const value = e.detail.value
const [y, m, d] = value.split('-')
const formatted = `${y}${m}${d}`
const result = validateBirthday(formatted)
if (!result.ok) {
showValidationError(result.message)
return
}
this.birthday = result.value
},
editHeight() {
uni.showModal({
title: '修改身高',
editable: true,
placeholderText: '50-250,单位 cm',
content: String(this.height),
success: (res) => {
if (!res.confirm) return
const result = validateHeight(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.height = result.value
}
})
},
editWeight() {
uni.showModal({
title: '修改体重',
editable: true,
placeholderText: '20-300,单位 kg',
content: String(this.weight),
success: (res) => {
if (!res.confirm) return
const result = validateWeight(res.content)
if (!result.ok) {
showValidationError(result.message)
return
}
this.weight = result.value
}
})
},
toggleGoal(goal) {
const index = this.fitnessGoals.indexOf(goal)
if (index >= 0) {
this.fitnessGoals.splice(index, 1)
return
}
const preview = [...this.fitnessGoals, goal]
const result = validateFitnessGoals(preview, this.fitnessGoalOptions)
if (!result.ok) {
showValidationError(result.message)
return
}
this.fitnessGoals.push(goal)
},
isGoalSelected(goal) {
return this.fitnessGoals.includes(goal)
}
}
}
</script>
<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/user-info-page.css';
@import '@/common/style/memberInfo/pages/user-info-pixso.css';
.user-info-row,
.user-info-measure {
border-radius: 8px;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">训练</text>
<text class="tab-page__subtitle">记录每一次进步</text>
</view>
<view class="train-cards">
<view class="train-card" hover-class="train-card--hover" @tap="goTrainReport">
<text class="train-card__title">训练报告</text>
<text class="train-card__desc">查看本周/本月运动数据与趋势</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goBodyTest">
<text class="train-card__title">智能体测</text>
<text class="train-card__desc">连接设备获取专业体测报告</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goBodyHistory">
<text class="train-card__title">体测报告</text>
<text class="train-card__desc">历史记录与对比分析</text>
</view>
<view class="train-card" hover-class="train-card--hover" @tap="goCheckIn">
<text class="train-card__title">签到记录</text>
<text class="train-card__desc">到店打卡与训练频次</text>
</view>
</view>
<view class="bottom-placeholder"></view>
<TabBar :active="2" />
</view>
</template>
<script setup>
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goTrainReport() {
navigateToPage(PAGE.TRAIN_REPORT)
}
function goBodyTest() {
navigateToPage(PAGE.BODY_TEST_HOME)
}
function goBodyHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
}
function goCheckIn() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
}
</script>
<style lang="scss" scoped>
.tab-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #1a202c;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.train-cards {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.train-card {
padding: 28rpx 32rpx;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.train-card__title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #1e2a3a;
}
.train-card__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
@@ -0,0 +1,239 @@
const fs = require('fs');
const path = require('path');
const CSS_DIR = path.join(__dirname, '../common/style/memberInfo/pages');
const STYLE_PREFIX = '@/common/style/memberInfo/pages';
const PAGES = {
booking: {
cssFile: 'booking-pixso.css',
pageCssFile: 'booking-page.css',
rootClass: 'Pixso-frame-2_964',
backFrameClass: 'Pixso-frame-2_969',
extraData: `,
activeTab: 'ongoing'`,
extraMethods: `,
switchTab(tab) {
this.activeTab = tab
},
cancelBooking() {
uni.showModal({
title: '取消预约',
content: '确定要取消该预约吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已取消', icon: 'success' })
}
}
})
}`
},
memberCard: {
cssFile: 'member-card-pixso.css',
pageCssFile: 'member-card-page.css',
rootClass: 'Pixso-frame-2_877',
backFrameClass: 'Pixso-frame-2_882',
extraData: `,
activeFilter: 'all'`,
extraMethods: `,
switchFilter(filter) {
this.activeFilter = filter
},
renewCard() {
uni.showToast({ title: '续费功能开发中', icon: 'none' })
}`
},
userInfo: {
cssFile: 'user-info-pixso.css',
pageCssFile: 'user-info-page.css',
rootClass: 'Pixso-frame-2_791',
backFrameClass: 'Pixso-frame-2_796',
navWrapperClass: 'stroke-wrapper-2_795',
navContentClass: 'frame-content-2_795',
navTitleClass: 'Pixso-paragraph-2_799',
saveFrameClass: 'Pixso-frame-2_800',
extraData: `,
name: '张小芳',
phone: '138****6789',
gender: 'female',
birthday: '1995年06月15日',
height: '165',
weight: '63.5',
fitnessGoals: ['增肌']`,
extraMethods: `,
handleSave() {
uni.showToast({ title: '保存成功', icon: 'success' })
},
changeAvatar() {
uni.showToast({ title: '更换头像功能开发中', icon: 'none' })
},
rebindPhone() {
uni.showToast({ title: '换绑功能开发中', icon: 'none' })
},
selectGender(gender) {
this.gender = gender
},
toggleGoal(goal) {
const index = this.fitnessGoals.indexOf(goal)
if (index >= 0) {
this.fitnessGoals.splice(index, 1)
} else {
this.fitnessGoals.push(goal)
}
},
isGoalSelected(goal) {
return this.fitnessGoals.includes(goal)
}`
}
};
function alignCss(css) {
let result = css.replace(/@import\s+['"]\.\/base\.css['"];\s*/g, '');
const replacements = [
['url(@/assets/images/', 'url(@/static/images/'],
['box-shadow: 0px 8px 16px 0px rgba(255, 107, 53, 0.25098039215686274)', 'box-shadow: var(--shadow-orange-glow)'],
['background-color: rgba(255, 255, 255, 1)', 'background-color: var(--bg-white)'],
['color: rgba(255, 255, 255, 1)', 'color: var(--text-inverse)'],
['border-color: rgba(255, 255, 255, 1)', 'border-color: var(--bg-white)'],
['rgba(11, 43, 75, 1)', 'var(--primary-dark)'],
['rgba(26, 74, 111, 1)', 'var(--primary-deep)'],
['rgba(255, 107, 53, 1)', 'var(--accent-orange)'],
['rgba(255, 140, 90, 1)', 'var(--accent-orange-light)'],
['rgba(249, 250, 254, 1)', 'var(--bg-light)'],
['rgba(30, 42, 58, 1)', 'var(--text-dark)'],
['rgba(94, 111, 141, 1)', 'var(--text-muted)'],
['rgba(138, 153, 180, 1)', 'var(--text-light)'],
['rgba(233, 237, 242, 1)', 'var(--border-light)'],
['rgba(46, 204, 113, 1)', 'var(--success-green)'],
['rgba(231, 76, 60, 1)', 'var(--error-red)'],
['height: 900px', 'height: auto;\n min-height: 100%'],
['height: 2300px', 'height: auto;\n min-height: 100%'],
['height: 1901px', 'height: auto']
];
for (const [from, to] of replacements) {
result = result.split(from).join(to);
}
return result;
}
function convertTemplate(template, config) {
let result = template.replace(/\r\n/g, '\n');
result = result.replace(/<div\b/g, '<view');
result = result.replace(/<\/div>/g, '</view>');
result = result.replace(/<p\b/g, '<text');
result = result.replace(/<\/p>/g, '</text>');
result = result.replace(/\s+id="[^"]*"/g, '');
result = result.replace(/\{\{\s*"([^"]*?)"\s*\}\}/gs, '$1');
result = result.replace(/\{\{\s*'([^']*?)'\s*\}\}/gs, '$1');
result = result.replace(
'<view class="scroll-container">',
'<view class="scroll-container theme-light">'
);
result = result.replace(/>\s*9:41\s*</g, '>{{ statusBarTime }}<');
if (config.backFrameClass) {
result = result.replace(
new RegExp(`class="${config.backFrameClass}"`, 'g'),
`class="${config.backFrameClass} nav-back" @tap.stop="goBack"`
);
}
if (config.navTitleClass) {
result = result.replace(
new RegExp(`class="${config.navTitleClass}"`, 'g'),
`class="${config.navTitleClass} nav-title"`
);
}
if (config.saveFrameClass) {
result = result.replace(
new RegExp(`class="${config.saveFrameClass}"`, 'g'),
`class="${config.saveFrameClass}" @tap="handleSave"`
);
}
if (config.bindings) {
for (const { from, to } of config.bindings) {
result = result.replace(from, to);
}
}
return result.trim();
}
function buildPageCss(config) {
return `@import './sub-page-base.css';
.${config.rootClass} {
width: 100%;
box-sizing: border-box;
}
`;
}
function convertPage(pageName, config) {
const vuePath = path.join(__dirname, `../pages/memberInfo/${pageName}.vue`);
const cssPath = path.join(CSS_DIR, config.cssFile);
const pageCssPath = path.join(CSS_DIR, config.pageCssFile);
const content = fs.readFileSync(vuePath, 'utf8').replace(/\r\n/g, '\n');
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/);
const styleMatch = content.match(/<style>([\s\S]*?)<\/style>/);
if (!templateMatch || !styleMatch) {
throw new Error(`Could not parse ${pageName}.vue`);
}
const template = convertTemplate(templateMatch[1], config);
const css = alignCss(styleMatch[1].trim());
const pageCss = buildPageCss(config);
const extraMethods = (config.extraMethods || '').replace(/^\s*,\s*/, '');
const extraDataBlock = (config.extraData || '').replace(/^\s*,\s*/, '');
const dataBody = pageName === 'userInfo'
? '...userInfoMock'
: extraDataBlock;
const vueOutput = `<template>
${template}
</template>
<script>
import { statusBarTimeMixin, subPageMixin } from '@/common/memberInfo/mixins.js'${pageName === 'userInfo' ? "\nimport { userInfoMock } from '@/common/memberInfo/mockData.js'" : ''}
export default {
mixins: [statusBarTimeMixin, subPageMixin],
data() {
return {
${dataBody}
}
},
methods: {
${extraMethods}
}
}
</script>
<style>
@import '${STYLE_PREFIX}/${config.pageCssFile}';
@import '${STYLE_PREFIX}/${config.cssFile}';
</style>
`;
fs.writeFileSync(cssPath, css + '\n', 'utf8');
fs.writeFileSync(pageCssPath, pageCss, 'utf8');
fs.writeFileSync(vuePath, vueOutput, 'utf8');
console.log(`converted ${pageName}.vue -> ${config.cssFile}`);
}
const target = process.argv[2];
const entries = target
? [[target, PAGES[target]]].filter(([, config]) => config)
: Object.entries(PAGES);
for (const [pageName, config] of entries) {
convertPage(pageName, config);
}
console.log('done');
@@ -0,0 +1,293 @@
/**
* 微信小程序组件/页面 WXSS 不支持本地 background-image
* 将 /static/images/ 背景图改为 <image> 标签,并清理 CSS。
*/
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const COMPONENT_MAP = {
MemberInfoStatusBar: 'member-info-status-bar.css',
MemberInfoHeader: 'member-info-header.css',
MemberInfoMemberCard: 'member-info-member-card.css',
MemberInfoQuickActions: 'member-info-quick-actions.css',
MemberInfoBookingList: 'member-info-booking-list.css',
MemberInfoCheckInList: 'member-info-check-in-list.css',
MemberInfoBodyReport: 'member-info-body-report.css',
MemberInfoCouponPoints: 'member-info-coupon-points.css',
MemberInfoReferral: 'member-info-referral.css',
MemberInfoSettings: 'member-info-settings.css',
MemberInfoLogout: 'member-info-logout.css'
};
const PAGE_MAP = {
booking: ['booking-pixso.css'],
memberCard: ['member-card-pixso.css'],
userInfo: ['user-info-pixso.css']
};
function extractBgImages(css) {
const map = new Map();
const ruleRe = /^\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/gm;
let match;
while ((match = ruleRe.exec(css)) !== null) {
const className = match[1];
const body = match[2];
const urlMatch = body.match(/background-image:\s*url\(\/static\/images\/([^)]+)\)/);
if (urlMatch) {
map.set(className, `/static/images/${urlMatch[1]}`);
}
}
return map;
}
function ensureImageDisplay(css, className) {
const ruleRe = new RegExp(`(\\.${className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{)([^}]*)(\\})`);
return css.replace(ruleRe, (full, head, body, tail) => {
if (/display:\s*block/.test(body)) return full;
return `${head}${body.trim()}\n display: block;\n${tail}`;
});
}
function getMode(className) {
if (/avatar|banner|photo|card-preview|AC\d/i.test(className)) return 'aspectFill';
return 'aspectFit';
}
function replaceViewWithImage(template, className, src) {
const mode = getMode(className);
const imageTag = `<image class="${className}" src="${src}" mode="${mode}" />`;
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [
new RegExp(`<view\\s+class="${escaped}"\\s*></view>`, 'g'),
new RegExp(`<view\\s+class="${escaped}"\\s*/>`, 'g'),
new RegExp(`<view\\s+class="${escaped}"\\s*>\\s*</view>`, 'g'),
new RegExp(`<view\\s+\\n\\s*class="${escaped}"\\s*\\n\\s*></view>`, 'g'),
new RegExp(`<view\\s+\\n\\s*class="${escaped}"\\s*\\n\\s*/>`, 'g'),
new RegExp(`<view\\s+[^>]*class="${escaped}"[^>]*>\\s*</view>`, 'g')
];
let result = template;
for (const re of patterns) {
result = result.replace(re, imageTag);
}
return result;
}
function stripAllLocalBgFromCss(css) {
let next = css;
next = next.replace(/background-image:\s*url\(\/static\/images\/[^)]+\);/g, '');
next = next.replace(/\n\s*background-size:\s*100%\s*100%;/g, '');
next = next.replace(/\n\s*background-repeat:\s*no-repeat;/g, '');
return next;
}
function convertPair(vuePath, cssPath) {
if (!fs.existsSync(vuePath) || !fs.existsSync(cssPath)) return;
let css = fs.readFileSync(cssPath, 'utf8');
const hadLocalBg = /background-image:\s*url\(\/static\/images\//.test(css);
if (!hadLocalBg) return;
const bgMap = extractBgImages(css);
let template = fs.readFileSync(vuePath, 'utf8');
const templateMatch = template.match(/<template>([\s\S]*?)<\/template>/);
if (!templateMatch) {
css = stripAllLocalBgFromCss(css);
fs.writeFileSync(cssPath, css, 'utf8');
console.log('css-only (no template)', path.relative(root, cssPath));
return;
}
let inner = templateMatch[1];
let changed = false;
for (const [className, src] of bgMap.entries()) {
const before = inner;
inner = replaceViewWithImage(inner, className, src);
if (inner !== before) {
changed = true;
css = ensureImageDisplay(css, className);
}
}
css = stripAllLocalBgFromCss(css);
if (changed) {
template = template.replace(templateMatch[1], inner);
fs.writeFileSync(vuePath, template, 'utf8');
}
fs.writeFileSync(cssPath, css, 'utf8');
console.log(
changed ? 'converted' : 'css-only',
path.relative(root, vuePath),
`(${bgMap.size} rules stripped)`
);
}
for (const [name, cssFile] of Object.entries(COMPONENT_MAP)) {
convertPair(
path.join(root, 'components/memberInfo', `${name}.vue`),
path.join(root, 'common/style/memberInfo', cssFile)
);
}
for (const [page, cssFiles] of Object.entries(PAGE_MAP)) {
for (const cssFile of cssFiles) {
convertPair(
path.join(root, 'pages/memberInfo', `${page}.vue`),
path.join(root, 'common/style/memberInfo/pages', cssFile)
);
}
}
console.log('done');
@@ -0,0 +1,14 @@
const fs = require('fs');
const path = require('path');
const dir = path.join(__dirname, '../components/memberInfo');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.vue'));
for (const file of files) {
let content = fs.readFileSync(path.join(dir, file), 'utf8');
content = content.replace(
/export default \{\n\s+options:/,
'export default {\n options:'
);
fs.writeFileSync(path.join(dir, file), content, 'utf8');
}
@@ -0,0 +1,25 @@
const fs = require('fs');
const path = require('path');
const dir = path.join(__dirname, '../components/memberInfo');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.vue'));
const optionsBlock = ` options: {
virtualHost: true,
styleIsolation: 'apply-shared'
},`;
for (const file of files) {
let content = fs.readFileSync(path.join(dir, file), 'utf8');
content = content.replace(/\r\n/g, '\n');
content = content.replace(/\n<style>[\s\S]*?<\/style>\n?/g, '\n');
if (content.includes('options:')) {
content = content.replace(/options:\s*\{[\s\S]*?\},?/m, optionsBlock);
} else {
content = content.replace(/export default \{\n/, `export default {\n${optionsBlock}\n`);
}
fs.writeFileSync(path.join(dir, file), content.endsWith('\n') ? content : content + '\n', 'utf8');
console.log('updated', file);
}
@@ -0,0 +1,90 @@
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const styleDir = path.join(root, 'common/style/memberInfo');
const pagesDir = path.join(styleDir, 'pages');
const componentsDir = path.join(root, 'components/memberInfo');
const cssMap = {
MemberInfoStatusBar: 'member-info-status-bar.css',
MemberInfoHeader: 'member-info-header.css',
MemberInfoMemberCard: 'member-info-member-card.css',
MemberInfoQuickActions: 'member-info-quick-actions.css',
MemberInfoBookingList: 'member-info-booking-list.css',
MemberInfoCheckInList: 'member-info-check-in-list.css',
MemberInfoBodyReport: 'member-info-body-report.css',
MemberInfoCouponPoints: 'member-info-coupon-points.css',
MemberInfoReferral: 'member-info-referral.css',
MemberInfoSettings: 'member-info-settings.css',
MemberInfoLogout: 'member-info-logout.css'
};
function stripCssImports(filePath) {
let css = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const next = css.replace(/^@import\s+[^;]+;\s*\n/gm, '');
if (next !== css) {
fs.writeFileSync(filePath, next.replace(/^\n+/, ''), 'utf8');
console.log('stripped imports:', path.relative(root, filePath));
}
}
// 纯样式文件:去掉嵌套 @import(小程序只认 vue 里第一层 @import
for (const name of fs.readdirSync(styleDir)) {
if (!name.endsWith('.css') || name === 'member-info-all.css') continue;
stripCssImports(path.join(styleDir, name));
}
for (const name of fs.readdirSync(pagesDir)) {
if (!name.endsWith('.css')) continue;
stripCssImports(path.join(pagesDir, name));
}
const componentStyleBlock = (cssFile) => `<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/${cssFile}';
</style>
`;
for (const [name, cssFile] of Object.entries(cssMap)) {
const filePath = path.join(componentsDir, `${name}.vue`);
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
content = content.replace(/\n<style>[\s\S]*?<\/style>\n?/g, '\n');
content = content.trimEnd() + '\n' + componentStyleBlock(cssFile);
fs.writeFileSync(filePath, content, 'utf8');
console.log('updated component:', name);
}
const memberInfoVue = path.join(root, 'pages/memberInfo/memberInfo.vue');
let memberInfoContent = fs.readFileSync(memberInfoVue, 'utf8').replace(/\r\n/g, '\n');
memberInfoContent = memberInfoContent.replace(
/<style>[\s\S]*?<\/style>/,
`<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-page.css';
</style>`
);
fs.writeFileSync(memberInfoVue, memberInfoContent, 'utf8');
console.log('updated memberInfo.vue');
const subPages = {
booking: ['booking-page.css', 'booking-pixso.css'],
memberCard: ['member-card-page.css', 'member-card-pixso.css'],
userInfo: ['user-info-page.css', 'user-info-pixso.css']
};
for (const [page, files] of Object.entries(subPages)) {
const vuePath = path.join(root, `pages/memberInfo/${page}.vue`);
let content = fs.readFileSync(vuePath, 'utf8').replace(/\r\n/g, '\n');
const imports = [
"@import '@/common/style/base.css';",
"@import '@/common/style/memberInfo/pages/page-reset.css';",
"@import '@/common/style/memberInfo/pages/sub-page-base.css';",
...files.map((f) => `@import '@/common/style/memberInfo/pages/${f}';`)
].join('\n');
content = content.replace(/<style>[\s\S]*?<\/style>/, `<style>\n${imports}\n</style>`);
fs.writeFileSync(vuePath, content, 'utf8');
console.log('updated', `${page}.vue`);
}
console.log('done');
@@ -0,0 +1,27 @@
const fs = require('fs');
const path = require('path');
const styleDir = path.join(__dirname, '../common/style');
function walk(dir) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!name.endsWith('.css')) continue;
let css = fs.readFileSync(full, 'utf8');
const next = css
.replace(/url\(@\/static\/images\//g, 'url(/static/images/')
.replace(/url\("@\/static\/images\//g, 'url("/static/images/')
.replace(/url\('@\/static\/images\//g, "url('/static/images/");
if (next !== css) {
fs.writeFileSync(full, next, 'utf8');
console.log('fixed', path.relative(styleDir, full));
}
}
}
walk(styleDir);
console.log('done');
@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
const styleDir = path.join(__dirname, '../common/style/memberInfo');
/** 硬编码 px → 统一字号变量(从大到小替换,避免误匹配) */
const fontSizePxMap = [
['32px', 'var(--font-size-5xl)'],
['28px', 'var(--font-size-4xl)'],
['24px', 'var(--font-size-3xl)'],
['22px', 'var(--font-size-2xl)'],
['20px', 'var(--font-size-xl)'],
['18px', 'var(--font-size-lg)'],
['17px', 'var(--font-size-md)'],
['16px', 'var(--font-size-md)'],
['15px', 'var(--font-size-base)'],
['14px', 'var(--font-size-base)'],
['13px', 'var(--font-size-base)'],
['12px', 'var(--font-size-sm)'],
['11px', 'var(--font-size-xs)'],
['10px', 'var(--font-size-xs)']
];
function walk(dir) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!name.endsWith('.css')) continue;
normalize(full);
}
}
function normalize(filePath) {
let css = fs.readFileSync(filePath, 'utf8');
let next = css;
const fontFamilyMap = [
['"Noto Sans SC-Bold"', 'var(--font-family)'],
['"Noto Sans SC-Medium"', 'var(--font-family)'],
['"Noto Sans SC-Regular"', 'var(--font-family)'],
['"Noto Sans SC-SemiBold"', 'var(--font-family)'],
['"Inter-Semi Bold"', 'var(--font-family)'],
['"Inter-Bold"', 'var(--font-family)'],
['"Inter-Regular"', 'var(--font-family)']
];
for (const [from, to] of fontFamilyMap) {
next = next.split(`font-family: ${from}`).join(`font-family: ${to}`);
}
const weightMap = [
['font-weight: Bold', 'font-weight: 700'],
['font-weight: Medium', 'font-weight: 500'],
['font-weight: Regular', 'font-weight: 400'],
['font-weight: SemiBold', 'font-weight: 600'],
['font-weight: Semi Bold', 'font-weight: 600']
];
for (const [from, to] of weightMap) {
next = next.split(from).join(to);
}
next = next.replace(/white-space:\s*pre;/g, 'white-space: nowrap;');
for (const [px, token] of fontSizePxMap) {
next = next.replace(new RegExp(`font-size:\\s*${px.replace('.', '\\.')};`, 'g'), `font-size: ${token};`);
}
if (next !== css) {
fs.writeFileSync(filePath, next, 'utf8');
console.log('normalized', path.relative(styleDir, filePath));
}
}
walk(styleDir);
console.log('done');
@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const componentsDir = path.join(__dirname, '../components/memberInfo');
const cssMap = {
MemberInfoStatusBar: 'member-info-status-bar.css',
MemberInfoHeader: 'member-info-header.css',
MemberInfoMemberCard: 'member-info-member-card.css',
MemberInfoQuickActions: 'member-info-quick-actions.css',
MemberInfoBookingList: 'member-info-booking-list.css',
MemberInfoCheckInList: 'member-info-check-in-list.css',
MemberInfoBodyReport: 'member-info-body-report.css',
MemberInfoCouponPoints: 'member-info-coupon-points.css',
MemberInfoReferral: 'member-info-referral.css',
MemberInfoSettings: 'member-info-settings.css',
MemberInfoLogout: 'member-info-logout.css'
};
for (const [name, cssFile] of Object.entries(cssMap)) {
const filePath = path.join(componentsDir, `${name}.vue`);
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
content = content.replace(/\n<style>[\s\S]*?<\/style>\n?/g, '\n');
const styleBlock = `\n<style>
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/${cssFile}';
</style>\n`;
if (!content.trimEnd().endsWith('</script>')) {
console.warn('skip unexpected format:', name);
continue;
}
content = content.trimEnd() + styleBlock;
fs.writeFileSync(filePath, content, 'utf8');
console.log('restored styles:', name);
}
const pageFiles = [
path.join(__dirname, '../pages/memberInfo/memberInfo.vue'),
path.join(__dirname, '../pages/memberInfo/userInfo.vue')
];
for (const pagePath of pageFiles) {
let content = fs.readFileSync(pagePath, 'utf8');
content = content.replace(
"@import '@/common/style/member-info-all.css';",
"@import '@/common/style/memberInfo/member-info-page.css';"
);
content = content.replace(
"@import '@/common/style/member-info-page.css';",
"@import '@/common/style/memberInfo/member-info-page.css';"
);
fs.writeFileSync(pagePath, content, 'utf8');
console.log('updated page:', path.basename(pagePath));
}
console.log('done');
@@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const componentsDir = path.join(root, 'components/memberInfo');
const componentFiles = fs.readdirSync(componentsDir).filter((name) => name.endsWith('.vue'));
for (const file of componentFiles) {
const filePath = path.join(componentsDir, file);
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const next = content.replace(/\n<style>[\s\S]*?<\/style>\n?/g, '\n');
if (next !== content) {
fs.writeFileSync(filePath, next.trimEnd() + '\n', 'utf8');
console.log('removed style block:', file);
}
}
console.log('done');
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Some files were not shown because too many files have changed in this diff Show More