612 lines
17 KiB
Vue
612 lines
17 KiB
Vue
<template>
|
||
<div class="page-shell profile-page">
|
||
<!-- 顶部导航 -->
|
||
<view class="profile-nav nav-gradient" :style="navSafeStyle">
|
||
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||
<text class="nav-title">个人信息</text>
|
||
<view class="nav-placeholder"></view>
|
||
</view>
|
||
|
||
<!-- 头像与概览(与「我的」页信息行一致:左图右文) -->
|
||
<div class="profile-hero">
|
||
<div class="hero-main-row">
|
||
<div class="avatar-wrap" @click="changeAvatar">
|
||
<img v-if="userInfo.avatar" :src="imgUrl(userInfo.avatar)" class="avatar-img" />
|
||
<div v-else class="avatar-placeholder" :style="avatarBg">
|
||
<span class="avatar-initials">{{ initials }}</span>
|
||
</div>
|
||
<div class="avatar-badge" aria-hidden="true"><AppIcon name="camera" :size="12" color="#64748b" /></div>
|
||
</div>
|
||
<div class="hero-text">
|
||
<div class="hero-name text-title">{{ userInfo.name || '未设置姓名' }}</div>
|
||
<div class="hero-phone text-sub">{{ userInfo.phone || '未绑定手机号' }}</div>
|
||
<div class="hero-tags">
|
||
<span v-if="userInfo.role === 'boss'" class="role-pill hero-role">店长</span>
|
||
<span v-else-if="userInfo.role === 'customer'" class="role-pill hero-role role-customer">客户</span>
|
||
<span v-else class="role-pill hero-role role-staff">员工</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hero-hint text-sub">点击左侧头像可更换照片</div>
|
||
</div>
|
||
|
||
<!-- 资料项:与「我的」菜单卡片同源结构 -->
|
||
<div class="menu-section">
|
||
<div class="menu-card">
|
||
<div class="menu-title">资料与账号</div>
|
||
<div class="menu-item" @click="openEditName">
|
||
<div class="menu-left">
|
||
<span class="menu-icon"><AppIcon name="profile" :size="15" /></span>
|
||
<span class="menu-text">姓名</span>
|
||
</div>
|
||
<div class="menu-right">
|
||
<span class="menu-value">{{ userInfo.name || '未设置' }}</span>
|
||
<span class="menu-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
<div class="menu-item" @click="openEditPhone">
|
||
<div class="menu-left">
|
||
<span class="menu-icon"><AppIcon name="orders" :size="15" /></span>
|
||
<span class="menu-text">手机号</span>
|
||
</div>
|
||
<div class="menu-right">
|
||
<span class="menu-value">{{ userInfo.phone || '未设置' }}</span>
|
||
<span class="menu-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
<div class="menu-item menu-item-static">
|
||
<div class="menu-left">
|
||
<span class="menu-icon"><AppIcon name="mine" :size="15" /></span>
|
||
<span class="menu-text">角色</span>
|
||
</div>
|
||
<div class="menu-right">
|
||
<span v-if="userInfo.role === 'boss'" class="role-pill role-pill-compact">店长</span>
|
||
<span v-else-if="userInfo.role === 'customer'" class="role-pill role-pill-compact role-customer">客户</span>
|
||
<span v-else class="role-pill role-pill-compact role-staff">员工</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部信息 -->
|
||
<div class="footer-tip">宠伴生活馆 · {{ editionLabel }}</div>
|
||
|
||
<!-- 修改姓名弹窗 -->
|
||
<view v-if="showEditName" class="popup-mask profile-popup-mask" @click="showEditName = false">
|
||
<view class="popup-content profile-popup-sheet" @click.stop>
|
||
<view class="popup-header">
|
||
<text class="popup-title">修改姓名</text>
|
||
<view class="popup-close" @click="showEditName = false"><AppIcon name="close" :size="18" color="#94a3b8" /></view>
|
||
</view>
|
||
<view class="popup-body">
|
||
<view class="field-label">姓名</view>
|
||
<!-- 小程序原生 input 需外包一层可见边框,否则易与弹窗底色融为一体 -->
|
||
<view class="profile-input-shell">
|
||
<input
|
||
v-model="editForm.name"
|
||
class="profile-input-native"
|
||
type="nickname"
|
||
placeholder="请输入姓名"
|
||
maxlength="32"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="popup-footer profile-popup-footer">
|
||
<button class="van-button van-button--block van-button--primary" :loading="saving" @click="confirmEditName">保存</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 修改手机号弹窗 -->
|
||
<view v-if="showEditPhone" class="popup-mask profile-popup-mask" @click="showEditPhone = false">
|
||
<view class="popup-content profile-popup-sheet" @click.stop>
|
||
<view class="popup-header">
|
||
<text class="popup-title">修改手机号</text>
|
||
<view class="popup-close" @click="showEditPhone = false"><AppIcon name="close" :size="18" color="#94a3b8" /></view>
|
||
</view>
|
||
<view class="popup-body">
|
||
<view class="field-label">新手机号</view>
|
||
<view class="profile-input-shell">
|
||
<input v-model="editForm.phone" class="profile-input-native" type="number" placeholder="请输入手机号" maxlength="11" />
|
||
</view>
|
||
<view class="field-label field-label-gap">验证码</view>
|
||
<view class="sms-row">
|
||
<view class="profile-input-shell profile-sms-code-shell">
|
||
<input v-model="editForm.code" class="profile-input-native" type="digit" placeholder="请输入验证码" maxlength="6" />
|
||
</view>
|
||
<button class="van-button van-button--small van-button--primary" :disabled="smsCountdown > 0" @click="sendSms">
|
||
{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
<view class="popup-footer profile-popup-footer">
|
||
<button class="van-button van-button--block van-button--primary" :loading="savingPhone" @click="confirmEditPhone">保存</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import AppIcon from '../../components/AppIcon.vue'
|
||
import { navigateTo } from '../../utils/globalState.js'
|
||
import { updateUser, sendSms as sendSmsApi, imgUrl, BASE_URL } from '../../api/index.js'
|
||
import { getUserSession, setUserSession } from '../../utils/session.js'
|
||
|
||
const emit = defineEmits(['change-page'])
|
||
const goBackToMine = () => {
|
||
const pages = getCurrentPages()
|
||
if (pages.length > 1) {
|
||
uni.navigateBack()
|
||
return
|
||
}
|
||
navigateTo('mine')
|
||
}
|
||
|
||
const navSafeStyle = (() => {
|
||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||
let navHeight = statusBarHeight + 44
|
||
const menuRect = uni.getMenuButtonBoundingClientRect?.()
|
||
if (menuRect && menuRect.top && menuRect.height) {
|
||
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
|
||
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
|
||
}
|
||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||
})()
|
||
|
||
const userInfo = ref(getUserSession())
|
||
|
||
const showEditName = ref(false)
|
||
const showEditPhone = ref(false)
|
||
const saving = ref(false)
|
||
const savingPhone = ref(false)
|
||
const smsCountdown = ref(0)
|
||
let smsTimer = null
|
||
|
||
const editForm = ref({ name: '', phone: '', code: '' })
|
||
|
||
const editionLabel = computed(() => {
|
||
const r = userInfo.value.role
|
||
if (r === 'boss') return '商家版'
|
||
if (r === 'customer') return '客户版'
|
||
return '员工版'
|
||
})
|
||
|
||
const initials = computed(() => {
|
||
if (!userInfo.value.name) return '?'
|
||
return userInfo.value.name.slice(0, 1).toUpperCase()
|
||
})
|
||
|
||
const COLORS = ['#f5913e', '#2db96d', '#8b6914', '#e06050', '#5090d0']
|
||
const avatarBg = computed(() => {
|
||
const idx = (userInfo.value.name?.charCodeAt(0) || 0) % COLORS.length
|
||
return { background: COLORS[idx] }
|
||
})
|
||
|
||
/** 打开弹窗前同步当前资料,避免仅 onMounted 一次导致输入框空白或与展示不一致(尤其 C 端微信登录后) */
|
||
const openEditName = () => {
|
||
editForm.value.name = userInfo.value.name || ''
|
||
showEditName.value = true
|
||
}
|
||
|
||
const openEditPhone = () => {
|
||
editForm.value.phone = userInfo.value.phone || ''
|
||
editForm.value.code = ''
|
||
showEditPhone.value = true
|
||
}
|
||
|
||
const sendSms = async () => {
|
||
const phone = editForm.value.phone
|
||
if (!phone || phone.length !== 11) {
|
||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' }); return
|
||
}
|
||
const res = await sendSmsApi(phone)
|
||
if (res.code === 200) {
|
||
uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||
smsCountdown.value = 60
|
||
smsTimer = setInterval(() => {
|
||
smsCountdown.value--
|
||
if (smsCountdown.value <= 0) clearInterval(smsTimer)
|
||
}, 1000)
|
||
} else {
|
||
uni.showToast({ title: res.message || '发送失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const confirmEditName = async () => {
|
||
if (!editForm.value.name || !editForm.value.name.trim()) {
|
||
uni.showToast({ title: '请输入姓名', icon: 'none' }); return
|
||
}
|
||
saving.value = true
|
||
const res = await updateUser({ id: userInfo.value.id, name: editForm.value.name.trim() })
|
||
saving.value = false
|
||
if (res.code === 200) {
|
||
userInfo.value.name = editForm.value.name.trim()
|
||
setUserSession(userInfo.value)
|
||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||
showEditName.value = false
|
||
} else {
|
||
uni.showToast({ title: res.message || '修改失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const confirmEditPhone = async () => {
|
||
const { phone, code } = editForm.value
|
||
if (!phone || phone.length !== 11) {
|
||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' }); return
|
||
}
|
||
if (!code || code.length !== 6) {
|
||
uni.showToast({ title: '请输入6位验证码', icon: 'none' }); return
|
||
}
|
||
savingPhone.value = true
|
||
const res = await updateUser({ id: userInfo.value.id, phone, code })
|
||
savingPhone.value = false
|
||
if (res.code === 200) {
|
||
userInfo.value.phone = phone
|
||
setUserSession(userInfo.value)
|
||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||
showEditPhone.value = false
|
||
} else {
|
||
uni.showToast({ title: res.message || '修改失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const changeAvatar = () => {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
success: async (res) => {
|
||
const tempPath = res.tempFilePaths[0]
|
||
uni.showLoading({ title: '上传中...' })
|
||
uni.uploadFile({
|
||
url: `${BASE_URL}/upload/image`,
|
||
filePath: tempPath,
|
||
name: 'file',
|
||
success: async (uploadRes) => {
|
||
uni.hideLoading()
|
||
const data = JSON.parse(uploadRes.data)
|
||
if (data.code === 200) {
|
||
const avatarUrl = imgUrl(data.data.url)
|
||
const updateRes = await updateUser({ id: userInfo.value.id, avatar: data.data.url })
|
||
if (updateRes.code === 200) {
|
||
userInfo.value.avatar = avatarUrl
|
||
setUserSession(userInfo.value)
|
||
uni.showToast({ title: '头像已更新', icon: 'success' })
|
||
} else {
|
||
uni.showToast({ title: updateRes.message || '更新失败', icon: 'none' })
|
||
}
|
||
} else {
|
||
uni.showToast({ title: data.message || '上传失败', icon: 'none' })
|
||
}
|
||
},
|
||
fail: () => {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
userInfo.value = getUserSession()
|
||
editForm.value.name = userInfo.value.name || ''
|
||
editForm.value.phone = userInfo.value.phone || ''
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (smsTimer) clearInterval(smsTimer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.profile-page {
|
||
padding-bottom: 120rpx;
|
||
background: var(--pet-bg, #f5f7fb);
|
||
min-height: 100vh;
|
||
}
|
||
.nav-placeholder { width: 32px; }
|
||
|
||
.profile-nav {
|
||
padding: 14px 16px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.nav-back { font-size: 20px; color: #fff; }
|
||
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
|
||
|
||
/* 顶部概览卡:左头像、右文案,与「我的」用户条同一逻辑 */
|
||
.profile-hero {
|
||
margin: 14px 14px 0;
|
||
padding: 18px 16px 14px;
|
||
background: #fff;
|
||
border: 1px solid #e6ecf4;
|
||
border-radius: 16px;
|
||
box-shadow: var(--pet-card-shadow, 0 8px 24px rgba(15, 23, 42, 0.06));
|
||
}
|
||
.hero-main-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.avatar-wrap {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.avatar-img,
|
||
.avatar-placeholder {
|
||
width: 84px;
|
||
height: 84px;
|
||
border-radius: 50%;
|
||
border: 2px solid #e8edf4;
|
||
object-fit: cover;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.avatar-placeholder {
|
||
background: rgba(226, 232, 240, 0.75);
|
||
}
|
||
.avatar-initials {
|
||
font-size: 30px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
.avatar-badge {
|
||
position: absolute;
|
||
bottom: 2px;
|
||
right: 2px;
|
||
width: 28px;
|
||
height: 28px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
border: 1px solid #e2e8f0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
|
||
}
|
||
.hero-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.hero-name {
|
||
font-size: 19px;
|
||
line-height: 1.25;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.hero-phone {
|
||
margin-top: 6px;
|
||
font-size: 14px;
|
||
color: var(--pet-subtext, #6b7280);
|
||
}
|
||
.hero-tags {
|
||
margin-top: 10px;
|
||
}
|
||
.role-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 26px;
|
||
padding: 0 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
background: #fff7ed;
|
||
color: #c2410c;
|
||
}
|
||
.role-pill.hero-role.role-staff,
|
||
.role-pill.role-staff {
|
||
background: #ecfdf3;
|
||
color: #166534;
|
||
}
|
||
.role-pill.hero-role.role-customer,
|
||
.role-pill.role-pill-compact.role-customer {
|
||
background: #eff6ff;
|
||
color: #1d4ed8;
|
||
}
|
||
.role-pill-compact {
|
||
height: 24px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
}
|
||
.hero-hint {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f1f5f9;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 与 Mine.vue 一致的菜单列表 */
|
||
.menu-section {
|
||
padding: 0 14px;
|
||
margin-top: 12px;
|
||
}
|
||
.menu-card {
|
||
background: #fff;
|
||
border: 1px solid #e6ecf4;
|
||
border-radius: 16px;
|
||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
.menu-title {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
font-weight: 700;
|
||
padding: 12px 14px 8px;
|
||
}
|
||
.menu-item {
|
||
min-height: 54px;
|
||
padding: 0 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.menu-item:active:not(.menu-item-static) {
|
||
background: #f8fafc;
|
||
}
|
||
.menu-item + .menu-item {
|
||
border-top: 1px solid #eef2f7;
|
||
}
|
||
.menu-item-static {
|
||
cursor: default;
|
||
}
|
||
.menu-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
.menu-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 8px;
|
||
background: #f1f5f9;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.menu-text {
|
||
font-size: 16px;
|
||
color: #1f2937;
|
||
font-weight: 600;
|
||
}
|
||
.menu-right {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
max-width: 55%;
|
||
justify-content: flex-end;
|
||
}
|
||
.menu-value {
|
||
font-size: 14px;
|
||
color: #64748b;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
}
|
||
.menu-arrow {
|
||
color: #c4cfdd;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.field-label-gap { margin-top: 12px; }
|
||
|
||
.profile-popup-mask {
|
||
z-index: 3000;
|
||
align-items: flex-end;
|
||
}
|
||
.profile-popup-sheet {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
max-height: 88vh;
|
||
border-radius: 20px 20px 0 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.profile-popup-sheet .popup-header {
|
||
flex-shrink: 0;
|
||
padding: 18px 20px 14px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
.profile-popup-sheet .popup-title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
.profile-popup-sheet .popup-close {
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: -6px -8px -6px 0;
|
||
border-radius: 10px;
|
||
}
|
||
.profile-popup-sheet .popup-close:active {
|
||
background: #f1f5f9;
|
||
}
|
||
.profile-popup-sheet .popup-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px 20px;
|
||
}
|
||
.profile-popup-footer {
|
||
flex-shrink: 0;
|
||
padding: 8px 20px max(env(safe-area-inset-bottom), 20px);
|
||
border-top: 1px solid #f1f5f9;
|
||
background: #fff;
|
||
}
|
||
|
||
.sms-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.sms-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.sms-row .profile-sms-code-shell {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.footer-tip {
|
||
text-align: center;
|
||
margin-top: 28px;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
</style>
|
||
|
||
<!-- 非 scoped:小程序原生 input 在部分机型上 scoped 样式穿透不稳定 -->
|
||
<style>
|
||
.profile-popup-sheet .profile-input-shell {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-height: 50px;
|
||
padding: 0 14px;
|
||
background: #f1f5f9;
|
||
border: 2px solid #94a3b8;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.profile-popup-sheet .profile-input-native {
|
||
display: block;
|
||
flex: 1;
|
||
width: 100%;
|
||
min-width: 0;
|
||
height: 46px;
|
||
min-height: 46px;
|
||
line-height: 46px;
|
||
padding: 0;
|
||
margin: 0;
|
||
border: none !important;
|
||
outline: none;
|
||
background: transparent !important;
|
||
font-size: 16px;
|
||
color: #0f172a;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
.profile-popup-sheet .profile-input-native::placeholder {
|
||
color: #64748b;
|
||
}
|
||
</style>
|