feat(wechat): 集成微信原生地址导入、头像选择与昵称填写能力

- 新增微信收货地址导入功能(chooseAddress),含地区匹配与错误处理
- 个人资料页接入微信头像选择(chooseAvatar)与昵称填写(type="nickname")
- 重构收货地址添加页与管理页的UI布局,抽取公共样式 address-page.scss
- 地址页与个人页增加加载失败提示、请求防抖与保存状态反馈
- 下拉刷新改用 Promise.allSettled 避免单接口失败阻断其他数据更新
- manifest 新增 chooseAddress 隐私权限声明,版本号更新至 5.03
This commit is contained in:
pikachu1995@126.com
2026-11-04 11:09:34 +08:00
parent 7b2b045fd4
commit d72f02f7cf
9 changed files with 507 additions and 247 deletions

View File

@@ -3,8 +3,8 @@
"name" : "lilishop",
"appid" : "__UNI__ED807EA",
"description" : "",
"versionName" : "4.5.3",
"versionCode" : 4000503,
"versionName" : "5.03",
"versionCode" : 503,
"transformPx" : false,
"app-plus" : {
"compatible" : {
@@ -212,7 +212,7 @@
"plugins" : {},
"libVersion" : "3.5.8",
"__usePrivacyCheck__" : false,
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ]
},
"h5" : {
"devServer" : {

2
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "lilishop-uniapp",
"version": "4.5.3",
"hasInstallScript": true,
"license": "ISC",
"license": "Apache-2.0",
"dependencies": {
"@tencentcloud/chat": "^3.5.5",
"hls.js": "^1.6.16",

View File

@@ -11,7 +11,7 @@
"type": "git",
"url": "https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp"
},
"license": "MIT",
"license": "Apache-2.0",
"dependencies": {
"@tencentcloud/chat": "^3.5.5",
"hls.js": "^1.6.16",

View File

@@ -1,5 +1,19 @@
<template>
<view :style="themeStyle" class="add-address">
<view :style="themeStyle" class="address-page add-address">
<!-- #ifdef MP-WEIXIN -->
<view class="wechat-import">
<button class="wechat-import-button" :disabled="importing" @click="importWechatAddress">
<view class="wechat-icon"><u-icon name="weixin-fill" color="#07b75b" size="26" /></view>
<view class="wechat-copy">
<text class="wechat-title">{{ importing ? '正在导入微信地址…' : '导入微信收货地址' }}</text>
</view>
<u-icon name="arrow-right" color="#77a38a" size="14" />
</button>
</view>
<!-- #endif -->
<view v-if="importNotice" class="import-notice">{{ importNotice }}</view>
<view class="section-heading">收货信息</view>
<view class="address-card form-card">
<up-form
:model="form"
ref="uForm"
@@ -7,30 +21,31 @@
:rules="rules"
label-position="left"
label-width="180rpx"
:label-style="{ color: '#5b606b', fontSize: '28rpx' }"
>
<up-form-item label="收货人" label-width="180rpx" prop="name" :border-bottom="true">
<up-form-item label="收货人" label-width="160rpx" prop="name" :border-bottom="true" :custom-style="fieldStyle">
<u-input
v-model="form.name"
border="none"
input-align="right"
input-align="left"
clearable
placeholder="请输入收货人姓名"
placeholder="姓名"
/>
</up-form-item>
<up-form-item label="手机号码" label-width="180rpx" prop="mobile" :border-bottom="true">
<up-form-item label="手机号码" label-width="160rpx" prop="mobile" :border-bottom="true" :custom-style="fieldStyle">
<u-input
v-model="form.mobile"
type="number"
maxlength="11"
border="none"
input-align="right"
placeholder="请输入收货人手机号码"
input-align="left"
placeholder="收货人的手机号码"
/>
</up-form-item>
<up-form-item label="所在区域" label-width="180rpx" prop="___path" :border-bottom="true">
<view class="form-value" @click="showPicker">
<up-form-item label="所在地区" label-width="160rpx" prop="___path" :border-bottom="true" :custom-style="fieldStyle" @click="showPicker">
<view class="form-value" :class="{ 'is-placeholder': !form.___path }">
{{ form.___path || '请选择所在地区' }}
</view>
<template #right>
@@ -38,40 +53,38 @@
</template>
</up-form-item>
<up-form-item class="detailAddress" label="详细地址" label-width="180rpx" prop="detail" :border-bottom="true">
<u-input
type="textarea"
<up-form-item label="详细地址" label-width="160rpx" prop="detail" :border-bottom="true" :custom-style="{ ...fieldStyle, alignItems: 'flex-start' }">
<textarea
class="address-textarea"
v-model="form.detail"
maxlength="100"
height="150"
border="none"
input-align="right"
placeholder="街道楼牌号等"
:auto-height="true"
placeholder="街道、门牌号等详细信息"
placeholder-style="color: #b1b5bd; font-size: 28rpx;"
/>
</up-form-item>
<up-form-item label="地址别名" label-width="180rpx" :border-bottom="false">
<up-form-item label="地址标签" label-width="160rpx" :border-bottom="false" :custom-style="fieldStyle">
<u-input
v-model="form.alias"
border="none"
input-align="right"
placeholder="请输入地址别名"
input-align="left"
placeholder="如:家、公司(选填)"
/>
</up-form-item>
<view class="default-row">
<u-checkbox
usedAlone
shape="circle"
size="30"
:active-color="lightColor"
v-model:checked="form.isDefault"
label="设为默认地址"
></u-checkbox>
</view>
<view class="saveBtn" @click="save">保存</view>
</up-form>
</view>
<view class="address-card default-row">
<view>
<text class="default-title">设为默认地址</text>
<text class="default-hint">下单时优先使用这个地址</text>
</view>
<switch :checked="!!form.isDefault" :color="mainColor" @change="form.isDefault = $event.detail.value" />
</view>
<view class="address-footer">
<button class="address-primary-button" :disabled="importing" @click="save">保存收货地址</button>
</view>
<m-city
:provinceData="list"
@@ -93,16 +106,114 @@ import MCity from '@/components/m-city/m-city.vue'
import uniMap from '@/components/uniMap'
import permision from '@/js_sdk/wa-permission/permission.js'
import { getThemeStyle } from '@/utils/theme'
import { getRegionsById } from '@/api/common.js'
import { resolveWechatRegion, getWechatAddressErrorType } from '@/utils/wechatAddress.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor)
const mainColor = computed(() => store.getters.mainColor)
const fieldStyle = { padding: '28rpx 0', minHeight: '100rpx', boxSizing: 'border-box', alignItems: 'center' }
const mapFlag = ref(false)
const routerVal = ref<Record<string, string>>({})
const uForm = ref<any>(null)
const cityPicker = ref<any>(null)
const importing = ref(false)
const importNotice = ref('')
// #ifdef MP-WEIXIN
function importWechatAddress() {
if (importing.value) return
importing.value = true
uni.chooseAddress({
success: async (address: any) => {
uni.showLoading({ title: '正在匹配地区', mask: true })
// Clear the old region and coordinates, including when editing an existing address.
Object.assign(form, {
name: address.userName || '',
mobile: address.telNumber || '',
detail: address.detailInfoNew || address.detailInfo || '',
consigneeAddressIdPath: [],
consigneeAddressPath: [],
___path: '',
lat: '',
lon: '',
})
importNotice.value = ''
try {
const regions = await resolveWechatRegion(address, getRegionsById)
if (regions) {
if (regions.length < 4 && address.streetName) {
form.detail = address.detailInfo || [address.streetName, address.detailInfoNew].filter(Boolean).join('')
}
const names = regions.map((item: any) => item.name || item.localName)
form.consigneeAddressIdPath = regions.map((item: any) => item.id)
form.consigneeAddressPath = names
form.___path = names.join('')
const center = String(regions[regions.length - 1].center || '').split(',')
if (center.length === 2) [form.lon, form.lat] = center
importNotice.value = '已导入微信地址,请核对后保存'
} else {
showImportedRegionHint(address)
}
} catch {
showImportedRegionHint(address)
} finally {
importing.value = false
uni.hideLoading()
uForm.value?.clearValidate()
}
},
fail: (error: any) => {
importing.value = false
const type = getWechatAddressErrorType(error)
if (type === 'cancel') return
let platform = ''
try { platform = (globalThis as any).wx?.getDeviceInfo?.()?.platform || '' } catch {}
// Log diagnostics only, never the selected address or other personal information.
console.warn('[微信地址导入失败]', {
errMsg: String(error?.errMsg || error?.message || ''),
errCode: error?.errCode ?? error?.errno ?? '',
type,
platform,
})
if (type === 'auth') {
uni.showModal({
title: '开启收货地址授权',
content: '需要允许使用微信收货地址才能导入,也可以继续手动填写。',
confirmText: '去设置',
success: (result) => { if (result.confirm) uni.openSetting({}) },
})
return
}
if (type === 'privacy') {
console.warn('[微信地址导入] 请核对小程序后台的用户隐私保护指引是否声明收货地址,以及用户隐私授权状态。')
}
if (type === 'configuration') {
console.warn('[微信地址导入] manifest.json 的 mp-weixin.requiredPrivateInfos 必须包含 chooseAddress,修改后需重新编译小程序。')
}
const content = type === 'configuration'
? '收货地址导入暂未配置完成,请稍后再试,也可以手动填写。'
: type === 'privacy'
? '暂时无法获取微信收货地址,请稍后重试,也可以手动填写。'
: platform === 'devtools'
? '当前开发工具中未能打开微信地址簿,请使用手机微信扫码预览测试,也可以手动填写。'
: type === 'unsupported'
? '当前微信环境不支持导入收货地址,请更新微信后重试,也可以手动填写。'
: '暂时无法打开微信地址簿,请稍后重试,也可以手动填写。'
uni.showModal({ title: '微信地址未导入', content, showCancel: false })
},
})
}
function showImportedRegionHint(address: any) {
const region = [address.provinceName, address.cityName, address.countyName, address.streetName].filter(Boolean).join('')
// Preserve the street if the user has to select it manually and the new detail field omits it.
form.detail = address.detailInfo || [address.streetName, address.detailInfoNew].filter(Boolean).join('')
importNotice.value = `已导入姓名、电话和详细地址,请补选所在地区${region ? '(微信地址:' + region + ')' : ''}`
}
// #endif
const form = reactive<Record<string, any>>({
detail: '',
@@ -160,18 +271,24 @@ const rules = {
onShow(() => {})
onLoad((option) => {
uni.showLoading({ title: '加载中' })
onLoad(async (option) => {
routerVal.value = option || {}
if (option.id) {
getAddressDetail(option.id).then((res) => {
const params = res.data.result
params.___path = params.consigneeAddressPath
Object.assign(form, params)
if (store.state.isShowToast) uni.hideLoading()
})
uni.setNavigationBarTitle({ title: option?.id ? '编辑收货地址' : '新增收货地址' })
if (!option?.id) return
uni.showLoading({ title: '加载中' })
try {
const res = await getAddressDetail(option.id)
const params = res.data?.result
if (!res.data?.success || !params) {
uni.showToast({ title: '地址加载失败,请重试', icon: 'none' })
return
}
Object.assign(form, params, { ___path: params.consigneeAddressPath || '' })
} catch {
uni.showToast({ title: '地址加载失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
uni.hideLoading()
})
onReady(() => {
@@ -233,6 +350,7 @@ function callBackAddress(val: any) {
}
function save() {
if (importing.value) return
uForm.value?.validate().then(() => {
const params = { ...form }
delete params.___path
@@ -278,11 +396,12 @@ function getpickerParentValue(e: any[]) {
form.___path = name
}
if (index == e.length - 1) {
const _town = item.children.filter((_child: any) => _child.id == item.id)
form.lat = _town[0].center.split(',')[1]
form.lon = _town[0].center.split(',')[0]
const center = String(item.center || item.children?.find((_child: any) => _child.id == item.id)?.center || '').split(',')
form.lat = center.length === 2 ? center[1] : ''
form.lon = center.length === 2 ? center[0] : ''
}
})
importNotice.value = ''
}
function showPicker() {
@@ -290,99 +409,25 @@ function showPicker() {
}
</script>
<style scoped lang="scss">
page {
background: #f5f5f5;
}
.add-address {
min-height: 100vh;
background: #fff;
padding-bottom: 40rpx;
}
:deep(.u-form-item) {
padding: 0 30rpx;
min-height: 100rpx;
}
:deep(.u-form-item__body) {
min-height: 100rpx;
align-items: center;
}
:deep(.u-form-item__body__left__content__label) {
font-size: 30rpx;
color: #333;
}
:deep(.u-form-item__body__right__content__slot) {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
:deep(.u-input) {
padding: 0 !important;
}
:deep(.u-input__content__field-wrapper__field) {
font-size: 28rpx !important;
color: #666 !important;
}
.form-value {
width: 100%;
text-align: right;
font-size: 28rpx;
color: #666;
}
.detailAddress {
:deep(.u-form-item__body) {
align-items: flex-start;
padding-top: 24rpx;
padding-bottom: 24rpx;
min-height: auto;
}
:deep(.u-form-item__body__left) {
padding-top: 8rpx;
}
:deep(.u-form-item__body__right__content__slot) {
justify-content: flex-end;
align-items: flex-start;
}
:deep(.u-textarea__field),
:deep(.u-input__content__field-wrapper__field) {
text-align: right !important;
}
:deep(.uni-textarea-placeholder),
:deep(.input-placeholder) {
text-align: right;
}
}
.default-row {
padding: 24rpx 30rpx;
}
:deep(.default-row .u-checkbox__label) {
font-size: 28rpx;
color: $font-color-light;
}
.saveBtn {
margin: 40rpx 30rpx 0;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 30rpx;
background: $light-color;
color: #fff;
border-radius: 40rpx;
@import "./address-page.scss";
.wechat-import { margin-bottom: 28rpx; }
.wechat-import-button {
display: flex; align-items: center; padding: 28rpx; margin: 0; width: 100%; text-align: left;
background: #eef9f2; border-radius: 24rpx; line-height: 1.5;
&::after { border: 1rpx solid #dcefe3; border-radius: 24rpx; }
&[disabled] { background: #eef9f2; opacity: .65; }
}
.wechat-icon { width: 76rpx; height: 76rpx; border-radius: 20rpx; background: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 22rpx; }
.wechat-copy { flex: 1; min-width: 0; }
.wechat-title { display: block; color: #253e30; font-size: 30rpx; font-weight: 600; }
.import-notice { margin-bottom: 24rpx; padding: 20rpx 24rpx; border-radius: 16rpx; background: #fff5e5; color: #a47a3b; font-size: 24rpx; line-height: 1.6; }
.section-heading { padding: 0 8rpx 20rpx; font-size: 26rpx; color: #858b96; }
.form-card { padding: 0 28rpx; }
.form-value { width: 100%; line-height: 1.6; font-size: 28rpx; color: #303744; overflow-wrap: anywhere; }
.is-placeholder { color: #b1b5bd; }
.address-textarea { width: 100%; min-height: 88rpx; font-size: 28rpx; color: #303744; line-height: 1.6; padding: 0; }
.default-row { display: flex; justify-content: space-between; align-items: center; margin-top: 24rpx; padding: 28rpx; }
.default-title { display: block; font-size: 28rpx; color: #353b47; }
.default-hint { display: block; font-size: 23rpx; color: #a0a5af; margin-top: 8rpx; }
.default-row switch { transform: scale(.8); transform-origin: right center; }
</style>

View File

@@ -0,0 +1,43 @@
.address-page {
min-height: 100vh;
box-sizing: border-box;
background: #f6f7f9;
padding: 28rpx 24rpx calc(180rpx + env(safe-area-inset-bottom));
}
.address-card {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
}
.address-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
box-sizing: border-box;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
border-top: 1rpx solid #f0f1f4;
}
.address-primary-button {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
height: 88rpx;
margin: 0;
padding: 0 24rpx;
border-radius: 44rpx;
background: var(--theme-primary);
color: #fff;
font-size: 30rpx;
font-weight: 500;
line-height: 1;
&::after { border: none; }
&[disabled] { opacity: .5; background: var(--theme-primary); color: #fff; }
}

View File

@@ -1,55 +1,64 @@
<template>
<view class="address">
<u-empty class="empty" v-if="addressList.length === 0" text="暂无收货地址" mode="address"></u-empty>
<view class="list" >
<view class="item c-content" v-for="(item, index) in addressList" :key="index">
<view class="basic">
<text>{{ item.name }}</text>
<text>{{ item.mobile }}</text>
<text class="default" v-show="item.isDefault">默认</text>
<view>
<div class="region">
<span v-if="item.consigneeAddressPath[0]">{{item.consigneeAddressPath[0]}}</span>
<span v-if="item.consigneeAddressPath[1]">{{item.consigneeAddressPath[1]}}</span>
<span v-if="item.consigneeAddressPath[2]">{{item.consigneeAddressPath[2]}}</span>
<span v-if="item.consigneeAddressPath[3]">{{item.consigneeAddressPath[3]}}</span>
<span>{{ item.detail }}</span>
</div>
</view>
<view class="address-page address-manage" :style="themeStyle">
<view v-if="!loading && addressList.length === 0" class="empty-state">
<view class="empty-icon"><u-icon name="map" size="42" color="#c4c8d1" /></view>
<text class="empty-title">还没有收货地址</text>
<text class="empty-description">添加常用地址,下次购物更方便</text>
</view>
<view v-if="addressList.length" class="list-heading">
<text>我的收货地址</text>
<text class="address-count">共 {{ addressList.length }} 个</text>
</view>
<view class="address-list">
<view class="address-card address-item" v-for="item in addressList" :key="item.id">
<view class="recipient-line">
<text class="recipient-name">{{ item.name }}</text>
<text class="recipient-mobile">{{ item.mobile }}</text>
</view>
<view class="edit">
<view class="relative" @click="setDefault(item)">
<view v-if="item.isDefault" class="alifont icon-xuanzhong"></view>
<text v-else class="unchecked"></text>
<text>{{ item.isDefault ? "默认地址" : "设为默认" }}</text>
<view class="address-region">{{ item.consigneeAddressPath.join(' ') }}</view>
<text class="address-detail">{{ item.detail }}</text>
<view class="address-tags" v-if="item.isDefault || item.alias">
<text v-if="item.isDefault" class="address-tag default-tag">默认地址</text>
<text v-if="item.alias" class="address-tag">{{ item.alias }}</text>
</view>
<view class="card-actions">
<view class="default-action" @click="setDefault(item)">
<view class="default-check" :class="{ 'is-checked': item.isDefault }">
<u-icon v-if="item.isDefault" name="checkmark" size="11" color="#fff" />
</view>
<text>{{ item.isDefault ? '已设为默认' : '设为默认' }}</text>
</view>
<view class="relative">
<view class="alifont icon-bianji-copy"></view>
<text class="mr-40" @click="addAddress(item.id)">编辑</text>
<view class="alifont icon-lajitong" @click.stop="removeAddress(item.id)"></view>
<text @click.stop="removeAddress(item.id)">删除</text>
<view class="edit-actions">
<view class="text-action" @click="addAddress(item.id)">
<u-icon name="edit-pen" size="16" color="#6c7280" /><text>编辑</text>
</view>
<view class="action-divider"></view>
<view class="text-action" @click="removeAddress(item.id)">
<u-icon name="trash" size="16" color="#9297a1" /><text>删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="btn-bar">
<view class="btn" @click="addAddress('')">
<u-icon name="plus-circle" color="#ffffff" size="18"></u-icon>
<text class="btn-text">添加新收货人</text>
</view>
<view class="address-footer">
<button class="address-primary-button" @click="addAddress('')">
<u-icon name="plus" color="#fff" size="17" /><text>新增收货地址</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { onLoad, onShow, onPullDownRefresh, onBackPress } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { tipsToLogin } from '@/utils/filters.js'
import * as API_Address from '@/api/address.js'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loading = ref(true)
const addressList = ref<any[]>([])
const routerVal = ref<Record<string, string>>({})
@@ -65,7 +74,6 @@ onLoad((val) => {
})
onPullDownRefresh(() => {
addressList.value = []
getAddressList()
})
@@ -75,19 +83,23 @@ onShow(() => {
}
})
function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading()
}
function getAddressList() {
uni.showLoading()
API_Address.getAddressList(params.pageNumber, params.pageSize).then((res) => {
res.data.result.records.forEach((item: any) => {
item.consigneeAddressPath = item.consigneeAddressPath.split(',')
})
addressList.value = res.data.result.records
hideLoadingIfNeeded()
})
async function getAddressList() {
loading.value = true
try {
const res = await API_Address.getAddressList(params.pageNumber, params.pageSize)
if (!res.data?.success || !Array.isArray(res.data.result?.records)) throw new Error('地址加载失败')
addressList.value = res.data.result.records.map((item: any) => ({
...item,
consigneeAddressPath: Array.isArray(item.consigneeAddressPath)
? item.consigneeAddressPath
: String(item.consigneeAddressPath || '').split(',').filter(Boolean),
}))
} catch {
uni.showToast({ title: '地址加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function removeAddress(id: string) {
@@ -97,7 +109,7 @@ function removeAddress(id: string) {
success: (res) => {
if (!res.confirm) return
API_Address.deleteAddress(id).then((result) => {
if (result.statusCode == 200) {
if (result.data?.success) {
uni.showToast({ icon: 'none', title: '删除成功' })
getAddressList()
} else {
@@ -115,17 +127,45 @@ function addAddress(id?: string) {
}
function setDefault(item: any) {
delete item.updateBy
delete item.updateTime
delete item.deleteFlag
if (!item.isDefault) item.isDefault = true
API_Address.editAddress(item).then(() => {
uni.showToast({ title: '设置默认地址成功', icon: 'none' })
getAddressList()
})
if (item.isDefault) return
const data = { ...item, isDefault: true, consigneeAddressPath: item.consigneeAddressPath.join(',') }
delete data.updateBy
delete data.updateTime
delete data.deleteFlag
API_Address.editAddress(data).then((res) => {
if (res.data?.success) {
uni.showToast({ title: '已设为默认地址', icon: 'none' })
getAddressList()
} else {
uni.showToast({ title: res.data?.message || '设置失败,请重试', icon: 'none' })
}
}).catch(() => uni.showToast({ title: '设置失败,请重试', icon: 'none' }))
}
</script>
<style lang="scss" scoped>
@import "./address.scss";
@import "./address-page.scss";
.list-heading { display: flex; justify-content: space-between; padding: 8rpx 8rpx 24rpx; font-size: 26rpx; color: #6c7280; }
.address-count { color: #a0a5af; font-size: 24rpx; }
.address-item { padding: 32rpx 32rpx 0; margin-bottom: 24rpx; }
.recipient-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 20rpx; margin-bottom: 18rpx; }
.recipient-name { font-size: 34rpx; font-weight: 600; color: #222732; overflow-wrap: anywhere; }
.recipient-mobile { color: #636a77; font-size: 28rpx; }
.address-region { color: #9399a4; font-size: 25rpx; line-height: 1.7; }
.address-detail { display: block; color: #343b48; font-size: 29rpx; line-height: 1.7; overflow-wrap: anywhere; }
.address-tags { display: flex; flex-wrap: wrap; gap: 12rpx; padding-top: 20rpx; }
.address-tag { max-width: 100%; overflow-wrap: anywhere; padding: 4rpx 14rpx; background: #f3f4f6; border-radius: 8rpx; color: #7f8591; font-size: 22rpx; }
.default-tag { color: var(--theme-primary); background: var(--theme-primary-10); }
.card-actions { margin-top: 26rpx; border-top: 1rpx solid #f0f1f4; min-height: 92rpx; display: flex; justify-content: space-between; align-items: center; }
.default-action, .text-action, .edit-actions { display: flex; align-items: center; }
.default-action { gap: 12rpx; min-height: 80rpx; color: #858b96; font-size: 24rpx; }
.default-check { width: 30rpx; height: 30rpx; box-sizing: border-box; border: 2rpx solid #dce0e6; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.is-checked { background: var(--theme-primary); border-color: var(--theme-primary); }
.text-action { gap: 8rpx; min-height: 80rpx; color: #6c7280; font-size: 24rpx; padding: 0 8rpx; }
.action-divider { width: 1rpx; height: 22rpx; background: #e9ebef; margin: 0 16rpx; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 160rpx 0 80rpx; }
.empty-icon { width: 148rpx; height: 148rpx; display: flex; align-items: center; justify-content: center; background: #eaecef; border-radius: 50%; margin-bottom: 36rpx; }
.empty-title { color: #454c59; font-size: 32rpx; font-weight: 500; }
.empty-description { color: #a0a5af; font-size: 26rpx; margin-top: 16rpx; }
</style>

View File

@@ -1,9 +1,17 @@
<template>
<view :style="themeStyle" class="person-msg">
<!-- #ifdef MP-WEIXIN -->
<button class="head avatar-button" open-type="chooseAvatar" :disabled="avatarUploading || saving" @chooseavatar="onChooseAvatar">
<image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image>
<view>{{ avatarUploading ? '头像上传中…' : '点击选择微信头像' }}</view>
</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="head" @click="changeFace">
<image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image>
<view>点击修改头像</view>
<view>{{ avatarUploading ? '头像上传中…' : '点击修改头像' }}</view>
</view>
<!-- #endif -->
<up-form
:model="form"
@@ -13,12 +21,26 @@
label-width="180rpx"
>
<up-form-item label="昵称" label-width="180rpx" :border-bottom="true">
<!-- #ifdef MP-WEIXIN -->
<input
class="nickname-input"
type="nickname"
:value="form.nickName"
:disabled="saving"
placeholder="点击填写微信昵称"
@input="onNicknameChange"
@blur="onNicknameChange"
@nicknamereview="onNicknameReview"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<u-input
v-model="form.nickName"
border="none"
input-align="right"
placeholder="请输入昵称"
/>
<!-- #endif -->
</up-form-item>
<up-form-item label="性别" label-width="180rpx" :border-bottom="true">
@@ -55,7 +77,9 @@
</up-form>
<view class="bottom">
<view class="submit" @click="submit">保存</view>
<button class="submit" :disabled="avatarUploading || saving" :loading="saving" @click="submit">
{{ avatarUploading ? '头像上传中…' : saving ? '正在保存…' : '保存' }}
</button>
</view>
<u-datetime-picker
@@ -80,7 +104,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { saveUserInfo } from '@/api/members.js'
@@ -126,6 +150,24 @@ const maxBirthdayDate = Date.now()
const region = ref([{ id: '', localName: '请选择', children: [] }])
const showBirthday = ref(false)
const cityPicker = ref<any>(null)
const avatarUploading = ref(false)
const saving = ref(false)
function onNicknameChange(e: any) {
if (typeof e?.detail?.value === 'string') form.nickName = e.detail.value
}
function onNicknameReview(e: any) {
if (e?.detail?.pass === false) {
form.nickName = ''
uni.showToast({ title: '昵称未通过审核,请重新填写', icon: 'none' })
}
}
function onChooseAvatar(e: any) {
const filePath = e?.detail?.avatarUrl
if (typeof filePath === 'string' && filePath) uploadAvatar(filePath)
}
function getPickerParentValue(selected: any[]) {
form.region = []
@@ -145,34 +187,69 @@ function clickRegion() {
cityPicker.value?.show()
}
function submit() {
async function submit() {
if (avatarUploading.value || saving.value) return
// Nickname autofill can update the value in the blur event before the save tap.
await nextTick()
if (avatarUploading.value || saving.value) return
const params = JSON.parse(JSON.stringify(form))
params.nickName = String(params.nickName || '').trim()
if (!params.nickName) {
uni.showToast({ title: '请填写昵称', icon: 'none' })
return
}
delete params.regionPath
saveUserInfo(params).then((res) => {
if (res.statusCode === 200) {
saving.value = true
try {
const res = await saveUserInfo(params)
if (res.data?.success && res.data.result) {
storage.setUserInfo(res.data.result)
uni.navigateBack()
} else {
uni.showToast({ title: res.data?.message || '保存失败,请重试', icon: 'none' })
}
})
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
}
function changeFace() {
if (avatarUploading.value || saving.value) return
uni.chooseImage({
count: 1,
success: (chooseImageRes) => {
uni.uploadFile({
url: upload,
filePath: chooseImageRes.tempFilePaths[0],
name: 'file',
header: { accessToken: storage.getAccessToken() },
success: (uploadFileRes) => {
const data = JSON.parse(uploadFileRes.data)
form.face = data.result
},
})
const filePath = chooseImageRes.tempFilePaths?.[0]
if (filePath) uploadAvatar(filePath)
},
})
}
function uploadAvatar(filePath: string) {
if (avatarUploading.value || saving.value) return
avatarUploading.value = true
uni.uploadFile({
url: upload,
filePath,
name: 'file',
header: { accessToken: storage.getAccessToken() },
success: (res) => {
try {
const data = JSON.parse(res.data)
if (res.statusCode !== 200 || !data.success || typeof data.result !== 'string' || !data.result) {
throw new Error('上传失败')
}
form.face = data.result
} catch {
uni.showToast({ title: '头像上传失败,请重新选择', icon: 'none' })
}
},
fail: () => uni.showToast({ title: '头像上传失败,请重新选择', icon: 'none' }),
complete: () => { avatarUploading.value = false },
})
}
function selectTime(e: any) {
const timestamp = typeof e?.value === 'number' ? e.value : birthdayValue.value
const formatted = formatBirthday(timestamp)
@@ -192,22 +269,11 @@ function navigateToBindMobile(username: string) {
})
}
function syncUserInfo() {
onShow(() => {
const latest = storage.getUserInfo() || {}
form.nickName = latest.nickName || ''
form.birthday = latest.birthday || ''
form.face = latest.face || '/static/missing-face.png'
form.region = latest.region || []
form.sex = latest.sex != null ? String(latest.sex) : '1'
form.regionPath = latest.region
// Refresh a newly bound phone without overwriting unsaved avatar/nickname edits.
form.mobile = latest.mobile
form.username = latest.username
birthday.value = latest.birthday || ''
birthdayValue.value = parseBirthdayTimestamp(latest.birthday)
}
onShow(() => {
syncUserInfo()
})
</script>
@@ -248,6 +314,23 @@ page {
}
}
.avatar-button {
width: 100%;
border-radius: 0;
line-height: normal;
&::after { border: none; }
&[disabled] { background: #fff; color: #999; }
}
.nickname-input {
width: 100%;
min-width: 0;
height: 64rpx;
text-align: right;
font-size: 28rpx;
color: #333;
}
.form {
background: #fff;
}
@@ -341,5 +424,9 @@ page {
font-size: 30rpx;
background: $light-color;
color: #fff;
margin: 0;
padding: 0;
&::after { border: none; }
&[disabled] { opacity: .5; background: $light-color; color: #fff; }
}
</style>

View File

@@ -182,8 +182,15 @@ onShow(() => {
})
onPullDownRefresh(() => {
getUserOrderNum()
userInfo.value = isLogin()
userInfo.value = isLogin() || {}
if (isLogin('auth')) {
getUserOrderNum()
} else {
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
uni.stopPullDownRefresh()
}
})
// #ifndef MP
@@ -205,17 +212,24 @@ function userDetail() {
}
async function getUserOrderNum() {
uni.stopPullDownRefresh()
Promise.all([
getCouponsNum(),
getFootprintNum(),
getUserWallet(),
]).then((res: any[]) => {
couponNum.value = res[0].data.result
footNum.value = res[1].data.result
walletNum.value = res[2].data.result.memberWallet
})
try {
// Each request can fail independently; a missing wallet must not break the other counts.
const results = await Promise.allSettled([
getCouponsNum(),
getFootprintNum(),
getUserWallet(),
])
// The user may have logged out while these requests were running.
if (!isLogin('auth')) return
const targets = [couponNum, footNum, walletNum]
results.forEach((result, index) => {
if (result.status !== 'fulfilled' || !result.value?.data?.success) return
const data = result.value.data.result
targets[index].value = index === 2 ? (data?.memberWallet ?? 0) : (data ?? 0)
})
} finally {
uni.stopPullDownRefresh()
}
}
</script>

31
utils/wechatAddress.js Normal file
View File

@@ -0,0 +1,31 @@
export function getWechatAddressErrorType(error) {
const message = String(error?.errMsg || error?.message || '')
if (/cancel/i.test(message)) return 'cancel'
if (/requiredPrivateInfos/i.test(message)) return 'configuration'
if (/privacy|隐私/i.test(message)) return 'privacy'
if (/auth deny|auth denied|authorize no response|auth reject/i.test(message)) return 'auth'
if (/not support|not supported|not available|not a function/i.test(message)) return 'unsupported'
return 'unknown'
}
// Match each name under its parent; never guess an ID from a similarly named region.
export async function resolveWechatRegion(address, fetchRegions) {
const names = [address.provinceName, address.cityName, address.countyName, address.streetName]
const selected = []
let parentId = 0
for (let level = 0; level < 4; level++) {
const response = await fetchRegions(parentId)
if (!response?.data?.success || !Array.isArray(response.data.result)) {
throw new Error('地区加载失败')
}
const children = response.data.result
if (!children.length) return selected.length >= 3 ? selected : null
const name = String(names[level] || '').trim()
if (!name) return null
const matches = children.filter(item => String(item.name || item.localName || '').trim() === name)
if (matches.length !== 1 || !matches[0].id) return null
selected.push(matches[0])
parentId = matches[0].id
}
return selected
}