Files

1364 lines
45 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="mine-page" :style="{ background: themeConfig.bgColor }">
<view class="page-content">
<view class="section-header">
<view class="section-title-row">
<view class="section-accent" :style="{ background: themeConfig.gradientSoft }"></view>
<text class="section-title" :style="{ color: themeConfig.textPrimary }">个人资料</text>
<text class="section-subtitle" :style="{ color: themeConfig.textSecondary }">点击头像可编辑</text>
</view>
</view>
<view class="profile-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 24rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="profile-row">
<view class="avatar-wrapper" @click="openProfileEditor">
<image v-if="userAvatar" class="avatar-img" :src="userAvatar" mode="aspectFill"></image>
<view v-else class="avatar-placeholder" :style="{ background: themeConfig.gradientSoft }">
<text class="avatar-init">{{ (userName || '我')[0] }}</text>
</view>
</view>
<view class="profile-info">
<view class="profile-info-top">
<text class="user-name" :style="{ color: themeConfig.textPrimary }">{{ userName || '点击设置昵称' }}</text>
<view class="level-badge" :style="{ background: themeConfig.primaryColor + '15' }">
<text class="level-text" :style="{ color: themeConfig.primaryColor }" @click="goToGrowth">Lv.{{ growthInfo.level }}</text>
</view>
</view>
<view class="user-stats-row">
<view class="user-stat">
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ dayCount }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">天数</text>
</view>
<view class="user-stat-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="user-stat">
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ growthInfo.streakDays || 0 }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">连续</text>
</view>
<view class="user-stat-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="user-stat">
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ growthInfo.achievements.unlocked || 0 }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">成就</text>
</view>
</view>
<text class="user-signature" v-if="userSignature" :style="{ color: themeConfig.textSecondary }">{{ userSignature }}</text>
</view>
</view>
</view>
<view class="section-header">
<view class="section-title-row">
<view class="section-accent" :style="{ background: themeConfig.gradientSoft }"></view>
<text class="section-title" :style="{ color: themeConfig.textPrimary }">本月数据</text>
<text class="section-subtitle" :style="{ color: themeConfig.textSecondary }">收支与完成度</text>
</view>
</view>
<view class="exp-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 24rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="exp-header">
<text class="exp-label" :style="{ color: themeConfig.textPrimary }">升级进度</text>
<text class="exp-text" :style="{ color: themeConfig.textSecondary }">{{ growthInfo.experience }}/{{ growthInfo.nextLevelExp }} EXP</text>
</view>
<view class="exp-progress-bar">
<view class="exp-progress-fill" :style="{ width: expPercent + '%', background: themeConfig.gradientSoft }"></view>
</view>
</view>
<view class="stats-row">
<view class="stat-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 20rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="stat-icon-wrap" :style="{ background: themeConfig.dangerColor + '15' }">
<FaIcon icon="wallet" :size="22" :color="themeConfig.dangerColor" />
</view>
<view class="stat-info">
<text class="stat-val" :style="{ color: themeConfig.textPrimary }">¥{{ formatNumber(totalAmount) }}</text>
<text class="stat-lbl" :style="{ color: themeConfig.textSecondary }">净收支</text>
</view>
</view>
<view class="stat-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 20rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="stat-icon-wrap" :style="{ background: themeConfig.primaryColor + '15' }">
<FaIcon icon="check-circle" :size="22" :color="themeConfig.primaryColor" />
</view>
<view class="stat-info">
<text class="stat-val" :style="{ color: themeConfig.textPrimary }">{{ finishRate }}%</text>
<text class="stat-lbl" :style="{ color: themeConfig.textSecondary }">完成率</text>
</view>
</view>
<view class="stat-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 20rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="stat-icon-wrap" :style="{ background: themeConfig.secondaryColor + '15' }">
<FaIcon icon="calendar" :size="22" :color="themeConfig.secondaryColor" />
</view>
<view class="stat-info">
<text class="stat-val" :style="{ color: themeConfig.textPrimary }">{{ monthBudget > 0 ? '¥' + formatNumber(monthBudget) : '未设' }}</text>
<text class="stat-lbl" :style="{ color: themeConfig.textSecondary }">月预算</text>
</view>
</view>
</view>
<view class="section-header">
<view class="section-title-row">
<view class="section-accent" :style="{ background: themeConfig.gradientSoft }"></view>
<text class="section-title" :style="{ color: themeConfig.textPrimary }">更多功能</text>
<text class="section-subtitle" :style="{ color: themeConfig.textSecondary }">设置与账户</text>
</view>
</view>
<view class="menu-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 24rpx ${themeConfig.shadowLight}`, borderColor: themeConfig.borderLight }">
<view class="menu-item" @click="goToGrowth">
<view class="menu-icon" :style="{ background: themeConfig.primaryColor + '15' }">
<FaIcon icon="trophy" :size="22" :color="themeConfig.primaryColor" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">成长中心</text>
<text class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
<view class="menu-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="menu-item" @click="handleBudgetSetting">
<view class="menu-icon" :style="{ background: themeConfig.secondaryColor + '15' }">
<FaIcon icon="wallet" :size="22" :color="themeConfig.secondaryColor" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">月度预算</text>
<text class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
<view class="menu-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="menu-item" @click="handleThemeSetting">
<view class="menu-icon" :style="{ background: themeConfig.primaryColor + '15' }">
<FaIcon icon="palette" :size="22" :color="themeConfig.primaryColor" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">主题皮肤</text>
<text class="menu-desc" :style="{ color: themeConfig.textSecondary }">{{ themeConfig.name || '默认' }}</text>
<text class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
<view class="menu-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="menu-item" @click="handleEmailSetting">
<view class="menu-icon" :style="{ background: (themeConfig.warningColor || '#ffaa33') + '20' }">
<FaIcon icon="envelope" :size="22" :color="themeConfig.warningColor || '#ffaa33'" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">绑定邮箱</text>
<text class="menu-desc" :style="{ color: userEmail ? themeConfig.textSecondary : themeConfig.textDisabled }">{{ userEmail || '未绑定' }}</text>
<text class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
<view class="menu-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="menu-item" :class="{ 'menu-item--disabled': isBindWechat }" @click="handleWechatBind">
<view class="menu-icon" :style="{ background: themeConfig.secondaryColor + '15' }">
<FaIcon icon="comment" :size="22" :color="themeConfig.secondaryColor" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">绑定微信</text>
<text class="menu-desc" :style="{ color: isBindWechat ? themeConfig.primaryColor : themeConfig.textDisabled }">{{ isBindWechat ? '已绑定' : '未绑定' }}</text>
<text v-if="!isBindWechat" class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
<view class="menu-divider" :style="{ background: themeConfig.borderColor }"></view>
<view class="menu-item" @click="handleAbout">
<view class="menu-icon" :style="{ background: themeConfig.textSecondary + '15' }">
<FaIcon icon="circle-info" :size="22" :color="themeConfig.textSecondary" />
</view>
<text class="menu-title" :style="{ color: themeConfig.textPrimary }">关于我们</text>
<text class="menu-desc" :style="{ color: themeConfig.textSecondary }">v{{ version }}</text>
<text class="menu-arrow" :style="{ color: themeConfig.textDisabled }">
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
</text>
</view>
</view>
</view>
<view class="safe-area-bottom"></view>
<view class="modal" v-if="showBudgetModal" @click="showBudgetModal = false">
<view class="modal-box" :style="{ background: themeConfig.cardBgColor }" @click.stop>
<view class="modal-header">
<text class="modal-title" :style="{ color: themeConfig.textPrimary }">设置月度预算</text>
<view class="modal-close" @click="showBudgetModal = false">
<FaIcon icon="xmark" :size="20" :color="themeConfig.textSecondary" />
</view>
</view>
<view class="modal-form">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">预算金额</text>
<input v-model="tempBudget" type="digit" placeholder="请输入月度预算"
class="form-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
</view>
<view class="modal-actions">
<view class="modal-btn cancel-btn btn btn--secondary" @click="showBudgetModal = false">取消</view>
<view class="modal-btn confirm-btn btn btn--primary" @click="saveBudget">确定</view>
</view>
</view>
</view>
<view class="modal" v-if="showProfileModal" @click="showProfileModal = false">
<view class="modal-box" :style="{ background: themeConfig.cardBgColor }" @click.stop>
<view class="modal-header">
<text class="modal-title" :style="{ color: themeConfig.textPrimary }">编辑资料</text>
<view class="modal-close" @click="showProfileModal = false">
<FaIcon icon="xmark" :size="20" :color="themeConfig.textSecondary" />
</view>
</view>
<view class="profile-modal-content">
<view class="profile-section">
<view class="avatar-preview-wrapper" @click="showAvatarPicker = !showAvatarPicker">
<view class="profile-avatar-frame" :style="{ borderColor: themeConfig.primaryColor }">
<image class="profile-avatar" :src="profileForm.avatar || userAvatar" mode="aspectFill" />
</view>
<view class="avatar-edit-badge" :style="{ background: themeConfig.primaryColor }">
<FaIcon icon="pen" :size="14" color="#fff" />
</view>
</view>
<view class="avatar-picker" v-if="showAvatarPicker">
<view class="avatar-grid">
<view class="avatar-option" :class="{ 'is-selected': profileForm.avatar === avatar }"
v-for="(avatar, index) in validAvatars" :key="index"
@click="selectAvatarForProfile(avatar)">
<view class="avatar-frame"
:style="{ borderColor: profileForm.avatar === avatar ? themeConfig.primaryColor : themeConfig.borderColor }">
<image class="option-avatar" :src="avatar" mode="aspectFill"></image>
</view>
</view>
</view>
</view>
</view>
<view class="profile-section">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">昵称</text>
<input v-model="profileForm.nickname" placeholder="请输入昵称"
class="form-input" :maxlength="10"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
</view>
<view class="profile-section">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">个性签名</text>
<input v-model="profileForm.signature" placeholder="请输入个性签名"
class="form-input" :maxlength="20"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
</view>
<view class="modal-actions">
<view class="modal-btn cancel-btn btn btn--secondary" @click="showProfileModal = false">取消</view>
<view class="modal-btn confirm-btn btn btn--primary" @click="saveProfile">保存</view>
</view>
</view>
</view>
</view>
<view class="modal" v-if="showEmailModal" @click="showEmailModal = false">
<view class="modal-box" :style="{ background: themeConfig.cardBgColor }" @click.stop>
<view class="modal-header">
<text class="modal-title" :style="{ color: themeConfig.textPrimary }">{{ userEmail ? '邮箱设置' : '绑定邮箱' }}</text>
<view class="modal-close" @click="showEmailModal = false">
<FaIcon icon="xmark" :size="20" :color="themeConfig.textSecondary" />
</view>
</view>
<view class="email-modal-content">
<view class="email-form">
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">邮箱地址</text>
<input v-model="emailForm.email" type="email" placeholder="请输入邮箱地址"
class="form-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
:disabled="userEmail && !isEditingEmail" />
</view>
<view v-if="userEmail && !isEditingEmail && !showChangePassword" class="form-item">
<view class="change-password-btn btn btn--primary" @click="showChangePassword = true">
<text class="btn-text" style="color:#fff;">修改密码</text>
</view>
<view class="change-password-btn secondary btn btn--secondary" @click="handleEditEmail">
<text class="btn-text" :style="{ color: themeConfig.textPrimary }">修改邮箱</text>
</view>
</view>
<view v-if="!userEmail || showChangePassword || isEditingEmail" class="form-item">
<view v-if="userEmail && showChangePassword && !isEditingEmail" class="form-item">
<view class="change-password-btn cancel-change btn btn--secondary" @click="cancelChangePassword">
<text class="btn-text" :style="{ color: themeConfig.textPrimary }">收起密码修改</text>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">密码</text>
<view class="password-input-wrapper">
<input v-model="emailForm.password" type="password" :password="!showEmailPassword1" placeholder="请输入密码"
class="form-input password-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
<text class="password-toggle-btn" @tap="showEmailPassword1 = !showEmailPassword1">
<FaIcon :icon="showEmailPassword1 ? 'eye-slash' : 'eye'" :size="20" :color="themeConfig.textSecondary" />
</text>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">确认密码</text>
<view class="password-input-wrapper">
<input v-model="emailForm.confirmPassword" type="password" :password="!showEmailPassword2" placeholder="请再次输入密码"
class="form-input password-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
<text class="password-toggle-btn" @tap="showEmailPassword2 = !showEmailPassword2">
<FaIcon :icon="showEmailPassword2 ? 'eye-slash' : 'eye'" :size="20" :color="themeConfig.textSecondary" />
</text>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">图形验证码</text>
<view class="captcha-row">
<input v-model="emailForm.captcha" type="text" placeholder="请输入验证码" maxlength="4"
class="form-input captcha-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
<view class="captcha-img" @click="refreshCaptcha">
<image :src="captchaImage" mode="aspectFill" class="captcha-image" />
</view>
</view>
</view>
<view v-if="emailForm.email !== userEmail" class="form-item">
<text class="form-label" :style="{ color: themeConfig.textPrimary }">邮箱验证码</text>
<view class="code-row">
<input v-model="emailForm.emailCode" type="digit" placeholder="请输入邮箱验证码"
class="form-input code-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }" />
<view class="send-code-btn btn btn--primary btn--sm" :class="{ disabled: emailCodeCountdown > 0 }" @click="sendEmailCode">
{{ emailCodeCountdown > 0 ? emailCodeCountdown + 's' : '发送验证码' }}
</view>
</view>
</view>
<view v-if="emailForm.password && emailForm.confirmPassword && emailForm.password !== emailForm.confirmPassword" class="form-error">两次密码不一致</view>
</view>
</view>
<view class="modal-actions" v-if="!userEmail || showChangePassword || isEditingEmail">
<view class="modal-btn cancel-btn btn btn--secondary" @click="showEmailModal = false">取消</view>
<view class="modal-btn confirm-btn btn btn--primary" @click="submitEmail">确定</view>
</view>
<view class="modal-actions" v-else>
<view class="modal-btn confirm-btn full-width btn btn--primary" @click="showEmailModal = false">关闭</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import {
getThemeConfigFromStorage,
THEME_PRESETS
} from '@/utils/theme'
import {
refreshNavBarTheme,
forceApplyTabBarTheme
} from '@/utils/themeGlobal'
import {
DEFAULT_AVATARS,
MINE_MENU_ICONS
} from '@/utils/constants.js'
import {
getUserInfo,
editUserInfo,
getMonthStat,
getMonthBudget,
setMonthBudget,
getGrowthInfo,
sendEmailCode,
setUserEmail,
bindWechat,
getCaptcha,
changePassword
} from '@/api/index'
import FaIcon from '@/components/FaIcon.vue'
export default {
components: {
FaIcon
},
data() {
return {
version: '1.0.0',
monthIncome: 0,
monthExpend: 0,
totalAmount: 0,
finishTaskCount: 0,
unfinishTaskCount: 0,
finishRate: 0,
monthBudget: 0,
tempBudget: '',
showBudgetModal: false,
DEFAULT_AVATARS: DEFAULT_AVATARS,
MINE_MENU_ICONS: MINE_MENU_ICONS,
userName: '',
userAvatar: '',
userEmail: '',
userCode: '',
isBindWechat: false,
isBindEmail: false,
currentMonth: new Date().getMonth() + 1,
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS['default'],
dayCount: 0,
joinDate: '2026-01-01',
userSignature: '',
showProfileModal: false,
showAvatarPicker: false,
showEmailModal: false,
showChangePassword: false,
isEditingEmail: false,
emailForm: {
email: '',
password: '',
confirmPassword: '',
captcha: '',
captchaId: '',
emailCode: ''
},
showPassword: false,
showConfirmPassword: false,
showEmailPassword: false,
showEmailPassword1: false,
showEmailPassword2: false,
codeCountdown: 0,
emailCodeCountdown: 0,
captchaImage: '',
profileForm: {
nickname: '',
signature: '',
avatar: ''
},
growthInfo: {
level: 1,
title: '初出茅庐',
titleIcon: '',
experience: 0,
nextLevelExp: 100,
totalDays: 0,
streakDays: 0,
maxStreakDays: 0,
avgCompletionRate: 0,
todayProgress: {
tasksCompleted: 0,
taskExp: 0,
billsRecorded: 0,
billExp: 0,
moodRecorded: false,
moodExp: 0,
streakBonus: 0,
protectBonus: 0,
totalExp: 0,
maxDailyExp: 120
},
achievements: [],
privileges: []
}
}
},
computed: {
validAvatars() {
return this.DEFAULT_AVATARS.filter((avatar) => !!avatar && avatar.trim() !== '')
},
expPercent() {
if (!this.growthInfo.nextLevelExp || this.growthInfo.nextLevelExp <= 0) return 0
return Math.min(100, Math.round((this.growthInfo.experience / this.growthInfo.nextLevelExp) * 100))
}
},
onLoad() {
this.themeChangeHandler = (themeData) => {
try {
let newTheme = null
if (themeData?.key && THEME_PRESETS[themeData.key]) {
newTheme = THEME_PRESETS[themeData.key]
} else if (themeData?.config) {
newTheme = themeData.config
}
if (newTheme) {
this.themeConfig = newTheme
}
} catch (e) {
console.error('主题更新失败:', e)
}
}
uni.$on('themeChanged', this.themeChangeHandler)
this.loadUserInfo()
this.calculateDayCount()
this.initData()
this.loadGrowthInfo()
},
onUnload() {
uni.$off('themeChanged', this.themeChangeHandler)
},
onShow() {
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
this.themeConfig = latestTheme
}
refreshNavBarTheme()
setTimeout(() => {
forceApplyTabBarTheme(latestTheme)
}, 100)
this.loadGrowthInfo()
this.loadUserInfo()
this.initData()
},
methods: {
async loadGrowthInfo() {
try {
const res = await getGrowthInfo()
if (res) {
this.growthInfo = {
level: res.level || 1,
title: res.title || '',
titleIcon: res.titleIcon || '',
experience: res.experience || 0,
nextLevelExp: res.nextLevelExp || 100,
totalDays: res.totalDays || 0,
streakDays: res.streakDays || 0,
maxStreakDays: res.maxStreakDays || 0,
avgCompletionRate: res.avgCompletionRate || 0,
todayProgress: {
tasksCompleted: res.todayProgress?.tasksCompleted || 0,
taskExp: res.todayProgress?.taskExp || 0,
billsRecorded: res.todayProgress?.billsRecorded || 0,
billExp: res.todayProgress?.billExp || 0,
moodRecorded: res.todayProgress?.moodRecorded || false,
moodExp: res.todayProgress?.moodExp || 0,
streakBonus: res.todayProgress?.streakBonus || 0,
protectBonus: res.todayProgress?.protectBonus || 0,
totalExp: res.todayProgress?.totalExp || 0,
maxDailyExp: res.todayProgress?.maxDailyExp || 120
},
achievements: res.achievements || [],
privileges: res.privileges || []
}
}
} catch (e) {
console.log('加载成长信息失败:', e)
}
},
goToGrowth() {
uni.navigateTo({ url: '/pages/growth/growth' })
},
formatNumber(num) {
if (num === null || num === undefined || isNaN(num) || num === '') return '0'
const number = Number(num)
const formatted = number.toFixed(2)
return formatted.replace(/\.00$/, '')
},
async loadUserInfo() {
try {
const token = uni.getStorageSync('user_token')
if (!token) return
const res = await getUserInfo()
this.userName = res.nickname || ''
this.userAvatar = res.avatar || this.validAvatars[0] || ''
this.userSignature = res.signature || ''
this.userEmail = res.email || ''
this.userCode = res.user_code || ''
this.isBindWechat = res.is_bind_wechat || false
this.isBindEmail = res.is_bind_email || false
uni.setStorageSync('userName', this.userName)
uni.setStorageSync('userAvatar', this.userAvatar)
uni.setStorageSync('userSignature', this.userSignature)
uni.setStorageSync('userEmail', this.userEmail)
uni.setStorageSync('userCode', this.userCode)
} catch (e) {
console.error('加载用户信息失败:', e)
}
},
calculateDayCount() {
const joinDate = new Date(this.joinDate)
const today = new Date()
const diffTime = today.getTime() - joinDate.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
this.dayCount = Math.max(0, diffDays)
},
async copyUserCode() {
if (!this.userCode) return
try {
await uni.setClipboardData({ data: this.userCode })
uni.showToast({ title: '复制成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '复制失败', icon: 'none' })
}
},
openProfileEditor() {
this.profileForm = {
nickname: this.userName || '',
signature: this.userSignature || '',
avatar: this.userAvatar || ''
}
this.showAvatarPicker = false
this.showProfileModal = true
},
selectAvatarForProfile(avatar) {
this.profileForm.avatar = avatar
},
async saveProfile() {
try {
const avatar = this.profileForm.avatar?.trim()
if (!avatar) {
uni.showToast({ title: '请选择头像', icon: 'none' })
return
}
const nickname = this.profileForm.nickname?.trim()
if (!nickname) {
uni.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
const signature = this.profileForm.signature?.trim()
if (!signature) {
uni.showToast({ title: '个性签名不能为空', icon: 'none' })
return
}
const params = {}
if (nickname !== this.userName) params.nickname = nickname
if (signature !== this.userSignature) params.signature = signature
if (avatar !== this.userAvatar) params.avatar = avatar
if (Object.keys(params).length === 0) {
uni.showToast({ title: '未修改任何内容', icon: 'none' })
return
}
const res = await editUserInfo(params)
if (res) {
if (params.nickname) {
this.userName = params.nickname
uni.setStorageSync('userName', this.userName)
}
if (params.signature) this.userSignature = params.signature
if (params.avatar) {
this.userAvatar = params.avatar
uni.setStorageSync('userAvatar', this.userAvatar)
}
uni.showToast({ title: '保存成功', icon: 'success' })
this.showProfileModal = false
}
} catch (e) {
console.error('保存资料失败:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
},
async initData() {
try {
const now = new Date()
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const res = await getMonthStat({ month })
this.monthIncome = res.income || 0
this.monthExpend = res.expend || 0
this.finishTaskCount = res.finishTask || 0
const totalTask = res.totalTask || 0
this.unfinishTaskCount = totalTask - this.finishTaskCount
this.totalAmount = this.monthIncome - this.monthExpend
this.finishRate = totalTask > 0 ? Math.round(this.finishTaskCount / totalTask * 100) : 0
} catch (e) {
this.totalAmount = 0
this.finishRate = 0
}
try {
const res = await getMonthBudget()
this.monthBudget = res.amount || 0
} catch (e) {}
},
handleThemeSetting() {
uni.navigateTo({ url: '/pages/themeSetting/themeSetting' })
},
handleBudgetSetting() {
this.tempBudget = this.formatNumber(this.monthBudget)
this.showBudgetModal = true
},
async saveBudget() {
const num = Number(this.tempBudget)
if (!this.tempBudget || isNaN(num) || num < 0.01) {
uni.showToast({ title: '请输入大于0的有效预算', icon: 'none' })
return
}
try {
await setMonthBudget({ amount: num })
this.monthBudget = num
this.showBudgetModal = false
uni.showToast({ title: '设置成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: '设置预算失败', icon: 'none' })
}
},
handleAbout() {
uni.navigateTo({ url: '/pages/about/about' })
},
async handleWechatBind() {
if (this.isBindWechat) {
return
}
try {
const loginRes = await uni.login().catch(() => { throw new Error('获取微信登录凭证失败') })
const code = loginRes.code
if (!code) throw new Error('微信登录凭证为空')
const result = await bindWechat({ code })
if (result) {
this.isBindWechat = true
uni.showToast({ title: '绑定成功', icon: 'success' })
} else {
uni.showToast({ title: '绑定失败', icon: 'none' })
}
} catch (err) {
uni.showToast({ title: err.message || '绑定失败', icon: 'none' })
}
},
handleEmailSetting() {
this.emailForm = {
email: this.userEmail || '',
password: '',
confirmPassword: '',
captcha: '',
captchaId: '',
emailCode: ''
}
this.showPassword = false
this.showConfirmPassword = false
this.showEmailPassword1 = false
this.showEmailPassword2 = false
this.showChangePassword = false
this.isEditingEmail = false
this.codeCountdown = 0
this.showEmailModal = true
this.refreshCaptcha()
},
handleEditEmail() {
this.isEditingEmail = true
this.showChangePassword = false
this.emailForm.email = ''
this.emailForm.password = ''
this.emailForm.confirmPassword = ''
this.emailForm.captcha = ''
this.emailForm.emailCode = ''
this.refreshCaptcha()
},
cancelChangePassword() {
this.showChangePassword = false
this.emailForm.password = ''
this.emailForm.confirmPassword = ''
this.emailForm.captcha = ''
this.emailForm.emailCode = ''
this.refreshCaptcha()
},
async refreshCaptcha() {
try {
const res = await getCaptcha()
if (res?.image) {
this.captchaImage = res.image
this.emailForm.captchaId = res.captchaId
}
} catch (e) {
console.error('获取验证码失败:', e)
}
},
async sendEmailCode() {
if (this.emailCodeCountdown > 0) return
const captcha = this.emailForm.captcha?.trim()
if (!captcha) {
uni.showToast({ title: '请输入图形验证码', icon: 'none' })
return
}
const email = this.emailForm.email?.trim()
if (!email) {
uni.showToast({ title: '请输入邮箱地址', icon: 'none' })
return
}
const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailReg.test(email)) {
uni.showToast({ title: '请输入正确的邮箱格式', icon: 'none' })
return
}
if (email === this.userEmail) {
uni.showToast({ title: '邮箱未更改,无需验证', icon: 'none' })
return
}
try {
await sendEmailCode({ email, captcha, captchaId: this.emailForm.captchaId })
uni.showToast({ title: '验证码已发送', icon: 'success' })
this.emailCodeCountdown = 60
const timer = setInterval(() => {
this.emailCodeCountdown--
if (this.emailCodeCountdown <= 0) clearInterval(timer)
}, 1000)
this.emailForm.captcha = ''
this.refreshCaptcha()
} catch (e) {
uni.showToast({ title: '发送失败,请重试', icon: 'none' })
this.refreshCaptcha()
}
},
async submitEmail() {
const email = this.emailForm.email?.trim()
const emailCode = this.emailForm.emailCode?.trim()
const password = this.emailForm.password?.trim()
const confirmPassword = this.emailForm.confirmPassword?.trim()
const captcha = this.emailForm.captcha?.trim()
const isEmailChanged = email !== this.userEmail
const isChangePasswordOnly = this.userEmail && !isEmailChanged && this.showChangePassword
if (!email) {
uni.showToast({ title: '请输入邮箱地址', icon: 'none' })
return
}
const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailReg.test(email)) {
uni.showToast({ title: '请输入正确的邮箱格式', icon: 'none' })
return
}
if (!password) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
if (password.length < 6) {
uni.showToast({ title: '密码长度不能少于6位', icon: 'none' })
return
}
if (!confirmPassword) {
uni.showToast({ title: '请确认密码', icon: 'none' })
return
}
if (password !== confirmPassword) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
if (!captcha) {
uni.showToast({ title: '请输入图形验证码', icon: 'none' })
return
}
if (isEmailChanged && !emailCode) {
uni.showToast({ title: '请输入邮箱验证码', icon: 'none' })
return
}
if (isEmailChanged && emailCode.length < 4) {
uni.showToast({ title: '验证码格式不正确', icon: 'none' })
return
}
try {
if (isChangePasswordOnly) {
await changePassword({ email, password, confirmPassword, captcha, captchaId: this.emailForm.captchaId })
uni.showToast({ title: '密码修改成功', icon: 'success' })
this.showEmailModal = false
} else {
const submitData = { email, password, captcha, captchaId: this.emailForm.captchaId }
if (isEmailChanged) submitData.code = emailCode
const res = await setUserEmail(submitData)
if (res) {
this.userEmail = email
uni.setStorageSync('userEmail', email)
uni.showToast({ title: this.userEmail ? '修改成功' : '绑定成功', icon: 'success' })
}
this.showEmailModal = false
}
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
this.refreshCaptcha()
this.emailForm.captcha = ''
}
}
}
}
</script>
<style lang="scss" scoped>
.mine-page {
min-height: 100vh;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.page-content {
padding: calc(20rpx + env(safe-area-inset-top)) 32rpx 0;
}
/* ========== 区域标题 ========== */
.section-header {
padding: 24rpx 4rpx 16rpx;
}
.section-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.section-accent {
width: 8rpx;
height: 28rpx;
border-radius: 4rpx;
flex-shrink: 0;
}
.section-title {
font-size: 30rpx;
font-weight: 800;
letter-spacing: 1rpx;
}
.section-subtitle {
font-size: 22rpx;
font-weight: 500;
margin-left: auto;
}
/* ========== 个人资料卡片 ========== */
.profile-card {
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 8rpx;
border: 2rpx solid transparent;
}
.profile-row {
display: flex;
align-items: center;
gap: 20rpx;
}
.avatar-wrapper {
position: relative;
width: 108rpx;
height: 108rpx;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-init {
font-size: 40rpx;
font-weight: 700;
color: #fff;
}
.profile-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
}
.profile-info-top {
display: flex;
align-items: center;
gap: 10rpx;
}
.user-name {
font-size: 32rpx;
font-weight: 700;
}
.level-badge {
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.level-text {
font-size: 22rpx;
font-weight: 700;
}
.user-stats-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.user-stat {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.stat-num {
font-size: 28rpx;
font-weight: 700;
}
.stat-label {
font-size: 20rpx;
font-weight: 500;
}
.user-stat-divider {
width: 2rpx;
height: 28rpx;
}
.user-signature {
font-size: 22rpx;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ========== 升级进度卡片 ========== */
.exp-card {
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 12rpx;
border: 2rpx solid transparent;
}
.exp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.exp-label {
font-size: 24rpx;
font-weight: 700;
}
.exp-text {
font-size: 22rpx;
font-weight: 500;
}
.exp-progress-bar {
height: 12rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 6rpx;
overflow: hidden;
}
.exp-progress-fill {
height: 100%;
border-radius: 6rpx;
}
/* ========== 数据统计卡片 ========== */
.stats-row {
display: flex;
gap: 14rpx;
margin-bottom: 8rpx;
}
.stat-card {
flex: 1;
border-radius: 16rpx;
padding: 18rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
border: 2rpx solid transparent;
}
.stat-icon-wrap {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.stat-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.stat-val {
font-size: 26rpx;
font-weight: 800;
}
.stat-lbl {
font-size: 20rpx;
font-weight: 500;
}
/* ========== 菜单卡片 ========== */
.menu-card {
border-radius: 20rpx;
padding: 0 20rpx;
border: 2rpx solid transparent;
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 0;
}
.menu-icon {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-title {
flex: 1;
font-size: 26rpx;
font-weight: 600;
}
.menu-desc {
font-size: 22rpx;
font-weight: 500;
margin-right: 8rpx;
}
.menu-item--disabled {
opacity: 0.7;
}
.menu-arrow {
flex-shrink: 0;
}
.menu-divider {
height: 2rpx;
opacity: 0.5;
}
.safe-area-bottom {
height: env(safe-area-inset-bottom);
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
width: 600rpx;
border-radius: 20rpx;
padding: 28rpx;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
}
.modal-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-form {
margin-bottom: 24rpx;
}
.form-label {
font-size: 26rpx;
font-weight: 600;
margin-bottom: 10rpx;
display: block;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
font-size: 28rpx;
border: 2rpx solid;
border-radius: 12rpx;
box-sizing: border-box;
}
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 16rpx;
}
.modal-btn {
flex: 1;
}
.modal-btn.full-width {
flex: 1;
}
.profile-modal-content {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.profile-section {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.avatar-preview-wrapper {
position: relative;
width: 140rpx;
height: 140rpx;
align-self: center;
}
.profile-avatar-frame {
width: 100%;
height: 100%;
border: 4rpx solid;
border-radius: 16rpx;
overflow: hidden;
}
.profile-avatar {
width: 100%;
height: 100%;
}
.avatar-edit-badge {
position: absolute;
bottom: -4rpx;
right: -4rpx;
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-picker {
margin-top: 16rpx;
}
.avatar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12rpx;
}
.avatar-option {
display: flex;
justify-content: center;
}
.avatar-frame {
width: 80rpx;
height: 80rpx;
border: 3rpx solid;
border-radius: 12rpx;
overflow: hidden;
}
.avatar-option.is-selected .avatar-frame {
border-width: 4rpx;
}
.option-avatar {
width: 100%;
height: 100%;
}
.email-modal-content {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.email-form {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.password-input-wrapper {
position: relative;
}
.password-toggle-btn {
position: absolute;
right: 16rpx;
top: 50%;
transform: translateY(-50%);
padding: 8rpx;
}
.captcha-row {
display: flex;
gap: 12rpx;
}
.captcha-input {
flex: 1;
}
.captcha-img {
width: 200rpx;
height: 80rpx;
border-radius: 12rpx;
overflow: hidden;
}
.captcha-image {
width: 100%;
height: 100%;
}
.code-row {
display: flex;
gap: 12rpx;
}
.code-input {
flex: 1;
}
.send-code-btn {
padding: 0 20rpx;
white-space: nowrap;
}
.change-password-btn {
margin-bottom: 12rpx;
}
.btn-text {
color: #fff;
}
.form-error {
font-size: 22rpx;
color: #e74c3c;
}
</style>