13 changed files with 1180 additions and 163 deletions
@ -0,0 +1,278 @@ |
|||
<template> |
|||
<view class="drawer-container"> |
|||
<!-- 蒙层 --> |
|||
<!-- <view |
|||
class="mask" |
|||
:class="{ show: visibleInner && isFullScreen }" |
|||
:style="{ top: isFullScreen ? '0' : halfY + 'px' }" |
|||
@click="close" |
|||
/> --> |
|||
|
|||
<!-- 抽屉 --> |
|||
<view |
|||
class="drawer" |
|||
:class="{ |
|||
dragging: isDragging |
|||
}" |
|||
:style="{ transform: `translateY(${translateY}px)` }" |
|||
@touchstart="onStart" |
|||
@touchmove.stop.prevent="onMove" |
|||
@touchend="onEnd" |
|||
> |
|||
<!-- 顶部拖拽条 --> |
|||
<view class="header" @touchstart.stop="onStart"> |
|||
<view class="bar"></view> |
|||
</view> |
|||
|
|||
<!-- 内容 --> |
|||
<view class="content"> |
|||
<slot></slot> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: "PopupDrawer", |
|||
props: { |
|||
visible: { |
|||
type: Boolean, |
|||
default: false |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
visibleInner: false, |
|||
|
|||
// 拖动状态 |
|||
isDragging: false, |
|||
startY: 0, |
|||
currentY: 0, |
|||
|
|||
// 位置 |
|||
windowHeight: 0, |
|||
halfY: 0, |
|||
fullY: 0, |
|||
translateY: 0, |
|||
|
|||
startTranslateY: 0, |
|||
|
|||
// 显示状态 |
|||
isFullScreen: false |
|||
}; |
|||
}, |
|||
watch: { |
|||
visible: { |
|||
immediate: true, |
|||
handler(v) { |
|||
if (v) { |
|||
this.open(); |
|||
} else { |
|||
this.close(); |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
created() { |
|||
const res = uni.getSystemInfoSync(); |
|||
this.windowHeight = res.windowHeight; |
|||
|
|||
this.halfY = this.windowHeight * 0.5; |
|||
this.fullY = 0; |
|||
|
|||
this.translateY = this.halfY; |
|||
}, |
|||
methods: { |
|||
/** 打开 */ |
|||
open() { |
|||
this.visibleInner = true; |
|||
this.isFullScreen = false; // 默认打开为半屏 |
|||
this.$nextTick(() => { |
|||
this.animateTo(this.halfY); |
|||
}); |
|||
}, |
|||
|
|||
/** 关闭 */ |
|||
close() { |
|||
this.isFullScreen = false; |
|||
this.animateTo(this.windowHeight, () => { |
|||
this.visibleInner = false; |
|||
this.$emit("update:visible", false); |
|||
this.$emit("close"); |
|||
}); |
|||
}, |
|||
|
|||
/** 手势开始 */ |
|||
onStart(e) { |
|||
// 阻止默认行为和事件冒泡 |
|||
e.preventDefault(); |
|||
e.stopPropagation(); |
|||
|
|||
this.isDragging = true; |
|||
this.startY = e.touches[0].clientY; |
|||
this.startTranslateY = this.translateY; |
|||
}, |
|||
|
|||
/** 手势移动 */ |
|||
onMove(e) { |
|||
// 阻止默认行为和事件冒泡 |
|||
e.preventDefault(); |
|||
e.stopPropagation(); |
|||
|
|||
if (!this.isDragging) return; |
|||
|
|||
const moveY = e.touches[0].clientY - this.startY; |
|||
let newY = this.startTranslateY + moveY; |
|||
|
|||
// 限制范围 |
|||
if (newY < 0) newY = 0; |
|||
if (newY > this.windowHeight) newY = this.windowHeight; |
|||
|
|||
this.translateY = newY; |
|||
}, |
|||
|
|||
/** 手势结束(吸附逻辑) */ |
|||
onEnd(e) { |
|||
e.preventDefault(); |
|||
e.stopPropagation(); |
|||
|
|||
this.isDragging = false; |
|||
|
|||
const moveY = e.changedTouches[0].clientY - this.startY; |
|||
|
|||
// --------------------------- |
|||
// 1. 在全屏状态 |
|||
// --------------------------- |
|||
if (this.translateY === this.fullY) { // translateY = 0 |
|||
// 往下拉超过 80 → 吸附到半屏,而不是关闭 |
|||
if (moveY > 80) { |
|||
this.isFullScreen = false; |
|||
return this.animateTo(this.halfY); |
|||
} |
|||
|
|||
// 往上拉(基本不会)→ 保持全屏 |
|||
if (moveY < -50) { |
|||
this.isFullScreen = true; |
|||
return this.animateTo(this.fullY); |
|||
} |
|||
} |
|||
|
|||
// --------------------------- |
|||
// 2. 在半屏状态 |
|||
// --------------------------- |
|||
if (this.translateY >= this.halfY - 10 && this.translateY <= this.halfY + 10) { |
|||
// 上拉 → 全屏 |
|||
if (moveY < -80) { |
|||
this.isFullScreen = true; |
|||
return this.animateTo(this.fullY); |
|||
} |
|||
// 下拉轻微 → 保持半屏 |
|||
if (moveY > 0 && moveY < 120) { |
|||
this.isFullScreen = false; |
|||
return this.animateTo(this.halfY); |
|||
} |
|||
} |
|||
|
|||
// --------------------------- |
|||
// 3. 如果下拉超过阈值(且不是全屏)→ 关闭 |
|||
// --------------------------- |
|||
if (moveY > 120) { |
|||
this.isFullScreen = false; |
|||
return this.close(); |
|||
} |
|||
|
|||
// --------------------------- |
|||
// 4. 自动吸附最近位置 |
|||
// --------------------------- |
|||
const points = [this.fullY, this.halfY]; |
|||
let nearest = points.reduce((prev, curr) => |
|||
Math.abs(curr - this.translateY) < Math.abs(prev - this.translateY) ? curr : prev |
|||
); |
|||
|
|||
this.isFullScreen = (nearest === this.fullY); |
|||
this.animateTo(nearest); |
|||
}, |
|||
/** 动画过渡(使用 RAF,不卡顿) */ |
|||
animateTo(targetY, callback) { |
|||
// 小程序动画对象 |
|||
const animation = uni.createAnimation({ |
|||
duration: 250, |
|||
timingFunction: "cubic-bezier(0.25, 0.1, 0.25, 1)" |
|||
}); |
|||
|
|||
animation.translateY(targetY).step(); |
|||
|
|||
this.animationData = animation.export(); |
|||
this.translateY = targetY; |
|||
|
|||
if (callback) { |
|||
setTimeout(callback, 260); |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.drawer-container { |
|||
position: fixed; |
|||
left: 0; |
|||
top: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
pointer-events: none; /* 默认不阻挡交互 */ |
|||
z-index: 9999; |
|||
|
|||
.mask { |
|||
position: absolute; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.45); |
|||
opacity: 0; |
|||
transition: opacity 0.25s; |
|||
pointer-events: auto; /* 蒙层接收交互 */ |
|||
|
|||
&.show { |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
.drawer { |
|||
position: absolute; |
|||
left: 0; |
|||
top: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: #fff; |
|||
border-radius: 24rpx 24rpx 0 0; |
|||
transition: transform 0.25s ease-out; |
|||
pointer-events: auto; /* 抽屉部分接收交互 */ |
|||
|
|||
&.dragging { |
|||
transition: none !important; |
|||
} |
|||
|
|||
.header { |
|||
width: 100%; |
|||
height: 80rpx; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
|
|||
.bar { |
|||
width: 120rpx; |
|||
height: 12rpx; |
|||
border-radius: 6rpx; |
|||
background: #ccc; |
|||
} |
|||
} |
|||
|
|||
.content { |
|||
height: calc(100% - 80rpx); |
|||
overflow-y: auto; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,215 @@ |
|||
<template> |
|||
<view class="edit-profile-page"> |
|||
<!-- 头像编辑区域 --> |
|||
<view class="profile-section"> |
|||
<view class="profile-item" @click="changeAvatar"> |
|||
<text class="item-label">头像</text> |
|||
<view class="item-value avatar-container"> |
|||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar"> |
|||
<image class="avatar" :src="userInfo.avatarUrl || '/static/default-avatar.png'" mode="aspectFill"></image> |
|||
</button> |
|||
<!-- <text class="arrow">></text> --> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="profile-item" @click="editNickname"> |
|||
<text class="item-label">昵称</text> |
|||
<view class="item-value"> |
|||
<text class="nickname">{{ userInfo.nickname || '未设置' }}</text> |
|||
<!-- <text class="arrow">></text> --> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- <view class="profile-item"> |
|||
<text class="item-label">性别</text> |
|||
<view class="item-value"> |
|||
<text>{{ userInfo.gender === 1 ? '男' : userInfo.gender === 2 ? '女' : '未设置' }}</text> |
|||
</view> |
|||
</view> --> |
|||
</view> |
|||
|
|||
<!-- 保存按钮 --> |
|||
<!-- <view class="save-section"> |
|||
<button class="save-btn" @click="saveProfile">保存</button> |
|||
</view> --> |
|||
</view> |
|||
</template> |
|||
|
|||
<script setup> |
|||
import { ref, computed } from 'vue'; |
|||
import { useUserStore } from '../../stores/user.js'; |
|||
import { appUserEditAvatar, appserEditUser } from '../../api/index.js'; |
|||
|
|||
const userStore = useUserStore(); |
|||
const userInfo = computed(() => userStore.userInfo || {}); |
|||
|
|||
// 更换头像 |
|||
const onChooseAvatar = async (e) => { |
|||
const { avatarUrl } = e.detail |
|||
console.log(avatarUrl) |
|||
try { |
|||
await appUserEditAvatar(avatarUrl) |
|||
// 更新用户 store 中的头像信息 |
|||
if (avatarUrl) { |
|||
userStore.updateUserInfoField('avatarUrl', avatarUrl); |
|||
} |
|||
} catch(err) { |
|||
console.log(err) |
|||
} |
|||
|
|||
} |
|||
|
|||
// 更换头像(通过按钮点击触发) |
|||
const changeAvatar = () => { |
|||
// 这里可以添加其他更换头像的逻辑 |
|||
console.log('点击更换头像'); |
|||
}; |
|||
|
|||
// 编辑昵称 |
|||
const editNickname = () => { |
|||
uni.showModal({ |
|||
title: '编辑昵称', |
|||
editable: true, |
|||
placeholderText: '请输入昵称', |
|||
success: async (res) => { |
|||
if (res.confirm) { |
|||
const nickname = res.content; |
|||
if (nickname) { |
|||
/* userStore.updateUserInfoField('nickName', nickname); |
|||
uni.showToast({ |
|||
title: '昵称已更新', |
|||
icon: 'success' |
|||
}); */ |
|||
try { |
|||
await appserEditUser({ nickname }) |
|||
userStore.updateUserInfoField('nickname', nickname); |
|||
} catch(err) { |
|||
console.log(err) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
}; |
|||
|
|||
// 保存资料 |
|||
const saveProfile = () => { |
|||
uni.showToast({ |
|||
title: '资料保存成功', |
|||
icon: 'success' |
|||
}); |
|||
|
|||
// 返回上一页 |
|||
setTimeout(() => { |
|||
uni.navigateBack(); |
|||
}, 1000); |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss"> |
|||
.edit-profile-page { |
|||
background-color: #f5f5f5; |
|||
min-height: 100vh; |
|||
padding: 0; |
|||
margin: 0; |
|||
|
|||
.page-header { |
|||
background-color: #fff; |
|||
padding: 16px; |
|||
text-align: center; |
|||
position: relative; |
|||
border-bottom: 1px solid #eee; |
|||
|
|||
.page-title { |
|||
font-size: 18px; |
|||
font-weight: bold; |
|||
color: #333; |
|||
} |
|||
} |
|||
|
|||
.profile-section { |
|||
background-color: #fff; |
|||
margin-top: 12px; |
|||
padding: 0 16px; |
|||
|
|||
.profile-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 16px 0; |
|||
border-bottom: 1px solid #eee; |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.item-label { |
|||
font-size: 16px; |
|||
color: #333; |
|||
} |
|||
|
|||
.item-value { |
|||
display: flex; |
|||
align-items: center; |
|||
color: #999; |
|||
|
|||
.nickname { |
|||
max-width: 200px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.arrow { |
|||
margin-left: 8px; |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.avatar-container { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.avatar-btn { |
|||
background: none; |
|||
border: none; |
|||
padding: 0; |
|||
margin: 0; |
|||
width: 50px; |
|||
height: 50px; |
|||
border-radius: 25px; |
|||
overflow: hidden; |
|||
|
|||
&::after { |
|||
border: none; |
|||
} |
|||
|
|||
.avatar { |
|||
width: 50px; |
|||
height: 50px; |
|||
border-radius: 25px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.save-section { |
|||
padding: 24px 16px; |
|||
|
|||
.save-btn { |
|||
width: 100%; |
|||
height: 48px; |
|||
background-color: #07c160; |
|||
color: #fff; |
|||
font-size: 16px; |
|||
border-radius: 24px; |
|||
border: none; |
|||
|
|||
&::after { |
|||
border: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,38 @@ |
|||
/** |
|||
* 延时函数 |
|||
* @param {number} ms - 延迟时间(毫秒) |
|||
* @returns {Promise} - 返回一个 Promise,在指定时间后 resolve |
|||
*/ |
|||
export function delay(ms) { |
|||
return new Promise(resolve => setTimeout(resolve, ms)); |
|||
} |
|||
|
|||
export function getLatLng() { |
|||
return new Promise((reslove) => { |
|||
uni.getLocation({ |
|||
type: 'gcj02', |
|||
success: function(res) { |
|||
console.log('当前位置的经度:' + res.longitude); |
|||
console.log('当前位置的纬度:' + res.latitude); |
|||
reslove({ |
|||
lat: res.latitude, |
|||
lng: res.longitude |
|||
}) |
|||
}, |
|||
fail: function(res) { |
|||
reslove({ |
|||
lat: null, |
|||
lng: null |
|||
}) |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
} |
|||
|
|||
// 使用示例:
|
|||
// async function example() {
|
|||
// console.log('开始');
|
|||
// await delay(2000); // 延时 2 秒
|
|||
// console.log('2秒后执行');
|
|||
// }
|
|||
Loading…
Reference in new issue