You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

595 lines
17 KiB

<template>
<view class="lf-page account-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">我的账号</view>
<view class="lf-nav__sub">维护个人资料和账号安全</view>
</view>
<view class="lf-pill">{{ account.roleName || '-' }}</view>
</view>
<view class="profile-card lf-card lf-card--ink">
<view class="profile-card__top">
<image v-if="account.wechatAvatar" class="profile-card__avatar-img" :src="account.wechatAvatar" mode="aspectFill" />
<view v-else class="profile-card__avatar">{{ (account.name || '-').slice(0, 1) }}</view>
<view>
<view class="profile-card__name">{{ account.name }}</view>
<view class="profile-card__phone">{{ account.phone || '未绑定手机号' }}</view>
</view>
</view>
<view v-if="isAdmin && (account.permissions || []).length" class="permission-strip">
<text v-for="permission in account.permissions || []" :key="permission">{{ permission }}</text>
</view>
</view>
<view class="lf-card action-card">
<view class="lf-row">
<view class="lf-section-title">个人资料</view>
<button class="mini-action" @click="toggleEdit">{{ editing ? '收起' : '编辑' }}</button>
</view>
<view v-if="!editing" class="profile-list">
<view class="profile-line">
<text>姓名</text>
<text>{{ account.name || '-' }}</text>
</view>
<view class="profile-line">
<text>孩子</text>
<text>{{ account.child?.name || '-' }}</text>
</view>
<view class="profile-line">
<text>年级</text>
<text>{{ account.child?.grade || '-' }}</text>
</view>
<view class="profile-line">
<text>关注点</text>
<text>{{ account.child?.focus || '-' }}</text>
</view>
<view v-if="account.role === 'parent'" class="profile-line">
<text>试听次数</text>
<text>剩余 {{ account.trialBookingRemaining ?? account.trialBookingLimit ?? 1 }} / {{ account.trialBookingLimit ?? 1 }} 次</text>
</view>
</view>
<view v-else class="edit-form">
<!-- #ifdef MP-WEIXIN -->
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="chooseAvatar">
<image v-if="form.wechatAvatar" :src="form.wechatAvatar" mode="aspectFill" />
<text v-else>选头像</text>
</button>
<!-- #endif -->
<view class="form-field">
<text>姓名</text>
<input v-model="form.name" maxlength="20" placeholder="请输入姓名" />
</view>
<view class="form-field">
<text>孩子姓名</text>
<input v-model="form.childName" maxlength="20" placeholder="请输入孩子姓名" />
</view>
<view class="form-field">
<text>孩子年级</text>
<input v-model="form.childGrade" maxlength="20" placeholder="例如:三年级" />
</view>
<view class="form-field">
<text>学习关注点</text>
<input v-model="form.childFocus" maxlength="30" placeholder="例如:数学思维 / 阅读表达" />
</view>
<button class="save-button" :disabled="saving" @click="saveProfile">{{ saving ? '保存中' : '保存资料' }}</button>
</view>
</view>
<view class="lf-card action-card">
<view class="lf-section-title">账号安全</view>
<view class="entry-row" @click="togglePasswordPanel">
<view>
<text>{{ account.hasPassword ? '修改密码' : '设置密码' }}</text>
<text>{{ account.hasPassword ? '用于账号密码登录' : '设置后可使用账号密码登录' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="passwordPanel" class="security-panel">
<view v-if="account.hasPassword" class="form-field">
<text>原密码</text>
<input v-model="passwordForm.oldPassword" password maxlength="32" placeholder="请输入原密码" />
</view>
<view class="form-field">
<text>新密码</text>
<input v-model="passwordForm.password" password maxlength="32" placeholder="至少 6 位" />
</view>
<view class="form-field">
<text>确认密码</text>
<input v-model="passwordForm.confirmPassword" password maxlength="32" placeholder="请再次输入新密码" />
</view>
<button class="save-button" :disabled="securitySaving" @click="savePassword">
{{ securitySaving ? '保存中' : account.hasPassword ? '修改密码' : '设置密码' }}
</button>
</view>
<view class="entry-row" @click="togglePhonePanel">
<view>
<text>换绑手机号</text>
<text>当前:{{ account.phone || '未绑定' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="phonePanel" class="security-panel">
<view class="form-field">
<text>新手机号</text>
<input v-model="phoneForm.phone" type="number" maxlength="11" placeholder="请输入新手机号" />
</view>
<view class="form-field form-field--code">
<view class="code-input">
<text>验证码</text>
<input v-model="phoneForm.code" type="number" maxlength="6" placeholder="请输入验证码" />
</view>
<button class="code-button" :disabled="phoneCountdown > 0" @click="requestPhoneCode">
{{ phoneCountdown > 0 ? `${phoneCountdown}s` : '获取验证码' }}
</button>
</view>
<button class="save-button" :disabled="securitySaving" @click="savePhone">
{{ securitySaving ? '保存中' : '确认换绑' }}
</button>
</view>
<view class="entry-row entry-row--danger" @click="confirmLogout">
<view>
<text>退出登录</text>
<text>退出后需要重新登录当前账号</text>
</view>
<u-icon name="arrow-right" color="#b54732" size="22"></u-icon>
</view>
</view>
<view v-if="isAdmin" class="lf-card action-card">
<view class="lf-section-title">管理入口</view>
<view class="entry-row" @click="go('/pages/admin/index')">
<view>
<text>移动管理台</text>
<text>线索推进、积分管理、试听课程管理</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { sendSmsCode, updateBoundPhone, updatePassword, updateProfile, uploadAvatar } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount, refreshStoredAccount, setStoredAccount } from '../../services/accountSession'
function createProfileForm(account = {}) {
return {
name: account.name || '',
childName: account.child?.name || '',
childGrade: account.child?.grade || '',
childFocus: account.child?.focus || '',
wechatAvatar: account.wechatAvatar || '',
}
}
function createPasswordForm() {
return {
oldPassword: '',
password: '',
confirmPassword: '',
}
}
export default {
data() {
const account = getStoredAccount() || {}
return {
account,
editing: false,
saving: false,
securitySaving: false,
passwordPanel: false,
phonePanel: false,
phoneCountdown: 0,
phoneTimer: null,
form: createProfileForm(account),
passwordForm: createPasswordForm(),
phoneForm: {
phone: '',
code: '',
},
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
},
async onShow() {
const account = await refreshStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
this.form = createProfileForm(account)
},
async onPullDownRefresh() {
await this.onShow()
uni.stopPullDownRefresh()
},
onUnload() {
if (this.phoneTimer) clearInterval(this.phoneTimer)
},
methods: {
toggleEdit() {
this.editing = !this.editing
if (this.editing) this.form = createProfileForm(this.account)
},
async chooseAvatar(event) {
const localPath = event.detail.avatarUrl
if (!localPath) return
const res = await uploadAvatar(localPath)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '头像上传失败', type: 'error' })
return
}
this.form.wechatAvatar = res.data?.url || res.url || localPath
this.$refs.toast.show({ message: '头像已上传', type: 'success' })
},
async saveProfile() {
if (!this.form.name.trim()) {
this.$refs.toast.show({ message: '请填写姓名', type: 'error' })
return
}
this.saving = true
try {
const res = await updateProfile(this.account.id, this.form)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '保存失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.editing = false
this.$refs.toast.show({ message: '资料已保存', type: 'success' })
} finally {
this.saving = false
}
},
togglePasswordPanel() {
this.passwordPanel = !this.passwordPanel
this.passwordForm = createPasswordForm()
if (this.passwordPanel) this.phonePanel = false
},
togglePhonePanel() {
this.phonePanel = !this.phonePanel
this.phoneForm = { phone: '', code: '' }
if (this.phonePanel) this.passwordPanel = false
},
async requestPhoneCode() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请先输入正确手机号', type: 'error' })
return
}
const res = await sendSmsCode(this.phoneForm.phone, 'update_phone')
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '验证码发送失败', type: 'error' })
return
}
const data = res.data || res
this.startPhoneCountdown(data.cooldown || 60)
this.$refs.toast.show({ message: res.message || data.message || '验证码已发送', type: 'success' })
},
startPhoneCountdown(seconds = 60) {
if (this.phoneTimer) clearInterval(this.phoneTimer)
this.phoneCountdown = Number(seconds || 60)
this.phoneTimer = setInterval(() => {
this.phoneCountdown -= 1
if (this.phoneCountdown <= 0) {
clearInterval(this.phoneTimer)
this.phoneTimer = null
}
}, 1000)
},
async savePassword() {
if (this.account.hasPassword && !this.passwordForm.oldPassword) {
this.$refs.toast.show({ message: '请输入原密码', type: 'error' })
return
}
if (this.passwordForm.password.length < 6) {
this.$refs.toast.show({ message: '密码至少需要 6 位', type: 'error' })
return
}
if (this.passwordForm.password !== this.passwordForm.confirmPassword) {
this.$refs.toast.show({ message: '两次输入的密码不一致', type: 'error' })
return
}
this.securitySaving = true
try {
const res = await updatePassword(this.account.id, this.passwordForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '密码保存失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.passwordPanel = false
this.passwordForm = createPasswordForm()
this.$refs.toast.show({ message: '密码已更新', type: 'success' })
} finally {
this.securitySaving = false
}
},
async savePhone() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请输入正确手机号', type: 'error' })
return
}
if (!this.phoneForm.code) {
this.$refs.toast.show({ message: '请输入验证码', type: 'error' })
return
}
this.securitySaving = true
try {
const res = await updateBoundPhone(this.account.id, this.phoneForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '换绑失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.phonePanel = false
this.phoneForm = { phone: '', code: '' }
this.$refs.toast.show({ message: '手机号已换绑', type: 'success' })
} finally {
this.securitySaving = false
}
},
applyAccount(account) {
this.account = account
this.form = createProfileForm(account)
setStoredAccount(account)
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
},
}
</script>
<style lang="scss" scoped>
.profile-card {
margin-top: 18rpx;
padding: 32rpx;
}
.profile-card__top {
display: flex;
align-items: center;
gap: 22rpx;
}
.profile-card__avatar,
.profile-card__avatar-img {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
}
.profile-card__avatar {
display: grid;
place-items: center;
color: #0b2f2a;
background: #f5c451;
font-size: 42rpx;
font-weight: 900;
}
.profile-card__name {
color: #ffffff;
font-size: 36rpx;
font-weight: 900;
}
.profile-card__phone {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 24rpx;
}
.permission-strip {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 28rpx;
}
.permission-strip text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #dff8ee;
background: rgba(255, 255, 255, 0.1);
font-size: 20rpx;
font-weight: 800;
}
.action-card {
margin-top: 22rpx;
padding: 28rpx;
}
.mini-action {
display: flex;
align-items: center;
justify-content: center;
min-width: 96rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 900;
line-height: normal;
}
.profile-list {
margin-top: 18rpx;
}
.profile-line {
display: flex;
justify-content: space-between;
gap: 24rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.profile-line:last-child {
border-bottom: 0;
}
.profile-line text:first-child {
color: #6f7c78;
font-size: 24rpx;
}
.profile-line text:last-child {
color: #17312d;
font-size: 26rpx;
font-weight: 900;
text-align: right;
}
.edit-form,
.security-panel {
margin-top: 20rpx;
padding: 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 16rpx;
background: #fbfdf8;
}
.avatar-button {
display: grid;
place-items: center;
width: 112rpx;
height: 112rpx;
margin-bottom: 22rpx;
border-radius: 28rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 900;
overflow: hidden;
}
.avatar-button image {
width: 112rpx;
height: 112rpx;
}
.form-field {
margin-top: 18rpx;
}
.form-field:first-child {
margin-top: 0;
}
.form-field text {
display: block;
margin-bottom: 10rpx;
color: #6f7c78;
font-size: 22rpx;
font-weight: 900;
}
.form-field input {
height: 78rpx;
padding: 0 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 26rpx;
line-height: 78rpx;
}
.form-field--code {
display: grid;
grid-template-columns: minmax(0, 1fr) 190rpx;
align-items: end;
gap: 16rpx;
}
.code-button,
.save-button {
display: flex;
align-items: center;
justify-content: center;
height: 78rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 900;
line-height: normal;
}
.code-button {
color: #0b4f46;
border: 1rpx solid #b9d8d2;
background: #dff8ee;
}
.code-button[disabled] {
color: #7d8b86;
background: #eef3ec;
}
.save-button {
margin-top: 24rpx;
color: #ffffff;
background: #0b4f46;
}
.entry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.entry-row:last-child {
border-bottom: 0;
}
.entry-row--danger text:first-child {
color: #b54732;
}
.entry-row text {
display: block;
}
.entry-row text:first-child {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.entry-row text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
</style>