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