petstore-frontend/src/pages/mine/Profile.vue
2026-04-14 21:04:26 +08:00

612 lines
17 KiB
Vue
Raw 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>
<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>