418 lines
18 KiB
Vue
418 lines
18 KiB
Vue
<template>
|
||
<view class="profile-page">
|
||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
||
<text class="nav-title">● 个人中心</text>
|
||
<text class="settings-symbol" @click="goToSettings">⚙</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
||
<view class="content-inner">
|
||
<view class="user-card">
|
||
<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">✎</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>
|
||
<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">☰</text>
|
||
<text class="section-title">最近签到</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>
|
||
</view>
|
||
<view v-if="signInRecords.length === 0" class="empty-hint">
|
||
<text>暂无签到记录</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="sign-section">
|
||
<view class="sign-header">
|
||
<view class="section-title-row">
|
||
<text class="section-symbol">✓</text>
|
||
<text class="section-title">团课签到记录</text>
|
||
</view>
|
||
<text class="section-more">最近{{ courseCheckInRecords.length }}条</text>
|
||
</view>
|
||
<view v-for="(record, idx) in courseCheckInRecords" :key="idx" class="sign-item">
|
||
<view class="sign-left">
|
||
<text class="sign-date">{{ record.fullDate }}</text>
|
||
<text class="sign-time">{{ record.shortTime }}</text>
|
||
</view>
|
||
<view class="sign-right">
|
||
<text class="sign-type">{{ record.courseName }}</text>
|
||
<text class="sign-source">{{ record.location }}</text>
|
||
<text class="sign-status success">已签到</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="courseCheckInRecords.length === 0" class="empty-hint">
|
||
<text>暂无团课签到记录</text>
|
||
</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">✕</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 bookingApi = require('../../api/booking')
|
||
const defaultAvatar = require('../../common/img/20200414210134_qbeyi.jpg')
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
statusBarHeight: 0,
|
||
navBarHeight: 44,
|
||
totalHeaderHeight: 44,
|
||
loading: true,
|
||
userInfo: {
|
||
nickname: '加载中...', memberNo: '', phone: '', gender: 0,
|
||
birthday: '', lastLoginAt: '', avatar: '',
|
||
totalSignInDays: 0, monthSignInCount: 0
|
||
},
|
||
signInRecords: [],
|
||
courseCheckInRecords: [],
|
||
// 编辑弹窗
|
||
showEdit: false,
|
||
saving: false,
|
||
editForm: { nickname: '', gender: 0, birthday: '' },
|
||
genderOptions: [
|
||
{ label: '男', value: 'MALE' },
|
||
{ label: '女', value: 'FEMALE' }
|
||
],
|
||
today: '',
|
||
showDefaultAvatar: false
|
||
}
|
||
},
|
||
onLoad() {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||
let navBarHeight = 44
|
||
// #ifdef MP-WEIXIN
|
||
const menuButton = uni.getMenuButtonBoundingClientRect()
|
||
if (menuButton) {
|
||
navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height
|
||
}
|
||
// #endif
|
||
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
|
||
this.loadData()
|
||
},
|
||
onPullDownRefresh() {
|
||
this.loadData().finally(() => { uni.stopPullDownRefresh() })
|
||
},
|
||
methods: {
|
||
async loadData() {
|
||
this.loading = true
|
||
try {
|
||
const memberInfo = store.getMemberInfo()
|
||
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
||
|
||
const promises = [
|
||
memberApi.getMemberInfo(),
|
||
memberApi.getCheckInRecords({ page: 0, size: 5 })
|
||
]
|
||
if (memberId) {
|
||
promises.push(bookingApi.getMemberBookings(memberId))
|
||
}
|
||
|
||
const results = await Promise.allSettled(promises)
|
||
const [memberResult, signInResult] = results
|
||
|
||
if (memberResult.status === 'fulfilled') {
|
||
const info = memberResult.value
|
||
this.userInfo = {
|
||
nickname: info.nickname || '会员',
|
||
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 != null ? info.gender : 0,
|
||
birthday: info.birthday || '',
|
||
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
|
||
: (signInResult.value.content || [])
|
||
this.signInRecords = records.map(r => ({
|
||
date: (r.checkInTime || r.createdAt || '').substring(5, 10) || '--',
|
||
time: (r.checkInTime || r.createdAt || '').substring(11, 16) || '--',
|
||
typeLabel: r.typeLabel || '签到',
|
||
sourceLabel: r.sourceLabel || '小程序',
|
||
status: r.status === 'SUCCESS' || r.status === 1 ? 'success' : 'fail',
|
||
statusLabel: r.status === 'SUCCESS' || r.status === 1 ? '成功' : '失败'
|
||
}))
|
||
}
|
||
// 处理团课签到记录(预约状态为「已出席」的课程)
|
||
if (results.length >= 3 && results[2].status === 'fulfilled') {
|
||
const bookings = results[2].value
|
||
const arr = Array.isArray(bookings) ? bookings : (bookings && bookings.content ? bookings.content : [])
|
||
this.courseCheckInRecords = arr
|
||
.filter(b => String(b.status) === '2')
|
||
.map(b => {
|
||
const st = b.courseStartTime || ''
|
||
const dt = st ? new Date(st.replace(/ /, 'T')) : null
|
||
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') : ''
|
||
const hh = dt && !isNaN(dt) ? String(dt.getHours()).padStart(2, '0') : '--'
|
||
const mm = dt && !isNaN(dt) ? String(dt.getMinutes()).padStart(2, '0') : '--'
|
||
const inTime = b.checkInTime || b.updatedAt || ''
|
||
const indt = inTime ? new Date(inTime.replace(/ /, 'T')) : null
|
||
let checkInTimeStr = ''
|
||
if (indt && !isNaN(indt)) {
|
||
checkInTimeStr = String(indt.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(indt.getDate()).padStart(2, '0') + ' ' +
|
||
String(indt.getHours()).padStart(2, '0') + ':' +
|
||
String(indt.getMinutes()).padStart(2, '0')
|
||
}
|
||
return {
|
||
courseName: b.courseName || '未命名课程',
|
||
location: b.location || '',
|
||
fullDate: y + '-' + mo + '-' + d,
|
||
shortTime: hh + ':' + mm,
|
||
checkInTime: checkInTimeStr || (mo + '-' + d)
|
||
}
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('个人中心数据加载失败:', e)
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
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: {
|
||
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>
|
||
|
||
<style scoped>
|
||
.profile-page { min-height: 100vh; background: #F5F7FA; overflow-x: hidden; }
|
||
.header-wrap { background: #1A1A1A; }
|
||
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; }
|
||
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
|
||
.settings-symbol { font-size: 20px; color: rgba(255,255,255,0.8); }
|
||
.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-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; }
|
||
.edit-symbol { font-size: 20px; color: #7A7E84; }
|
||
.user-details { background: #F5F7FA; border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; }
|
||
.detail-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||
.detail-label { font-size: 13px; color: #7A7E84; }
|
||
.detail-value { font-size: 14px; font-weight: 600; color: #1E1E1E; }
|
||
.stats-card { background: #1A1A1A; border-radius: 20px; padding: 18px 20px; display: flex; justify-content: space-around; margin-bottom: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
||
.stat-item { display: flex; flex-direction: column; align-items: center; }
|
||
.stat-number { font-size: 24px; font-weight: 700; color: #00E676; }
|
||
.stat-label { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; }
|
||
.stat-divider { width: 1px; background: rgba(255,255,255,0.15); }
|
||
.section-title-row { display: flex; align-items: center; margin-bottom: 12px; }
|
||
.section-symbol { font-size: 16px; color: #00E676; margin-right: 8px; }
|
||
.section-title { font-size: 16px; font-weight: 700; color: #1E1E1E; }
|
||
.sign-section { margin-bottom: 16px; }
|
||
.sign-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||
.sign-header .section-title-row { margin-bottom: 0; }
|
||
.section-more { font-size: 13px; color: #00C853; }
|
||
.sign-item { background: #FFFFFF; border-radius: 12px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
||
.sign-left { display: flex; flex-direction: column; }
|
||
.sign-date { font-size: 14px; font-weight: 600; color: #1E1E1E; }
|
||
.sign-time { font-size: 12px; color: #7A7E84; margin-top: 2px; }
|
||
.sign-right { display: flex; align-items: center; }
|
||
.sign-type { font-size: 12px; color: #7A7E84; margin-right: 6px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sign-source { font-size: 11px; color: #BDBDBD; background: #F5F7FA; padding: 1px 6px; border-radius: 8px; margin-right: 6px; }
|
||
.sign-status { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
|
||
.sign-status.success { background: rgba(0,230,118,0.15); color: #00C853; }
|
||
.sign-status.fail { background: rgba(255,82,82,0.15); color: #FF5252; }
|
||
.empty-hint { text-align: center; padding: 20px 0; color: #BDBDBD; font-size: 13px; }
|
||
.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>
|