feat(profile): 新增个人资料设置页面及功能

添加个人资料设置页面,支持用户设置头像和昵称。主要变更包括:
1. 新增 profile 页面路由配置
2. 扩展用户类型定义,增加 profileDone 字段
3. 添加更新用户信息的 API 接口
4. 实现头像上传、压缩和保存功能
5. 在首页检查用户资料完成状态并跳转
6. 优化用户信息处理逻辑,提取公共方法
This commit is contained in:
dzq 2025-12-04 17:42:21 +08:00
parent c6df25cfff
commit a1e4a46656
9 changed files with 560 additions and 62 deletions

View File

@ -21,7 +21,8 @@
"Bash(mkdir -p \"E:\\code\\智柜宝\\wx\\src\\pages\\rental\")",
"Bash(cat /E/code/智柜宝/wx/README.md)",
"Bash(cat /E/code/智柜宝/wx/package.json)",
"Bash(cat /E/code/智柜宝/wx/manifest.config.ts)"
"Bash(cat /E/code/智柜宝/wx/manifest.config.ts)",
"Bash(tree:*)"
],
"deny": [],
"ask": []

View File

@ -17,4 +17,9 @@ export async function getWxUserByOpenid(openid: string) {
/** 根据openid获取动态码 */
export async function generateDynamicCode(openid: string) {
return await http.get<DynamicCodeResponse>("wx/generateDynamicCode", { openid });
}
/** 根据openid更新用户昵称和头像 */
export async function updateUserByOpenid(openid: string, nickName?: string, avatar?: string) {
return await http.get<WxUserDTO>("wx/updateUserByOpenid", { openid, nickName, avatar });
}

View File

@ -16,6 +16,8 @@ export interface WxUserDTO {
nickName?: string;
/** 头像 */
avatar?: string;
/** 是否完成个人信息填写 */
profileDone?: boolean;
/** 手机号码 */
tel?: string;
/** 余额(分) */
@ -32,6 +34,7 @@ export interface WxUserDTO {
ab98Name?: string;
/** 汇邦云用户头像 */
ab98FaceImg?: string;
/** 企业用户信息 */
qyUser?: {
/** 企业用户ID */

View File

@ -128,6 +128,13 @@
"navigationBarTitleText": "支付成功"
}
},
{
"path": "pages/profile/index",
"type": "page",
"style": {
"navigationBarTitleText": "设置头像昵称"
}
},
{
"path": "pages/QrScanner/index",
"type": "page",

View File

@ -3,11 +3,11 @@ import { ref, onMounted } from 'vue'
import { useWxStore } from '@/pinia/stores/wx'
import { useProductStore } from '@/pinia/stores/product'
import { useCartStore } from '@/pinia/stores/cart'
import { getCorpidById, getShopListApi } from '@/api/shop'
import { getShopListApi } from '@/api/shop'
import type { ShopEntity } from '@/api/shop/types'
import ProductContainer from './components/product-container.vue';
import { generateDynamicCode, getWxUserByOpenid, mpCodeToOpenId } from '@/api/users'
import { toHttpsUrl } from '@/utils'
import { toHttpsUrl, uniLogin } from '@/utils'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { storeToRefs } from 'pinia'
import { useWxParamsStore } from '@/pinia/stores/wx-params'
@ -29,6 +29,8 @@ const showShopList = ref<boolean>(true)
const shopList = ref<ShopEntity[]>([])
const shopId = ref<number>(0)
//
onMounted(async () => {
})
@ -67,75 +69,75 @@ function handleCheckout() {
onLoad(async (query) => {
const wxParamsStore = useWxParamsStore();
const { wxUserDTO } = storeToRefs(wxStore);
console.log('page index onLoad query: ', query);
// scene 使 decodeURIComponent scene
// scene
if (query && query.scene) {
const scene = decodeURIComponent(query.scene);
wxParamsStore.parseScene(scene);
}
const cid = wxParamsStore.getNumberParam('cid', -1);
let corpid = uni.getStorageSync('local_corpid');
// IDID
if (cid > 0) {
// IDcid > 0APIID
corpid = (await getCorpidById(cid)).data;
// ID
uni.setStorageSync('local_corpid', corpid || '');
} else if (corpid) {
// ID使
// corpid
} else {
// cidcorpidIDcid=0
corpid = (await getCorpidById(0)).data;
// ID
uni.setStorageSync('local_corpid', corpid || '');
}
// ID
const cid = wxParamsStore.getNumberParam('cid', -1);
const corpid = await wxStore.getCorpid(cid);
//
let openid = uni.getStorageSync('local_openid');
if (!openid) {
uni.login({
provider: 'weixin', //使
success: function (loginRes) {
mpCodeToOpenId(loginRes.code).then((wxUser) => {
console.log('wxUser:', wxUser);
wxStore.wxMpCallback(wxUser.data, corpid);
uni.setStorageSync('local_openid', wxUser.data.openid || '');
if (wxUser.data.ab98UserId) {
ab98UserStore.setAb98UserName(wxUser.data.ab98Name || wxUser.data.nickName || '')
ab98UserStore.setAb98UserFaceImg(wxUser.data.ab98FaceImg || '')
}
}).catch((e) => {
console.error('mpCodeToOpenId error:', e)
})
},
});
} else {
//
const wxUser = await getWxUserByOpenid(openid);
console.log('wxUser:', wxUser);
wxStore.wxMpCallback(wxUser.data, corpid);
uni.setStorageSync('local_openid', wxUser.data.openid || '');
if (wxUser.data.ab98UserId) {
ab98UserStore.setAb98UserName(wxUser.data.ab98Name || wxUser.data.nickName || '')
ab98UserStore.setAb98UserFaceImg(wxUser.data.ab98FaceImg || '')
try {
if (!openid) {
//
const loginRes = await uniLogin({ provider: 'weixin' });
const wxUser = await mpCodeToOpenId(loginRes.code);
console.log('wxUser:', wxUser);
await wxStore.processUserInfo(wxUser.data, corpid);
} else {
//
const wxUser = await getWxUserByOpenid(openid);
console.log('wxUser:', wxUser);
await wxStore.processUserInfo(wxUser.data, corpid);
}
} catch (error) {
console.error('用户登录处理失败:', error);
}
//
// ab98UserId profileDone
if (wxUserDTO.value && !wxUserDTO.value.ab98UserId && wxUserDTO.value.profileDone !== true) {
console.log('用户需要完善资料,跳转到个人资料设置页面');
uni.navigateTo({
url: '/pages/profile/index'
});
return; //
}
//
if (showShopList.value) {
try {
// handleWxCallback
await wxStore.waitForHandleWxCallbackComplete();
const res = await getShopListApi(wxStore.corpid || '');
console.log('获取店铺列表:', res)
console.log('获取店铺列表:', res);
if (res?.code === 0 && res?.data?.length > 0) {
shopList.value = res.data;
// shopList.value = [...shopList.value, ...res.data, ...res.data, ...res.data];
}
} catch (error) {
console.error('获取店铺列表失败:', error)
console.error('获取店铺列表失败:', error);
}
}
});
onShow(async () => {
//
if (showShopList.value) {
try {
await wxStore.waitForHandleWxCallbackComplete();
const res = await getShopListApi(wxStore.corpid || '');
console.log('获取店铺列表:', res);
if (res?.code === 0 && res?.data?.length > 0) {
shopList.value = res.data;
}
} catch (error) {
console.error('获取店铺列表失败:', error);
}
}
})

View File

@ -5,7 +5,7 @@ import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { useWxParamsStore } from '@/pinia/stores/wx-params'
import { storeToRefs } from 'pinia'
import { toHttpsUrl } from '@/utils'
import { generateDynamicCode } from '@/api/users'
import { generateDynamicCode, getWxUserByOpenid } from '@/api/users'
import { DynamicCodeResponse } from '@/api/users/types'
import { useToast } from 'wot-design-uni'
@ -30,7 +30,8 @@ const name = computed(() => {
return userName.value || qyName.value || '未知用户'
})
const userAvatar = computed(() => face_img.value ? toHttpsUrl(face_img.value) : '/static/favicon.ico')
const userAvatar = computed(() => face_img.value ? toHttpsUrl(face_img.value) :
wxUserDTO.value?.avatar ? toHttpsUrl(wxUserDTO.value.avatar) : '/static/favicon.ico')
//
const dynamicCodeActionSheet = ref<boolean>(false)
@ -80,8 +81,10 @@ onMounted(() => {
wxStore.refreshBalance()
});
onShow(() => {
wxStore.refreshBalance()
onShow(async () => {
const wxUser = await getWxUserByOpenid(wxStore.openid);
console.log('wxUser:', wxUser);
await wxStore.processUserInfo(wxUser.data, wxStore.corpid);
});
//
@ -114,7 +117,7 @@ const handleGenerateDynamicCode = async () => {
<view class="avatar-wrapper">
<image
class="avatar"
:src="wxUserDTO?.ab98FaceImg"
:src="wxUserDTO?.ab98FaceImg || wxUserDTO?.avatar || '/static/favicon.ico'"
mode="aspectFill"
@click="handleShowWxParams"
/>

429
src/pages/profile/index.vue Normal file
View File

@ -0,0 +1,429 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { useWxStore } from '@/pinia/stores/wx'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { storeToRefs } from 'pinia'
import { getWxUserByOpenid, updateUserByOpenid } from '@/api/users'
import { useToast } from 'wot-design-uni'
import { getEnvBaseUploadUrl, toHttpsUrl } from '@/utils'
definePage({
style: {
navigationBarTitleText: '设置头像昵称',
},
})
const wxStore = useWxStore()
const ab98UserStore = useAb98UserStore()
const toast = useToast()
const { wxUserDTO, openid } = storeToRefs(wxStore)
const { name: userName, face_img } = storeToRefs(ab98UserStore)
//
const avatarUrl = ref('')
const tempAvatarPath = ref('') //
//
const nickName = ref('')
//
onMounted(() => {
// 使使
if (wxUserDTO.value) {
avatarUrl.value = wxUserDTO.value.avatar || ''
nickName.value = wxUserDTO.value.nickName || ''
}
// 使
if (!avatarUrl.value && face_img.value) {
avatarUrl.value = toHttpsUrl(face_img.value)
}
// 使
if (!nickName.value && userName.value) {
nickName.value = userName.value
}
//
if (!avatarUrl.value) {
avatarUrl.value = '/static/favicon.ico'
}
})
//
const uploadUrl = `${getEnvBaseUploadUrl()}/file/upload`
const uploading = ref(false)
// -
const compressImage = (src: string): Promise<string> => {
return new Promise((resolve, reject) => {
//
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
//
uni.downloadFile({
url: src,
success: (downloadResult) => {
console.log('网络图片下载完成:', downloadResult.tempFilePath)
//
compressLocalImage(downloadResult.tempFilePath)
},
fail: (err) => {
console.warn('网络图片下载失败:', err)
reject(new Error('网络图片下载失败'))
}
})
} else {
//
compressLocalImage(src)
}
//
function compressLocalImage(localSrc: string) {
uni.compressImage({
src: localSrc,
quality: 0.8,
maxWidth: 980,
maxHeight: 980,
success: (res) => {
console.log('头像图片压缩成功:', res)
resolve(res.tempFilePath)
},
fail: (err) => {
console.warn('头像图片压缩失败,使用原图:', err)
// 使
resolve(localSrc)
}
})
}
})
}
//
const uploadAvatar = async (filePath: string): Promise<string> => {
uploading.value = true
try {
//
const compressedPath = await compressImage(filePath)
console.log('头像压缩完成:', compressedPath)
//
const uploadResult = await new Promise<any>((resolve, reject) => {
uni.uploadFile({
url: uploadUrl,
filePath: compressedPath,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
try {
const response = JSON.parse(res.data)
resolve(response)
} catch (e) {
reject(new Error('上传响应解析失败'))
}
} else {
reject(new Error(`上传失败,状态码: ${res.statusCode}`))
}
},
fail: (err) => {
reject(err)
}
})
})
//
if (uploadResult.code === 0 && uploadResult.data && uploadResult.data.url) {
const avatarUrl = toHttpsUrl(uploadResult.data.url)
console.log('头像上传成功:', avatarUrl)
return avatarUrl
} else {
throw new Error(uploadResult.msg || '上传失败')
}
} catch (error) {
console.error('头像上传失败:', error)
throw error
} finally {
uploading.value = false
}
}
//
const onChooseAvatar = async (e: any) => {
const { avatarUrl: tempUrl } = e.detail
tempAvatarPath.value = tempUrl
avatarUrl.value = tempUrl
//
uni.showLoading({
title: '正在压缩上传头像...',
mask: true
})
try {
//
const uploadedUrl = await uploadAvatar(tempUrl)
// 使URL
avatarUrl.value = uploadedUrl
tempAvatarPath.value = uploadedUrl
uni.showToast({
title: '头像上传成功',
icon: 'success',
duration: 2000
})
} catch (error) {
console.error('头像上传失败:', error)
uni.showToast({
title: '头像上传失败,请重试',
icon: 'error',
duration: 2000
})
// 使
} finally {
uni.hideLoading()
}
}
//
const loading = ref(false)
const handleSubmit = async () => {
if (!openid.value) {
toast.show('用户信息获取失败,请重新登录')
return
}
if (!nickName.value.trim()) {
toast.show('请输入昵称')
return
}
// 使
const finalAvatarPath = tempAvatarPath.value || avatarUrl.value
loading.value = true
try {
// 使URLURL
const result = await updateUserByOpenid(
openid.value,
nickName.value.trim(),
finalAvatarPath
)
if (result?.code === 0) {
// store
const wxUser = await getWxUserByOpenid(openid.value);
console.log('wxUser:', wxUser);
await wxStore.processUserInfo(wxUser.data, wxStore.corpid);
toast.show('更新成功')
//
uni.navigateBack()
} else {
toast.show(result?.msg || '更新失败')
}
} catch (error) {
console.error('更新用户信息失败:', error)
toast.show('更新失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
<template>
<view class="profile-setting-page">
<!-- 头像设置区域 -->
<view class="setting-card">
<view class="section-title">设置头像</view>
<view class="avatar-section">
<view class="current-avatar">
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
</view>
<view class="avatar-hint">当前头像</view>
</view>
<!-- 微信小程序选择头像按钮 -->
<view class="choose-avatar-btn">
<button
class="avatar-button"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
选择头像
</button>
<view class="avatar-tip">点击按钮选择微信头像</view>
</view>
</view>
<!-- 昵称设置区域 -->
<view class="setting-card">
<view class="section-title">设置昵称</view>
<view class="nickname-section">
<input
class="nickname-input"
type="nickname"
v-model="nickName"
placeholder="请输入昵称"
:maxlength="20"
/>
<view class="nickname-tip">输入时键盘上方会展示微信昵称</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<wd-button
type="primary"
block
:loading="loading"
@click="handleSubmit"
>
{{ loading ? '更新中...' : '保存设置' }}
</wd-button>
</view>
</view>
</template>
<style scoped lang="scss">
.profile-setting-page {
background: #f7f8fa;
min-height: 100vh;
padding: 12px;
}
.setting-card {
background: white;
border-radius: 16px;
padding: 20px 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.05);
.section-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 20px;
padding-left: 8px;
position: relative;
letter-spacing: 0.5px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
border-radius: 2px;
}
}
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
.current-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
border: 4px solid rgba(64, 158, 255, 0.2);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
margin-bottom: 12px;
.avatar {
width: 100%;
height: 100%;
}
}
.avatar-hint {
font-size: 14px;
color: #666;
}
}
.choose-avatar-btn {
display: flex;
flex-direction: column;
align-items: center;
.avatar-button {
width: 200px;
height: 44px;
line-height: 44px;
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
color: white;
border: none;
border-radius: 22px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
margin-bottom: 12px;
&:active {
opacity: 0.9;
transform: translateY(1px);
}
}
.avatar-tip {
font-size: 12px;
color: #999;
text-align: center;
}
}
.nickname-section {
.nickname-input {
width: 100%;
height: 48px;
padding: 0 16px;
background: #f7f8fa;
border: 1px solid #e5e5e5;
border-radius: 24px;
font-size: 16px;
color: #1a1a1a;
margin-bottom: 12px;
&:focus {
border-color: #409EFF;
background: white;
}
&::placeholder {
color: #999;
}
}
.nickname-tip {
font-size: 12px;
color: #999;
padding-left: 8px;
}
}
.submit-section {
margin-top: 24px;
::v-deep .wd-button {
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #409EFF 0%, #66B1FF 100%);
border: none;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
&:active {
opacity: 0.9;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
import { pinia } from "@/pinia"
import { getOpenIdApi, getBalanceApi, qyLogin, getBalanceByQyUserid, fakeQyLoginApi, getUserBalance } from "@/api/shop"
import { getOpenIdApi, getBalanceApi, qyLogin, getBalanceByQyUserid, fakeQyLoginApi, getUserBalance, getCorpidById } from "@/api/shop"
import { ab98UserDTO, GetBalanceResponse } from "@/api/shop/types"
import { useAb98UserStore } from "./ab98-user"
import { defineStore } from "pinia"
@ -199,10 +199,39 @@ export const useWxStore = defineStore("wx", () => {
refreshBalance();
}
// 处理用户信息的通用函数
const processUserInfo = async (userData: any, corpid_: string) => {
wxMpCallback(userData, corpid_)
uni.setStorageSync('local_openid', userData.openid || '')
if (userData.ab98UserId) {
const ab98UserStore = useAb98UserStore();
ab98UserStore.setAb98UserName(userData.ab98Name || userData.nickName || '')
ab98UserStore.setAb98UserFaceImg(userData.ab98FaceImg || '')
}
}
// 获取企业ID
const getCorpid = async (cidParam: number): Promise<string> => {
let corpid = uni.getStorageSync('local_corpid')
const cid = cidParam
if (cid > 0) {
corpid = (await getCorpidById(cid)).data
} else if (!corpid) {
corpid = (await getCorpidById(0)).data
}
// 如果 corpid 有值,缓存它
if (corpid) {
uni.setStorageSync('local_corpid', corpid)
}
return corpid || ''
}
return { code, state, openid, corpid, userid, balance, useBalance,
balanceLimit, isCabinetAdmin, corpidLogin, name, ab98User, qyUserId, isFakeQyLogin,
isHandleWxCallbackComplete, wxUserDTO, ab98UserId, setOpenid, setBalance, setIsCabinetAdmin,
refreshBalance, setAb98User, waitForHandleWxCallbackComplete, wxMpCallback, setAb98UserId }
refreshBalance, setAb98User, waitForHandleWxCallbackComplete, wxMpCallback, setAb98UserId,
processUserInfo, getCorpid }
})
/**

View File

@ -194,3 +194,22 @@ export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
* /pages/index/index
*/
export const HOME_PAGE = `/${pages.find(page => page.type === 'home')?.path || pages[0].path}`
/**
* uni.login Promise 使使 await
* @param options - uni.login
* @returns Promise<UniApp.LoginRes>
*/
export function uniLogin(options?: UniApp.LoginOptions): Promise<UniApp.LoginRes> {
return new Promise((resolve, reject) => {
uni.login({
...options,
success: (res) => {
resolve(res)
},
fail: (err) => {
reject(err)
}
})
})
}