实现大部分页面前后端互通

This commit is contained in:
2026-07-17 15:48:36 +08:00
parent a9ccdab421
commit 2e7c2ced43
7 changed files with 560 additions and 215 deletions
@@ -158,6 +158,7 @@ public class MemberServiceImpl implements MemberService {
return MemberInfoVO.builder()
.id(member.getId())
.memberNo(member.getMemberNo())
.nickname(member.getNickname())
.phone(maskedPhone)
.gender(genderEnum)
@@ -166,6 +167,7 @@ public class MemberServiceImpl implements MemberService {
.avatar(member.getAvatar())
.hasPhone(phone != null)
.isSubscribed(member.getSubscribed() != null && member.getSubscribed())
.lastLoginAt(member.getLastLoginAt())
.build();
}
@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
/**
@@ -25,6 +26,9 @@ public class MemberInfoVO {
// 会员 ID
private Long id;
// 会员编号
private String memberNo;
// 昵称
private String nickname;
@@ -48,4 +52,7 @@ public class MemberInfoVO {
// 是否已关注公众号
private Boolean isSubscribed;
// 最后登录时间
private LocalDateTime lastLoginAt;
}
+9 -3
View File
@@ -50,15 +50,21 @@
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
"text": "首页",
"iconPath": "static/icon/shouye.png",
"selectedIconPath": "static/icon/shouye.png"
},
{
"pagePath": "pages/search/search",
"text": "课程"
"text": "课程",
"iconPath": "static/icon/jianshen.png",
"selectedIconPath": "static/icon/jianshen.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的"
"text": "我的",
"iconPath": "static/icon/wode.png",
"selectedIconPath": "static/icon/wode.png"
}
]
},
@@ -1,116 +1,124 @@
<template>
<view class="detail-root">
<view class="detail-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8249;</text>
<text class="nav-title">课程详情</text>
</view>
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8249;</text>
<text class="nav-title">课程详情</text>
</view>
</view>
<view class="cover-area">
<view class="cover-bg">
<text class="cover-text">{{ course.typeChar }}</text>
</view>
<view class="status-tag" v-if="course.isFull">已约满</view>
<view class="cover-area">
<image v-if="course.coverImage" class="cover-img" :src="course.coverImage" mode="aspectFill"
@error="course.coverError = true" />
<view v-if="!course.coverImage || course.coverError" class="cover-bg">
<text class="cover-text">{{ course.typeName ? course.typeName.slice(0, 2) : '课程' }}</text>
</view>
<view class="status-tag" :class="course.statusClass" v-if="course.statusText">{{ course.statusText }}</view>
</view>
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<view class="course-header">
<view class="header-top">
<text class="course-title">{{ course.courseName }}</text>
<view class="difficulty-badge" :class="'diff-' + course.difficultyLevel">
{{ course.difficultyLabel }}
</view>
</view>
<view class="header-meta">
<text class="meta-type">{{ course.tag }}</text>
</view>
</view>
<view class="info-card">
<view class="info-row">
<view class="info-item">
<text class="info-label">教练</text>
<text class="info-value">{{ course.coachName }}</text>
</view>
<view class="info-item">
<text class="info-label">地点</text>
<text class="info-value">{{ course.location }}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row">
<view class="info-item">
<text class="info-label">日期时间</text>
<text class="info-value">{{ course.startTime }} - {{ course.endTime }}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row">
<view class="info-item">
<text class="info-label">时长</text>
<text class="info-value">{{ course.duration }} 分钟</text>
</view>
<view class="info-item">
<text class="info-label">人数</text>
<text class="info-value">
{{ course.currentMembers }} / {{ course.maxMembers }}
<text v-if="course.maxMembers - course.currentMembers > 0" class="remain-text">
({{ course.maxMembers - course.currentMembers }})
</text>
</text>
</view>
</view>
</view>
<view class="section-card">
<text class="section-title">&#8226; 课程介绍</text>
<text class="desc-text">{{ course.description }}</text>
</view>
<view class="section-card">
<text class="section-title">&#8226; 课程信息</text>
<view class="detail-grid">
<view class="detail-item">
<text class="detail-label">课程类型</text>
<text class="detail-value">{{ course.typeName || '团课' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">基础难度</text>
<view class="stars">
<text v-for="i in 10" :key="i" class="star" :class="{ filled: i <= course.difficulty }">
{{ i <= course.difficulty ? '&#9733;' : '&#9734;' }}
</text>
<view class="course-header">
<view class="header-top">
<text class="course-title">{{ course.courseName }}</text>
<view class="difficulty-badge" :class="'diff-' + course.difficultyLevel" v-if="course.difficultyLabel">
{{ course.difficultyLabel }}
</view>
</view>
<view class="detail-item">
<text class="detail-label">分类</text>
<text class="detail-value">{{ course.categoryLabel || '综合' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">储值消耗</text>
<text class="detail-value price">{{ course.storedValueAmount > 0 ? course.storedValueAmount + '元' : '免费' }}</text>
<view class="header-meta" v-if="course.category">
<text class="meta-type">{{ course.category }}</text>
<text class="meta-type-name" v-if="course.typeName"> {{ course.typeName }}</text>
</view>
</view>
</view>
<view class="bottom-safe"></view>
<view class="info-card">
<view class="info-row">
<view class="info-item">
<text class="info-label">教练</text>
<text class="info-value">{{ course.coachName || '-' }}</text>
</view>
<view class="info-item">
<text class="info-label">地点</text>
<text class="info-value">{{ course.location || '-' }}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row">
<view class="info-item">
<text class="info-label">日期</text>
<text class="info-value">{{ course.fullDate }}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row">
<view class="info-item">
<text class="info-label">时间</text>
<text class="info-value">{{ course.shortTime }} - {{ course.endShortTime }}</text>
</view>
<view class="info-item">
<text class="info-label">时长</text>
<text class="info-value">{{ course.duration }}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row">
<view class="info-item">
<text class="info-label">人数</text>
<text class="info-value">
{{ course.currentMembers }} / {{ course.maxMembers }}
<text v-if="course.maxMembers - course.currentMembers > 0" class="remain-text">
({{ course.maxMembers - course.currentMembers }})
</text>
</text>
</view>
<view class="info-item">
<text class="info-label">储值消耗</text>
<text class="info-value price">{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}</text>
</view>
</view>
</view>
<view class="section-card" v-if="course.description">
<text class="section-title">&#8226; 课程介绍</text>
<text class="desc-text">{{ course.description }}</text>
</view>
<view class="section-card">
<text class="section-title">&#8226; 课程信息</text>
<view class="detail-grid">
<view class="detail-item" v-if="course.typeName">
<text class="detail-label">课程类型</text>
<text class="detail-value">{{ course.typeName }}</text>
</view>
<view class="detail-item">
<text class="detail-label">基础难度</text>
<view class="stars">
<text v-for="i in 10" :key="i" class="star" :class="{ filled: i <= (course.difficulty || 0) }">
{{ i <= (course.difficulty || 0) ? '&#9733;' : '&#9734;' }}
</text>
</view>
</view>
<view class="detail-item" v-if="course.category">
<text class="detail-label">分类</text>
<text class="detail-value">{{ course.category }}</text>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>
</scroll-view>
</view>
</scroll-view>
<view class="bottom-bar">
<view class="bottom-info">
<text class="bottom-price" :class="{ free: course.storedValueAmount === 0 }">
{{ course.storedValueAmount > 0 ? course.storedValueAmount : '免费' }}
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount : '免费' }}
</text>
<text class="bottom-desc">/ </text>
</view>
<button class="booking-btn" :class="{ disabled: course.isFull }" :disabled="course.isFull"
<button class="booking-btn" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
@click="handleBooking">
{{ course.isFull ? '已约满' : '立即预约' }}
{{ course.btnText }}
</button>
</view>
</view>
@@ -129,7 +137,34 @@
totalHeaderHeight: 44,
loading: true,
submitting: false,
course: { id: 0, courseName: '加载中...', coachName: '', location: '', typeChar: '课', tag: '', category: '', categoryLabel: '', typeName: '', startTime: '', endTime: '', duration: 0, maxMembers: 0, currentMembers: 0, isFull: false, difficulty: 0, difficultyLevel: 'mid', difficultyLabel: '', storedValueAmount: 0, description: '' }
course: {
id: 0,
courseName: '加载中...',
coachName: '',
location: '',
typeName: '',
category: '',
startTime: '',
endTime: '',
fullDate: '',
shortTime: '--',
endShortTime: '--',
duration: '',
maxMembers: 0,
currentMembers: 0,
isFull: false,
canBook: false,
btnText: '立即预约',
difficulty: 0,
difficultyLevel: 'mid',
difficultyLabel: '',
storedValueAmount: 0,
description: '',
statusText: '',
statusClass: '',
coverImage: '',
coverError: false
}
}
},
onLoad(options) {
@@ -153,36 +188,99 @@
try {
const detail = await courseApi.getCourseDetail(id)
if (detail) {
const bd = detail.calculatedDifficulty || detail.baseDifficulty || 5
const st = detail.startTime || ''
const et = detail.endTime || ''
const duration = st && et ? Math.round((new Date(et.replace(/-/g, '/')) - new Date(st.replace(/-/g, '/'))) / 60000) : 45
// 基础难度:优先 calculatedDifficulty,其次 baseDifficulty
const bd = detail.calculatedDifficulty || detail.baseDifficulty || 0
// 名称来源:API 可能已 join 返回 typeName / coachName / category
const tn = detail.typeName || (detail.typeInfo && detail.typeInfo.typeName) || ''
const cat = detail.typeCategory || (detail.typeInfo && detail.typeInfo.category) || ''
const desc = detail.description || (detail.typeInfo && detail.typeInfo.description) || ''
// 日期解析(兼容 ISO 格式 "2026-07-15T16:45:00" 和空格格式)
const st = detail.startTime || ''
const et = detail.endTime || ''
const dt = st ? new Date(st.replace(/ /, 'T')) : null
const edt = et ? new Date(et.replace(/ /, 'T')) : null
// 格式化
const hh = dt && !isNaN(dt) ? String(dt.getHours()).padStart(2, '0') : ''
const mm = dt && !isNaN(dt) ? String(dt.getMinutes()).padStart(2, '0') : ''
const ehh = edt && !isNaN(edt) ? String(edt.getHours()).padStart(2, '0') : ''
const emm = edt && !isNaN(edt) ? String(edt.getMinutes()).padStart(2, '0') : ''
const y = dt && !isNaN(dt) ? String(dt.getFullYear()) : ''
const mo = dt && !isNaN(dt) ? String(dt.getMonth() + 1).padStart(2, '0') : ''
const d = dt && !isNaN(dt) ? String(dt.getDate()).padStart(2, '0') : ''
// 时长
let durationStr = ''
if (dt && edt && !isNaN(dt) && !isNaN(edt)) {
const diffMin = Math.round((edt - dt) / 60000)
if (diffMin > 0) {
const hours = Math.floor(diffMin / 60)
const mins = diffMin % 60
durationStr = hours > 0 ? hours + '小时' + (mins > 0 ? mins + '分钟' : '') : diffMin + '分钟'
}
}
// 状态:数据库 status 字段 (0正常 1已取消 2已结束 3进行中 4超时)
const statusStr = detail.status != null ? Number(detail.status) : 0
const curMembers = detail.currentMembers != null ? detail.currentMembers : 0
const maxMems = detail.maxMembers != null ? detail.maxMembers : 0
const isFull = maxMems > 0 && curMembers >= maxMems
let statusText = ''
let statusClass = ''
let canBook = false
let btnText = '立即预约'
if (statusStr === 0) {
canBook = !isFull
btnText = isFull ? '已约满' : '立即预约'
statusText = isFull ? '已约满' : ''
} else if (statusStr === 1) {
statusText = '已取消'
statusClass = 'status-cancel'
btnText = '已取消'
} else if (statusStr === 2) {
statusText = '已结束'
statusClass = 'status-end'
btnText = '已结束'
} else if (statusStr === 3) {
statusText = '进行中'
statusClass = 'status-going'
btnText = '进行中'
} else if (statusStr === 4) {
statusText = '已超时'
statusClass = 'status-timeout'
btnText = '已超时'
}
this.course = {
id: detail.id || parseInt(id),
courseName: detail.courseName || '未命名课程',
coachName: detail.coachName || '',
location: detail.location || '',
typeChar: tn.charAt(0) || '课',
tag: cat || '团课',
category: cat,
categoryLabel: cat,
typeName: tn,
category: cat,
description: desc,
startTime: st,
endTime: et,
duration: duration,
maxMembers: detail.maxMembers || 0,
currentMembers: detail.currentMembers || 0,
isFull: (detail.currentMembers || 0) >= (detail.maxMembers || 1) && detail.maxMembers > 0,
fullDate: y + '-' + mo + '-' + d,
shortTime: (hh && mm) ? hh + ':' + mm : '--',
endShortTime: (ehh && emm) ? ehh + ':' + emm : '--',
duration: durationStr,
maxMembers: maxMems,
currentMembers: curMembers,
isFull: isFull,
canBook: canBook,
btnText: btnText,
difficulty: bd,
difficultyLevel: bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high',
difficultyLabel: bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难',
storedValueAmount: detail.storedValueAmount || 0,
description: detail.description || '暂无课程介绍',
labels: detail.labels || [],
coverImage: detail.coverImage || ''
difficultyLabel: bd > 0 ? (bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难') : '',
storedValueAmount: detail.storedValueAmount != null ? detail.storedValueAmount : 0,
statusText: statusText,
statusClass: statusClass,
coverImage: detail.coverImage || '',
coverError: false
}
}
} catch (e) {
@@ -194,7 +292,7 @@
},
goBack() { uni.navigateBack() },
async handleBooking() {
if (this.course.isFull || this.submitting) return
if (!this.course.canBook || this.submitting) return
const memberInfo = store.getMemberInfo()
if (!memberInfo || !memberInfo.memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
@@ -222,43 +320,63 @@
<style scoped>
.detail-page { min-height: 100vh; background: #F5F7FA; overflow-x: hidden; }
.header-wrap { background: #1A1A1A; }
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
.back-btn { font-size: 26px; color: #FFFFFF; font-weight: 700; margin-right: 10px; line-height: 1; }
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
.cover-area { height: 180px; position: relative; overflow: hidden; }
/* 封面区 */
.cover-area { height: 200px; position: relative; overflow: hidden; }
.cover-img { width: 100%; height: 100%; }
.cover-bg { width: 100%; height: 100%; background: linear-gradient(135deg, #00C853, #00BFA5); display: flex; align-items: center; justify-content: center; }
.cover-text { font-size: 56px; font-weight: 700; color: rgba(255,255,255,0.9); }
.status-tag { position: absolute; top: 14px; right: 16px; background: #FF5252; color: #FFFFFF; font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 40px; }
.status-tag { position: absolute; top: 14px; right: 16px; color: #FFFFFF; font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 40px; }
.status-cancel { background: #FF5252; }
.status-end { background: #9E9E9E; }
.status-going { background: #00C853; }
.status-timeout { background: #FF9800; }
/* 已约满不是DB status,是计算出来的 */
.status-tag:not(.status-cancel):not(.status-end):not(.status-going):not(.status-timeout) { background: #FF5252; }
/* 课程信息卡片 */
.content-inner { padding-bottom: 100px; }
.course-header { background: #FFFFFF; padding: 20px 16px 16px; border-radius: 20px 20px 0 0; margin-top: -16px; position: relative; z-index: 1; }
.header-top { display: flex; justify-content: space-between; align-items: center; }
.course-title { font-size: 22px; font-weight: 700; color: #1E1E1E; }
.difficulty-badge { padding: 4px 12px; border-radius: 40px; font-size: 12px; font-weight: 600; flex-shrink: 0; }
.course-title { font-size: 22px; font-weight: 700; color: #1E1E1E; flex: 1; }
.difficulty-badge { padding: 4px 12px; border-radius: 40px; font-size: 12px; font-weight: 600; flex-shrink: 0; margin-left: 10px; }
.diff-low { background: rgba(0,230,118,0.15); color: #00C853; }
.diff-mid { background: rgba(255,165,2,0.15); color: #FFA502; }
.diff-high { background: rgba(255,82,82,0.15); color: #FF5252; }
.header-meta { margin-top: 8px; }
.header-meta { margin-top: 8px; display: flex; align-items: center; }
.meta-type { font-size: 13px; color: #00C853; background: rgba(0,230,118,0.15); padding: 3px 10px; border-radius: 20px; font-weight: 500; }
.meta-type-name { font-size: 13px; color: #7A7E84; margin-left: 6px; }
.info-card { background: #FFFFFF; margin: 0 16px; border-radius: 20px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); margin-top: 12px; }
.info-row { display: flex; justify-content: space-between; }
.info-item { display: flex; flex-direction: column; flex: 1; }
.info-label { font-size: 12px; color: #7A7E84; margin-bottom: 4px; }
.info-value { font-size: 14px; font-weight: 600; color: #1E1E1E; }
.info-value.price { color: #00C853; }
.remain-text { font-size: 12px; color: #00C853; font-weight: 500; }
.divider { height: 1px; background: #EEEEEE; margin: 14px 0; }
.section-card { background: #FFFFFF; margin: 12px 16px; border-radius: 20px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.section-title { font-size: 16px; font-weight: 700; color: #1E1E1E; display: block; margin-bottom: 12px; }
.desc-text { font-size: 14px; color: #7A7E84; line-height: 1.7; }
.detail-grid { display: flex; flex-wrap: wrap; }
.detail-item { width: 50%; display: flex; flex-direction: column; margin-bottom: 14px; }
.detail-label { font-size: 12px; color: #7A7E84; }
.detail-value { font-size: 14px; font-weight: 600; color: #1E1E1E; margin-top: 4px; }
.detail-value.price { color: #00C853; }
.stars { display: flex; flex-wrap: wrap; }
.star { font-size: 13px; color: #E0E0E0; }
.star.filled { color: #FFA502; }
.bottom-safe { height: 30px; }
/* 底部栏 */
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #FFFFFF; padding: 12px 16px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); display: flex; align-items: center; justify-content: space-between; box-shadow: 0 -2px 12px rgba(0,0,0,0.06); z-index: 100; }
.bottom-info { display: flex; align-items: baseline; }
.bottom-price { font-size: 22px; font-weight: 700; color: #00C853; }
+199 -67
View File
@@ -10,77 +10,113 @@
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<view class="user-card">
<view class="user-row">
<view class="big-avatar"><text>{{ avatarText }}</text></view>
<view class="user-info">
<text class="user-name">{{ userInfo.nickname }}</text>
<view class="user-tag">
<text>{{ userInfo.memberNo }}</text>
<view class="user-row">
<image class="big-avatar-img" :src="avatarSrc" mode="aspectFill" @error="onAvatarError" />
<view class="user-info">
<text class="user-name">{{ userInfo.nickname }}</text>
<view class="user-tag">
<text>会员号{{ userInfo.memberNo }}</text>
</view>
</view>
<text class="edit-symbol" @click="openEdit">&#9998;</text>
</view>
<view class="user-details">
<view class="detail-row">
<text class="detail-label">手机号</text>
<text class="detail-value">{{ userInfo.phone }}</text>
</view>
<view class="detail-row">
<text class="detail-label">性别</text>
<text class="detail-value">{{ genderLabel }}</text>
</view>
<view class="detail-row">
<text class="detail-label">生日</text>
<text class="detail-value">{{ userInfo.birthday || '未设置' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">上次登录</text>
<text class="detail-value">{{ userInfo.lastLoginAt || '暂无' }}</text>
</view>
</view>
<text class="edit-symbol" @click="editProfile">&#9998;</text>
</view>
<view class="user-details">
<view class="detail-row">
<text class="detail-label">手机号</text>
<text class="detail-value">{{ userInfo.phone }}</text>
</view>
<view class="detail-row">
<text class="detail-label">性别</text>
<text class="detail-value">{{ genderLabel }}</text>
</view>
<view class="detail-row">
<text class="detail-label">生日</text>
<text class="detail-value">{{ userInfo.birthday || '未设置' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">上次登录</text>
<text class="detail-value">{{ userInfo.lastLoginAt || '暂无' }}</text>
</view>
</view>
</view>
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ userInfo.totalSignInDays }}</text>
<text class="stat-label">累计签到()</text>
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ userInfo.totalSignInDays }}</text>
<text class="stat-label">累计签到()</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ userInfo.monthSignInCount }}</text>
<text class="stat-label">本月签到</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ userInfo.monthSignInCount }}</text>
<text class="stat-label">本月签到</text>
</view>
</view>
<view class="sign-section">
<view class="sign-header">
<view class="section-title-row">
<text class="section-symbol">&#9776;</text>
<text class="section-title">最近签到</text>
<view class="sign-section">
<view class="sign-header">
<view class="section-title-row">
<text class="section-symbol">&#9776;</text>
<text class="section-title">最近签到</text>
</view>
<text class="section-more" @click="viewAllSignIn">查看全部</text>
</view>
<text class="section-more" @click="viewAllSignIn">查看全部</text>
</view>
<view v-for="(record, idx) in signInRecords" :key="idx" class="sign-item">
<view class="sign-left">
<text class="sign-date">{{ record.date }}</text>
<text class="sign-time">{{ record.time }}</text>
</view>
<view class="sign-right">
<text class="sign-type">{{ record.typeLabel }}</text>
<text class="sign-source">{{ record.sourceLabel }}</text>
<text class="sign-status" :class="record.status">{{ record.statusLabel }}</text>
<view v-for="(record, idx) in signInRecords" :key="idx" class="sign-item">
<view class="sign-left">
<text class="sign-date">{{ record.date }}</text>
<text class="sign-time">{{ record.time }}</text>
</view>
<view class="sign-right">
<text class="sign-type">{{ record.typeLabel }}</text>
<text class="sign-source">{{ record.sourceLabel }}</text>
<text class="sign-status" :class="record.status">{{ record.statusLabel }}</text>
</view>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>
</scroll-view>
<!-- 编辑资料弹窗 -->
<view v-if="showEdit" class="edit-overlay" @click="closeEdit">
<view class="edit-modal" @click.stop>
<view class="edit-modal-header">
<text class="edit-modal-title">编辑资料</text>
<text class="edit-modal-close" @click="closeEdit">&#10005;</text>
</view>
<scroll-view class="edit-modal-body" scroll-y>
<view class="edit-field">
<text class="edit-label">昵称</text>
<input class="edit-input" type="text" :value="editForm.nickname" @input="onNicknameInput" placeholder="请输入昵称" />
</view>
<view class="edit-field">
<text class="edit-label">性别</text>
<picker class="edit-picker" mode="selector" :range="genderOptions" range-key="label" :value="genderIndex" @change="onGenderChange">
<view class="edit-picker-value">{{ genderOptions[genderIndex].label }}</view>
</picker>
</view>
<view class="edit-field">
<text class="edit-label">生日</text>
<picker class="edit-picker" mode="date" :value="editForm.birthday" :end="today" @change="onBirthdayChange">
<view class="edit-picker-value">{{ editForm.birthday || '请选择生日' }}</view>
</picker>
</view>
</scroll-view>
<view class="edit-modal-footer">
<button class="edit-btn cancel" @click="closeEdit">取消</button>
<button class="edit-btn confirm" :disabled="saving" @click="saveProfile">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script>
const store = require('../../store/index')
const memberApi = require('../../api/member')
const defaultAvatar = require('../../common/img/20200414210134_qbeyi.jpg')
export default {
data() {
@@ -89,8 +125,22 @@
navBarHeight: 44,
totalHeaderHeight: 44,
loading: true,
userInfo: { nickname: '加载中...', memberNo: '', phone: '', gender: 0, birthday: '', lastLoginAt: '', totalSignInDays: 0, monthSignInCount: 0 },
signInRecords: []
userInfo: {
nickname: '加载中...', memberNo: '', phone: '', gender: 0,
birthday: '', lastLoginAt: '', avatar: '',
totalSignInDays: 0, monthSignInCount: 0
},
signInRecords: [],
// 编辑弹窗
showEdit: false,
saving: false,
editForm: { nickname: '', gender: 0, birthday: '' },
genderOptions: [
{ label: '男', value: 'MALE' },
{ label: '女', value: 'FEMALE' }
],
today: '',
showDefaultAvatar: false
}
},
onLoad() {
@@ -106,11 +156,12 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
// 设置今天日期,限制生日选择不晚于今天
const d = new Date()
this.today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
},
onShow() {
if (!store.isLoggedIn) {
return
}
if (!store.isLoggedIn) return
this.loadData()
},
methods: {
@@ -121,21 +172,21 @@
memberApi.getMemberInfo(),
memberApi.getCheckInRecords({ page: 0, size: 5 })
])
if (memberResult.status === 'fulfilled') {
const info = memberResult.value
this.userInfo = {
nickname: info.nickname || '会员',
memberNo: info.memberNo || ('ID' + (info.id || info.memberId || '')),
memberNo: info.memberNo || ('NM' + String(info.id || '').padStart(6, '0')),
phone: info.phone ? info.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '未绑定',
gender: info.gender || 0,
gender: info.gender != null ? info.gender : 0,
birthday: info.birthday || '',
lastLoginAt: info.lastLoginAt || '',
lastLoginAt: info.lastLoginAt ? info.lastLoginAt.replace('T', ' ').substring(0, 16) : '',
avatar: info.avatar || '',
totalSignInDays: info.totalSignInDays || 0,
monthSignInCount: info.monthSignInCount || 0
}
store.updateMemberInfo(this.userInfo)
}
if (signInResult.status === 'fulfilled') {
const records = Array.isArray(signInResult.value)
? signInResult.value
@@ -155,13 +206,76 @@
this.loading = false
}
},
editProfile() { uni.showToast({ title: '编辑资料', icon: 'none' }) },
openEdit() {
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
const genderVal = typeof this.userInfo.gender === 'string'
? this.userInfo.gender
: (genderMap[this.userInfo.gender] || 'MALE')
this.editForm = {
nickname: this.userInfo.nickname === '加载中...' ? '' : this.userInfo.nickname,
gender: genderVal,
birthday: this.userInfo.birthday || ''
}
this.showEdit = true
},
closeEdit() { this.showEdit = false },
onNicknameInput(e) { this.editForm.nickname = e.detail.value },
onGenderChange(e) {
const idx = Number(e.detail.value)
this.editForm.gender = this.genderOptions[idx].value
},
onBirthdayChange(e) {
this.editForm.birthday = e.detail.value
},
async saveProfile() {
if (this.saving) return
this.saving = true
try {
const body = {
nickname: this.editForm.nickname || undefined,
gender: this.editForm.gender || undefined,
birthday: this.editForm.birthday || undefined
}
const result = await memberApi.updateMemberInfo(body)
if (result) {
this.userInfo.nickname = result.nickname || this.userInfo.nickname
this.userInfo.avatar = result.avatar || this.userInfo.avatar
this.userInfo.gender = result.gender != null ? result.gender : this.userInfo.gender
this.userInfo.birthday = result.birthday || this.userInfo.birthday
store.updateMemberInfo(this.userInfo)
}
uni.showToast({ title: '保存成功', icon: 'success' })
this.showEdit = false
} catch (e) {
console.error('保存资料失败:', e)
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally {
this.saving = false
}
},
onAvatarError() { this.showDefaultAvatar = true },
goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) },
viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) }
},
computed: {
avatarText() { return (this.userInfo.nickname || '?').charAt(0) },
genderLabel() { const g = this.userInfo.gender; return g === 1 ? '男' : g === 2 ? '女' : '未设置' }
avatarSrc() {
if (this.showDefaultAvatar) return defaultAvatar
return this.userInfo.avatar || defaultAvatar
},
genderLabel() {
const g = this.userInfo.gender
if (g === 1 || g === 'MALE' || g === '男') return '男'
if (g === 2 || g === 'FEMALE' || g === '女') return '女'
return '未设置'
},
genderIndex() {
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
const genderStr = typeof this.editForm.gender === 'string'
? this.editForm.gender
: genderMap[this.editForm.gender] || 'UNKNOWN'
return Math.max(0, this.genderOptions.findIndex(o => o.value === genderStr))
}
}
}
</script>
@@ -175,7 +289,7 @@
.content-inner { padding: 16px 16px 0; }
.user-card { background: #FFFFFF; border-radius: 20px; padding: 20px; margin-bottom: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.user-row { display: flex; align-items: center; margin-bottom: 18px; }
.big-avatar { width: 56px; height: 56px; background: linear-gradient(135deg, #00E676, #00BFA5); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 24px; color: #1A1A1A; flex-shrink: 0; margin-right: 14px; }
.big-avatar-img { width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0; margin-right: 14px; }
.user-info { flex: 1; display: flex; flex-direction: column; }
.user-name { font-size: 18px; font-weight: 700; color: #1E1E1E; }
.user-tag { font-size: 13px; color: #00C853; font-weight: 500; }
@@ -207,4 +321,22 @@
.sign-status.success { background: rgba(0,230,118,0.15); color: #00C853; }
.sign-status.fail { background: rgba(255,82,82,0.15); color: #FF5252; }
.bottom-safe { height: 50px; }
/* 编辑弹窗 */
.edit-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.edit-modal { width: 85%; max-width: 340px; background: #FFFFFF; border-radius: 20px; overflow: hidden; }
.edit-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px 12px; border-bottom: 1px solid #EEEEEE; }
.edit-modal-title { font-size: 17px; font-weight: 700; color: #1E1E1E; }
.edit-modal-close { font-size: 18px; color: #7A7E84; }
.edit-modal-body { padding: 16px 20px; max-height: 60vh; }
.edit-field { display: flex; align-items: center; margin-bottom: 16px; }
.edit-label { width: 52px; font-size: 14px; color: #7A7E84; flex-shrink: 0; }
.edit-input { flex: 1; height: 40px; background: #F5F7FA; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1E1E1E; }
.edit-picker { flex: 1; }
.edit-picker-value { height: 40px; background: #F5F7FA; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1E1E1E; display: flex; align-items: center; }
.edit-modal-footer { display: flex; padding: 12px 20px 18px; gap: 12px; border-top: 1px solid #EEEEEE; }
.edit-btn { flex: 1; height: 42px; border-radius: 21px; font-size: 15px; font-weight: 600; display: flex; align-items: center; justify-content: center; border: none; }
.edit-btn::after { border: none; }
.edit-btn.cancel { background: #F5F7FA; color: #7A7E84; }
.edit-btn.confirm { background: #00E676; color: #1A1A1A; }
.edit-btn.confirm[disabled] { opacity: 0.5; }
</style>
+107 -27
View File
@@ -13,15 +13,20 @@
</view>
<view class="date-range-row">
<view class="date-range-item" @click="openStartDatePicker">
<text class="range-label">开始</text>
<text class="range-value" :class="{ placeholder: !startDate }">{{ startDate || '选择日期' }}</text>
</view>
<picker mode="date" :value="startDate" @change="onStartDateChange" :end="endDate || '2099-12-31'">
<view class="date-range-item">
<text class="range-label">开始</text>
<text class="range-value" :class="{ placeholder: !startDate }">{{ startDate || '选择日期' }}</text>
</view>
</picker>
<text class="range-sep"></text>
<view class="date-range-item" @click="openEndDatePicker">
<text class="range-label">结束</text>
<text class="range-value" :class="{ placeholder: !endDate }">{{ endDate || '选择日期' }}</text>
</view>
<picker mode="date" :value="endDate" @change="onEndDateChange" :start="startDate || '2020-01-01'">
<view class="date-range-item">
<text class="range-label">结束</text>
<text class="range-value" :class="{ placeholder: !endDate }">{{ endDate || '选择日期' }}</text>
</view>
</picker>
<text v-if="startDate || endDate" class="date-clear" @click="clearDateRange">&#10005;</text>
</view>
<view class="period-row">
@@ -33,14 +38,24 @@
</view>
</view>
<scroll-view class="filter-tabs" scroll-x>
<view class="filter-tabs-inner">
<view v-for="(tab, idx) in filterTabs" :key="idx" class="filter-tab"
:class="{ active: currentFilter === tab.value }" @click="switchFilter(tab.value)">
{{ tab.label }}
<view class="type-section">
<view class="type-all-row">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
</view>
<view class="type-grid" :class="{ expanded: typesExpanded }">
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)">
{{ tab.label }}
</view>
</view>
</view>
<view v-if="typeRows.length > 1" class="type-expand-row">
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded">
{{ typesExpanded ? '收起 ' : '展开更多 ' }}
</text>
</view>
</view>
</scroll-view>
<view class="sort-bar">
<text class="result-count"> {{ filteredCourses.length }} 个课程</text>
@@ -127,11 +142,10 @@
{ label: '下午', value: 'afternoon' },
{ label: '晚间', value: 'evening' }
],
filterTabs: [
{ label: '全部', value: 'all' }, { label: '有氧运动', value: '有氧运动' },
{ label: '高强度', value: '高强度' }, { label: '柔韧平衡', value: '柔韧平衡' },
{ label: '力量训练', value: '力量训练' }, { label: '免费', value: 'free' }
],
courseTypes: [], // 从后端获取的团课类型列表
typesExpanded: false, // 类型列表是否展开
visibleRowStart: 0, // 折叠时显示的起始行索引
COLS_PER_ROW: 4, // 每行类型数量
sortOptions: [
{ label: '时间', value: 'time' },
{ label: '难度', value: 'difficulty' },
@@ -154,8 +168,21 @@
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
this.loadCourses()
this.loadTypes()
},
computed: {
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
typeRows() {
const tabs = this.courseTypes.map(t => ({
label: t.typeName,
value: String(t.id)
}))
const rows = []
for (let i = 0; i < tabs.length; i += this.COLS_PER_ROW) {
rows.push(tabs.slice(i, i + this.COLS_PER_ROW))
}
return rows
},
filteredCourses() {
let list = this.courses.map(c => {
const bd = c.baseDifficulty || c.calculatedDifficulty || 5
@@ -226,15 +253,38 @@
(c.location || '').toLowerCase().includes(kw)
)
}
if (this.currentFilter === 'free') list = list.filter(c => c.storedValueAmount === 0)
else if (this.currentFilter !== 'all') list = list.filter(c => c.category === this.currentFilter)
if (this.currentFilter !== 'all') list = list.filter(c => String(c.courseType) === this.currentFilter)
if (this.currentPeriod !== 'all') list = list.filter(c => c.period === this.currentPeriod)
// 日期范围过滤:根据课程 startTime 的日期部分进行筛选
if (this.startDate || this.endDate) {
list = list.filter(c => {
const courseDate = (c.startTime || '').substring(0, 10)
if (!courseDate) return false
if (this.startDate && courseDate < this.startDate) return false
if (this.endDate && courseDate > this.endDate) return false
return true
})
}
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.baseDifficulty - b.baseDifficulty)
else if (this.currentSort === 'popular') list = [...list].sort((a, b) => (b.currentMembers || 0) - (a.currentMembers || 0))
else if (this.currentSort === 'time') {
list = [...list].sort((a, b) => {
const ta = a.startTime || ''
const tb = b.startTime || ''
return ta < tb ? -1 : ta > tb ? 1 : 0
})
}
// 只显示可预约的团课
list = list.filter(c => c.canBook)
return list
}
},
methods: {
// 判断某行是否可见:展开时全部可见,折叠时只有 visibleRowStart 行可见
isRowVisible(rowIndex) {
if (this.typesExpanded) return true
return rowIndex === this.visibleRowStart
},
async loadCourses() {
this.loading = true
try {
@@ -248,14 +298,31 @@
this.loading = false
}
},
async loadTypes() {
try {
const types = await courseApi.getCourseTypes()
// 按 id 排序
this.courseTypes = (Array.isArray(types) ? types : []).sort((a, b) => (a.id || 0) - (b.id || 0))
} catch (e) {
console.error('加载课程类型失败:', e)
}
},
onSearchInput() {},
clearSearch() { this.searchKeyword = '' },
onCoverLoad(course) { course.coverLoaded = true },
onCoverError(course) { course.coverError = true },
openStartDatePicker() { uni.showToast({ title: '选择开始日期', icon: 'none' }) },
openEndDatePicker() { uni.showToast({ title: '选择结束日期', icon: 'none' }) },
onStartDateChange(e) { this.startDate = e.detail.value },
onEndDateChange(e) { this.endDate = e.detail.value },
clearDateRange() { this.startDate = ''; this.endDate = '' },
selectPeriod(value) { this.currentPeriod = value },
switchFilter(value) { this.currentFilter = value },
selectType(tab) {
this.currentFilter = tab.value
// 找到该类型所在的行索引,折叠到那一行
const idx = this.typeRows.findIndex(row => row.some(t => t.value === tab.value))
if (idx >= 0) this.visibleRowStart = idx
this.typesExpanded = false
},
switchSort(value) { this.currentSort = value },
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) },
bookCourse(course) { if (course.canBook) uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) }
@@ -276,20 +343,33 @@
.clear-btn { font-size: 15px; color: rgba(255,255,255,0.5); padding: 4px; flex-shrink: 0; }
.date-range-row { display: flex; align-items: center; margin-bottom: 14px; }
.date-range-item { flex: 1; background: rgba(255,255,255,0.1); border-radius: 12px; padding: 10px 14px; margin-right: 10px; }
.date-range-row picker { flex: 1; margin-right: 10px; }
.date-range-item { background: rgba(255,255,255,0.1); border-radius: 12px; padding: 10px 14px; }
.range-label { font-size: 11px; color: rgba(255,255,255,0.5); display: block; margin-bottom: 2px; }
.range-value { font-size: 14px; font-weight: 600; color: #FFFFFF; }
.range-value.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; }
.range-sep { color: rgba(255,255,255,0.5); font-size: 14px; flex-shrink: 0; margin-right: 10px; }
.date-clear { font-size: 16px; color: rgba(255,255,255,0.4); padding: 8px; flex-shrink: 0; }
.period-row { display: flex; }
.period-item { flex: 1; text-align: center; padding: 7px 0; border-radius: 40px; font-size: 13px; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.08); margin: 0 3px; }
.period-item.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
.filter-tabs { background: #FFFFFF; display: flex; white-space: nowrap; border-bottom: 1px solid #EEEEEE; }
.filter-tabs-inner { padding: 14px 16px; display: flex; white-space: nowrap; }
.filter-tab { display: inline-block; padding: 6px 16px; border-radius: 40px; font-size: 13px; color: #7A7E84; background: #F5F7FA; margin-right: 8px; }
.type-section { background: #FFFFFF; padding: 12px 16px 14px; border-bottom: 1px solid #EEEEEE; }
.type-all-row { margin-bottom: 6px; }
.type-grid { }
.type-row {
display: flex; flex-wrap: wrap;
max-height: 0; opacity: 0;
margin-bottom: 0; overflow: hidden;
transition: max-height 0.25s ease, opacity 0.25s ease, margin-bottom 0.25s ease;
}
.type-row.row-visible { max-height: 100px; opacity: 1; margin-bottom: 6px; }
.type-grid.expanded .type-row { max-height: 100px; opacity: 1; margin-bottom: 6px; }
.filter-tab { display: inline-block; padding: 6px 16px; border-radius: 40px; font-size: 13px; color: #7A7E84; background: #F5F7FA; margin-right: 8px; margin-bottom: 6px; }
.filter-tab.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
.type-expand-row { text-align: center; padding-top: 2px; }
.type-expand-btn { font-size: 12px; color: #00C853; font-weight: 500; }
.sort-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: #FFFFFF; border-bottom: 1px solid #EEEEEE; }
.result-count { font-size: 13px; color: #7A7E84; }
+1 -1
View File
@@ -3,7 +3,7 @@ const Request = luchRequest.default || luchRequest
const { generateSignatureHeaders } = require('./signature')
const store = require('../store/index')
const BASE_URL = 'http://localhost:8084/api'
const BASE_URL = 'http://192.168.101.5:8084/api'
const http = new Request({
baseURL: BASE_URL,