feat:聊天界面

This commit is contained in:
ctw committed 2025-11-26 11:45:44 +08:00
1 parent 3aaa1cb425
commit b57bd5cb65
503 files changed
+52442 -1595

No files matched your search

+55 -56
View File
@@ -1,64 +1,63 @@
<template>
<view class="arrow-bar">
<text class="icon iconfont" :class="icon" :style="{color: textColor}"></text>
<text class="title">{{ title }}</text>
<uni-icons class="arrow" type="right" size="16"></uni-icons>
</view>
<view class="arrow-bar">
<text class="icon iconfont" :class="icon" :style="{ color: textColor }"></text>
<text class="title">{{ title }}</text>
<uni-icons class="arrow" type="right" size="16"></uni-icons>
</view>
</template>
<script>
export default {
name: "arrow-bar",
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
default: ''
}
},
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"]
}
},
computed:{
textColor() {
let hash = 0;
for (var i = 0; i < this.title.length; i++) {
hash += this.title.charCodeAt(i);
}
return this.colors[hash % this.colors.length];
}
}
}
export default {
name: 'arrow-bar',
props: {
title: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
},
data() {
return {
colors: ['#5daa31', '#c7515a', '#e03697', '#85029b', '#c9b455', '#326eb6'],
};
},
computed: {
textColor() {
let hash = 0;
for (var i = 0; i < this.title.length; i++) {
hash += this.title.charCodeAt(i);
}
return this.colors[hash % this.colors.length];
},
},
};
</script>
<style lang="scss" scoped>
.arrow-bar {
width: 100%;
height: 90rpx;
font-size: $im-font-size;
color: $im-text-color;
margin-top: 5rpx;
background-color: white;
line-height: 90rpx;
display: flex;
.icon {
margin-left: 40rpx;
}
.title {
flex: 1;
margin-left: 10rpx;
}
.arrow-bar {
width: 100%;
height: 90rpx;
font-size: $im-font-size;
color: $im-text-color;
margin-top: 5rpx;
background-color: white;
line-height: 90rpx;
display: flex;
.arrow {
margin-right: 40rpx;
}
}
</style>
.icon {
margin-left: 40rpx;
}
.title {
flex: 1;
margin-left: 10rpx;
}
.arrow {
margin-right: 40rpx;
}
}
</style>
+8 -6
View File
@@ -14,13 +14,13 @@
</view>
<view class="chat-content">
<view class="chat-at-text">{{ atText }}</view>
<view class="chat-send-name" v-if="isShowSendName">{{
chat.sendNickName + ':&nbsp;'
}}</view>
<view class="chat-send-name" v-if="isShowSendName">
{{ chat.sendNickName + ':&nbsp;' }}
</view>
<view v-if="!isTextMessage" class="chat-content-text">{{ chat.lastContent }}</view>
<rich-text v-else class="chat-content-text" :nodes="nodesText"></rich-text>
<view v-if="chat.isDnd" class="icon iconfont icon-dnd"></view>
<uni-badge v-else-if="chat.unreadCount > 0" :max-num="99" :text="chat.unreadCount" />
<wd-badge v-else-if="chat.unreadCount > 0" :max="99" :model-value="chat.unreadCount" />
</view>
</view>
</view>
@@ -111,8 +111,9 @@
position: relative;
padding: 18rpx 20rpx;
align-items: center;
background-color: white;
background-color: $im-bg;
white-space: nowrap;
border-bottom: 1px solid $im-border;
&:hover {
background-color: $im-bg-active;
@@ -175,7 +176,7 @@
display: flex;
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
padding-top: 8rpx;
padding: 8rpx 30rpx 0 0;
align-items: center;
.chat-at-text {
@@ -191,6 +192,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 30rpx;
}
.icon {
+254 -223
View File
@@ -1,243 +1,274 @@
<template>
<view class="chat-record">
<view class="chat-record-bar" :class="{ recording: recording }" id="chat-record-bar" @click.stop=""
@touchstart.prevent="onStartRecord" @touchmove.prevent="onTouchMove" @touchend.prevent="onEndRecord">
{{ recording ? '正在录音' : '长按 说话' }}</view>
<view v-if="recording" class="chat-record-window" :style="recordWindowStyle">
<view class="rc-wave">
<text class="note" style="--d: 0"></text>
<text class="note" style="--d: 1"></text>
<text class="note" style="--d: 2"></text>
<text class="note" style="--d: 3"></text>
<text class="note" style="--d: 4"></text>
<text class="note" style="--d: 5"></text>
<text class="note" style="--d: 6"></text>
</view>
<view class="rc-tip">{{ recordTip }}</view>
<view class="cancel-btn" @click="onCancel">
<uni-icons :class="moveToCancel ? 'red' : 'black'" type="clear" :size="moveToCancel ? 45 : 40"></uni-icons>
</view>
<view class="opt-tip" :class="moveToCancel ? 'red' : 'black'">{{ moveToCancel ? '松手取消' : '松手发送,上划取消' }}</view>
</view>
</view>
<view class="chat-record">
<view
class="chat-record-bar"
:class="{ recording: recording }"
id="chat-record-bar"
@click.stop=""
@touchstart.prevent="onStartRecord"
@touchmove.prevent="onTouchMove"
@touchend.prevent="onEndRecord"
>
{{ recording ? '正在录音' : '长按 说话' }}</view
>
<view v-if="recording" class="chat-record-window" :style="recordWindowStyle">
<view class="rc-wave">
<text class="note" style="--d: 0"></text>
<text class="note" style="--d: 1"></text>
<text class="note" style="--d: 2"></text>
<text class="note" style="--d: 3"></text>
<text class="note" style="--d: 4"></text>
<text class="note" style="--d: 5"></text>
<text class="note" style="--d: 6"></text>
</view>
<view class="rc-tip">{{ recordTip }}</view>
<view class="cancel-btn" @click="onCancel">
<uni-icons
:class="moveToCancel ? 'red' : 'black'"
type="clear"
:size="moveToCancel ? 45 : 40"
></uni-icons>
</view>
<view class="opt-tip" :class="moveToCancel ? 'red' : 'black'">{{
moveToCancel ? '松手取消' : '松手发送,上划取消'
}}</view>
</view>
</view>
</template>
<script>
export default {
name: "chat-record",
data() {
return {
recording: false,
moveToCancel: false,
recordBarTop: 0,
druation: 0,
rcTimer: null
}
},
methods: {
onTouchMove(e) {
const moveY = e.touches[0].clientY;
this.moveToCancel = moveY < this.recordBarTop - 40;
},
onCancel() {
if (this.recording) {
this.moveToCancel = true;
this.onEndRecord();
}
},
onStartRecord() {
/* 用户第一次使用语音会唤醒录音权限请求,此时会导致@touchend失效,
export default {
name: 'chat-record',
data() {
return {
recording: false,
moveToCancel: false,
recordBarTop: 0,
druation: 0,
rcTimer: null,
};
},
methods: {
onTouchMove(e) {
const moveY = e.touches[0].clientY;
this.moveToCancel = moveY < this.recordBarTop - 40;
},
onCancel() {
if (this.recording) {
this.moveToCancel = true;
this.onEndRecord();
}
},
onStartRecord() {
/* 用户第一次使用语音会唤醒录音权限请求,此时会导致@touchend失效,
一直处于录音状态,这里允许用户再次点击发送语音并结束录音 */
if (this.recording) {
return;
}
console.log("开始录音")
this.moveToCancel = false;
this.initRecordBar();
if(!this.$rc.checkIsEnable()){
return;
}
this.$rc.start().then(() => {
this.recording = true;
console.log("开始录音成功")
// 开始计时
this.startTimer();
}).catch((e) => {
console.log("录音失败" + JSON.stringify(e))
uni.showToast({
title: "录音失败",
icon: "none"
});
});
},
onEndRecord() {
if(!this.recording){
return;
}
this.recording = false;
// 停止计时
this.stopTimer();
// 停止录音
this.$rc.close();
// 触屏位置是否移动到了取消区域
if (this.moveToCancel) {
console.log("录音取消")
return;
}
// 大于1秒才发送
if (this.druation <= 1) {
uni.showToast({
title: "说话时间太短",
icon: 'none'
})
return;
}
this.$rc.upload().then((data) => {
this.$emit("send", data);
}).catch((e) => {
uni.showToast({
title: e,
icon: 'none'
})
})
},
startTimer() {
this.druation = 0;
this.stopTimer();
this.rcTimer = setInterval(() => {
this.druation++;
// 大于60s,直接结束
if (this.druation >= 60) {
this.onEndRecord();
}
}, 1000)
},
stopTimer() {
this.rcTimer && clearInterval(this.rcTimer);
this.rcTimer = null;
},
initRecordBar() {
const query = uni.createSelectorQuery().in(this);
query.select('#chat-record-bar').boundingClientRect((rect) => {
// 顶部高度位置
this.recordBarTop = rect.top;
}).exec()
}
},
computed: {
recordWindowStyle() {
const windowHeight = uni.getSystemInfoSync().windowHeight;
const bottom = windowHeight - this.recordBarTop + 12;
return `bottom:${bottom}px;`
},
recordTip() {
if (this.druation > 50) {
return `${60 - this.druation}s后将停止录音`;
}
return `录音时长:${this.druation}s`;
}
},
unmounted() {
this.stopTimer();
this.recording = false;
}
}
if (this.recording) {
return;
}
console.log('开始录音');
this.moveToCancel = false;
this.initRecordBar();
if (!this.$rc.checkIsEnable()) {
return;
}
this.$rc
.start()
.then(() => {
this.recording = true;
console.log('开始录音成功');
// 开始计时
this.startTimer();
})
.catch((e) => {
console.log('录音失败' + JSON.stringify(e));
uni.showToast({
title: '录音失败',
icon: 'none',
});
});
},
onEndRecord() {
if (!this.recording) {
return;
}
this.recording = false;
// 停止计时
this.stopTimer();
// 停止录音
this.$rc.close();
// 触屏位置是否移动到了取消区域
if (this.moveToCancel) {
console.log('录音取消');
return;
}
// 大于1秒才发送
if (this.druation <= 1) {
uni.showToast({
title: '说话时间太短',
icon: 'none',
});
return;
}
this.$rc
.upload()
.then((data) => {
this.$emit('send', data);
})
.catch((e) => {
uni.showToast({
title: e,
icon: 'none',
});
});
},
startTimer() {
this.druation = 0;
this.stopTimer();
this.rcTimer = setInterval(() => {
this.druation++;
// 大于60s,直接结束
if (this.druation >= 60) {
this.onEndRecord();
}
}, 1000);
},
stopTimer() {
this.rcTimer && clearInterval(this.rcTimer);
this.rcTimer = null;
},
initRecordBar() {
const query = uni.createSelectorQuery().in(this);
query
.select('#chat-record-bar')
.boundingClientRect((rect) => {
// 顶部高度位置
this.recordBarTop = rect.top;
})
.exec();
},
},
computed: {
recordWindowStyle() {
const windowHeight = uni.getSystemInfoSync().windowHeight;
const bottom = windowHeight - this.recordBarTop + 12;
return `bottom:${bottom}px;`;
},
recordTip() {
if (this.druation > 50) {
return `${60 - this.druation}s后将停止录音`;
}
return `录音时长:${this.druation}s`;
},
},
unmounted() {
this.stopTimer();
this.recording = false;
},
};
</script>
<style lang="scss" scoped>
.chat-record {
.rc-wave {
display: flex;
align-items: flex-end;
justify-content: center;
position: relative;
height: 80rpx;
.chat-record {
.rc-wave {
display: flex;
align-items: flex-end;
justify-content: center;
position: relative;
height: 80rpx;
.note {
background: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
width: 4px;
height: 50%;
border-radius: 5rpx;
margin-right: 4px;
animation: loading 0.5s infinite linear;
animation-delay: calc(0.1s * var(--d));
.note {
background: $im-bg-linear;
width: 4px;
height: 50%;
border-radius: 5rpx;
margin-right: 4px;
animation: loading 0.5s infinite linear;
animation-delay: calc(0.1s * var(--d));
@keyframes loading {
0% {
background-image: linear-gradient(to right, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 20%;
border-radius: 5rpx;
}
@keyframes loading {
0% {
background-image: linear-gradient(
to right,
$im-color-primary-light-1 0%,
$im-color-primary-light-6 100%
);
height: 20%;
border-radius: 5rpx;
}
50% {
background-image: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 80%;
border-radius: 5rpx;
}
50% {
background-image: linear-gradient(
to top,
$im-color-primary-light-1 0%,
$im-color-primary-light-6 100%
);
height: 80%;
border-radius: 5rpx;
}
100% {
background-image: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 20%;
border-radius: 5rpx;
}
}
}
}
100% {
background-image: linear-gradient(
to top,
$im-color-primary-light-1 0%,
$im-color-primary-light-6 100%
);
height: 20%;
border-radius: 5rpx;
}
}
}
}
.chat-record-bar {
padding: 10rpx;
margin: 10rpx;
border-radius: 10rpx;
text-align: center;
box-shadow: $im-box-shadow;
.chat-record-bar {
padding: 10rpx;
margin: 10rpx;
border-radius: 10rpx;
text-align: center;
box-shadow: $im-box-shadow;
background-color: $im-bg-active;
&.recording {
background-color: $im-color-primary;
color: #fff;
}
}
&.recording {
background-color: $im-color-primary;
color: #fff;
}
}
.chat-record-window {
position: fixed;
left: 0;
right: 0;
height: 360rpx;
background-color: rgba(255, 255, 255, 0.95);
padding: 30rpx;
.chat-record-window {
position: fixed;
left: 0;
right: 0;
height: 360rpx;
background-color: $im-bg-active;
padding: 30rpx;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
}
}
.rc-tip {
text-align: center;
font-size: $im-font-size-small;
color: gray;
margin-top: 20rpx;
}
.rc-tip {
text-align: center;
font-size: $im-font-size-small;
color: $im-text-color-light;
margin-top: 20rpx;
}
.cancel-btn {
text-align: center;
margin-top: 40rpx;
height: 80rpx;
}
.cancel-btn {
text-align: center;
margin-top: 40rpx;
height: 80rpx;
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
}
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
.red {
color: $im-color-danger !important;
}
}
}
</style>
.red {
color: $im-color-danger !important;
}
}
}
</style>
+123 -115
View File
@@ -1,122 +1,130 @@
<template>
<view>
<lsj-upload ref="lsjUpload" :height="'100%'" :option="option" @uploadEnd="onUploadEnd" @change="onChange"
:size="maxSize" :instantly="true">
<slot></slot>
</lsj-upload>
</view>
<view>
<lsj-upload
ref="lsjUpload"
:height="'100%'"
:option="option"
@uploadEnd="onUploadEnd"
@change="onChange"
:size="maxSize"
:instantly="true"
>
<slot></slot>
</lsj-upload>
</view>
</template>
<script>
import UNI_APP from '@/.env.js';
import CHAT_ENV from '@/chat-env.js';
export default {
name: "file-upload",
data() {
return {
fileMap: new Map(),
option: {
url: UNI_APP.BASE_URL + '/file/upload',
name: 'file',
header: {
accessToken: uni.getStorageSync('loginInfo').accessToken
}
}
}
},
props: {
maxSize: {
type: Number,
default: 10
},
onBefore: {
type: Function,
default: null
},
onSuccess: {
type: Function,
default: null
},
onError: {
type: Function,
default: null
}
},
methods: {
show() {
this.$refs.lsjUpload.show();
},
hide() {
this.$refs.lsjUpload.hide();
},
onUploadEnd(item) {
let file = this.fileMap.get(item.path);
if (item.type == 'fail') {
this.onError(file)
return;
}
let res = JSON.parse(item.responseText);
if (res.code == 200) {
// 上传成功
this.onOk(file, res);
} else if (res.code == 401) {
// token已过期,重新获取token
this.refreshToken().then((res) => {
let newToken = res.data.accessToken;
this.option.header.accessToken = newToken;
this.$refs.lsjUpload.setData(this.option);
// 重新上传
this.$refs.lsjUpload.upload(file.name);
}).catch(() => {
this.onError(file, res);
})
} else {
// 上传失败
this.onError(file, res);
}
},
onChange(files) {
if (!files.size) {
return;
}
files.forEach((file, name) => {
if (!this.fileMap.has(file.path)) {
this.onBefore && this.onBefore(file)
this.fileMap.set(file.path, file);
}
})
},
onOk(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onSuccess && this.onSuccess(file, res);
},
onFailed(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onError && this.onError(file, res);
},
refreshToken() {
return new Promise((resolve, reject) => {
let loginInfo = uni.getStorageSync('loginInfo')
uni.request({
method: 'PUT',
url: UNI_APP.BASE_URL + '/refreshToken',
header: {
refreshToken: loginInfo.refreshToken
},
success: (res) => {
resolve(res.data);
},
fail: (res) => {
reject(res);
}
})
})
}
}
}
export default {
name: 'file-upload',
data() {
return {
fileMap: new Map(),
option: {
url: CHAT_ENV.BASE_URL + '/file/upload',
name: 'file',
header: {
accessToken: uni.getStorageSync('loginInfo').accessToken,
},
},
};
},
props: {
maxSize: {
type: Number,
default: 10,
},
onBefore: {
type: Function,
default: null,
},
onSuccess: {
type: Function,
default: null,
},
onError: {
type: Function,
default: null,
},
},
methods: {
show() {
this.$refs.lsjUpload.show();
},
hide() {
this.$refs.lsjUpload.hide();
},
onUploadEnd(item) {
let file = this.fileMap.get(item.path);
if (item.type == 'fail') {
this.onError(file);
return;
}
let res = JSON.parse(item.responseText);
if (res.code == 200) {
// 上传成功
this.onOk(file, res);
} else if (res.code == 401) {
// token已过期,重新获取token
this.refreshToken()
.then((res) => {
let newToken = res.data.accessToken;
this.option.header.accessToken = newToken;
this.$refs.lsjUpload.setData(this.option);
// 重新上传
this.$refs.lsjUpload.upload(file.name);
})
.catch(() => {
this.onError(file, res);
});
} else {
// 上传失败
this.onError(file, res);
}
},
onChange(files) {
if (!files.size) {
return;
}
files.forEach((file, name) => {
if (!this.fileMap.has(file.path)) {
this.onBefore && this.onBefore(file);
this.fileMap.set(file.path, file);
}
});
},
onOk(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onSuccess && this.onSuccess(file, res);
},
onFailed(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onError && this.onError(file, res);
},
refreshToken() {
return new Promise((resolve, reject) => {
let loginInfo = uni.getStorageSync('loginInfo');
uni.request({
method: 'PUT',
url: CHAT_ENV.BASE_URL + '/refreshToken',
header: {
refreshToken: loginInfo.refreshToken,
},
success: (res) => {
resolve(res.data);
},
fail: (res) => {
reject(res);
},
});
});
},
},
};
</script>
<style></style>
<style></style>
+93 -93
View File
@@ -1,100 +1,100 @@
<template>
<view @click="selectAndUpload()">
<slot></slot>
</view>
<view @click="selectAndUpload()">
<slot></slot>
</view>
</template>
<script>
import UNI_APP from '@/.env.js'
import CHAT_ENV from '@/chat-env.js';
export default {
name: "image-upload",
data() {
return {
uploadHeaders: {
"accessToken": uni.getStorageSync('loginInfo').accessToken
}
}
},
props: {
maxCount: {
type: Number,
default: 1
},
maxSize: {
type: Number,
default: 5 * 1024 * 1024
},
sourceType: {
type: String,
default: 'album'
},
isPermanent: {
type: Boolean,
default: false
},
thumbSize: {
type: Number,
default: 50
},
onBefore: {
type: Function,
default: null
},
onSuccess: {
type: Function,
default: null
},
onError: {
type: Function,
default: null
}
},
methods: {
selectAndUpload() {
uni.chooseImage({
count: this.maxCount, //最多可以选择的图片张数,默认9
sourceType: [this.sourceType], //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
sizeType: ['original'], //original 原图,compressed 压缩图,默认二者都有
success: (res) => {
res.tempFiles.forEach((file) => {
if (!this.onBefore || this.onBefore(file)) {
// 调用上传图片的接口
this.uploadImage(file);
}
})
}
})
},
uploadImage(file) {
let action = `/image/upload?isPermanent=${this.isPermanent}&thumbSize=${this.thumbSize}`
uni.uploadFile({
url: UNI_APP.BASE_URL + action,
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
filePath: file.path, // 要上传文件资源的路径
name: 'file',
success: (res) => {
let data = JSON.parse(res.data);
if (data.code != 200) {
uni.showToast({
icon: "none",
title: data.message,
})
this.onError && this.onError(file, data);
} else {
this.onSuccess && this.onSuccess(file, data);
}
},
fail: (err) => {
console.log(err);
this.onError && this.onError(file, err);
}
})
}
}
}
export default {
name: 'image-upload',
data() {
return {
uploadHeaders: {
accessToken: uni.getStorageSync('loginInfo').accessToken,
},
};
},
props: {
maxCount: {
type: Number,
default: 1,
},
maxSize: {
type: Number,
default: 5 * 1024 * 1024,
},
sourceType: {
type: String,
default: 'album',
},
isPermanent: {
type: Boolean,
default: false,
},
thumbSize: {
type: Number,
default: 50,
},
onBefore: {
type: Function,
default: null,
},
onSuccess: {
type: Function,
default: null,
},
onError: {
type: Function,
default: null,
},
},
methods: {
selectAndUpload() {
uni.chooseImage({
count: this.maxCount, //最多可以选择的图片张数,默认9
sourceType: [this.sourceType], //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
sizeType: ['original'], //original 原图,compressed 压缩图,默认二者都有
success: (res) => {
res.tempFiles.forEach((file) => {
if (!this.onBefore || this.onBefore(file)) {
// 调用上传图片的接口
this.uploadImage(file);
}
});
},
});
},
uploadImage(file) {
let action = `/image/upload?isPermanent=${this.isPermanent}&thumbSize=${this.thumbSize}`;
uni.uploadFile({
url: CHAT_ENV.BASE_URL + action,
header: {
accessToken: uni.getStorageSync('loginInfo').accessToken,
},
filePath: file.path, // 要上传文件资源的路径
name: 'file',
success: (res) => {
let data = JSON.parse(res.data);
if (data.code != 200) {
uni.showToast({
icon: 'none',
title: data.message,
});
this.onError && this.onError(file, data);
} else {
this.onSuccess && this.onSuccess(file, data);
}
},
fail: (err) => {
console.log(err);
this.onError && this.onError(file, err);
},
});
},
},
};
</script>
<style></style>
<style></style>
+134 -118
View File
@@ -1,129 +1,145 @@
<template>
<view class="long-press-menu none-pointer-events">
<view @longpress.prevent.stop="onLongPress($event)" @touchmove="onTouchMove" @touchend="onTouchEnd">
<slot></slot>
</view>
<view v-if="isShowMenu" class="menu-mask" @touchstart="onClose()" @contextmenu.prevent=""></view>
<view v-if="isShowMenu" class="menu" :style="menuStyle">
<view class="menu-item" v-for="(item) in items" :key="item.key" @click.prevent="onSelectMenu(item)">
<text :style="itemStyle(item)"> {{ item.name }}</text>
</view>
</view>
</view>
<view class="long-press-menu none-pointer-events">
<view
@longpress.prevent.stop="onLongPress($event)"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<slot></slot>
</view>
<view
v-if="isShowMenu"
class="menu-mask"
@touchstart="onClose()"
@contextmenu.prevent=""
></view>
<view v-if="isShowMenu" class="menu" :style="menuStyle">
<view
class="menu-item"
v-for="item in items"
:key="item.key"
@click.prevent="onSelectMenu(item)"
>
<text :style="itemStyle(item)"> {{ item.name }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: "long-press-menu",
data() {
return {
isShowMenu: false,
isTouchMove: false,
style: ""
}
},
props: {
items: {
type: Array
}
},
methods: {
onLongPress(e) {
if (this.isTouchMove) {
// 屏幕移动时不弹出
return;
}
uni.getSystemInfo({
success: (res) => {
let touches = e.touches[0];
let style = "";
/* 因 非H5端不兼容 style 属性绑定 Object ,所以拼接字符 */
if (touches.clientY > (res.windowHeight / 2)) {
style = `bottom:${res.windowHeight - touches.clientY + 20}px;`;
} else {
style = `top:${touches.clientY + 20}px;`;
}
if (touches.clientX > (res.windowWidth / 2)) {
style += `right:${res.windowWidth - touches.clientX}px;`;
} else {
style += `left:${touches.clientX}px;`;
}
this.menuStyle = style;
//
this.$nextTick(() => {
this.isShowMenu = true;
});
this.menuTouch = touches
}
})
},
onTouchMove(e) {
this.isTouchMove = true;
let touches = e.touches[0];
// 屏幕拖动大于50px时,取消菜单
if (Math.abs(touches.clientX - this.menuTouch.clientX) > 50 ||
Math.abs(touches.clientY - this.menuTouch.clientY) > 50) {
this.onClose();
}
},
onTouchEnd() {
this.isTouchMove = false;
},
onSelectMenu(item) {
this.$emit("select", item);
this.isShowMenu = false;
},
onClose() {
this.isShowMenu = false;
},
itemStyle(item) {
if (item.color) {
return `color:${item.color};`
}
return `color:#000;`;
}
}
}
export default {
name: 'long-press-menu',
data() {
return {
isShowMenu: false,
isTouchMove: false,
style: '',
};
},
props: {
items: {
type: Array,
},
},
methods: {
onLongPress(e) {
if (this.isTouchMove) {
// 屏幕移动时不弹出
return;
}
uni.getSystemInfo({
success: (res) => {
let touches = e.touches[0];
let style = '';
/* 因 非H5端不兼容 style 属性绑定 Object ,所以拼接字符 */
if (touches.clientY > res.windowHeight / 2) {
style = `bottom:${res.windowHeight - touches.clientY + 20}px;`;
} else {
style = `top:${touches.clientY + 20}px;`;
}
if (touches.clientX > res.windowWidth / 2) {
style += `right:${res.windowWidth - touches.clientX}px;`;
} else {
style += `left:${touches.clientX}px;`;
}
this.menuStyle = style;
//
this.$nextTick(() => {
this.isShowMenu = true;
});
this.menuTouch = touches;
},
});
},
onTouchMove(e) {
this.isTouchMove = true;
let touches = e.touches[0];
// 屏幕拖动大于50px时,取消菜单
if (
Math.abs(touches.clientX || 0 - this.menuTouch.clientX || 0) > 50 ||
Math.abs(touches.clientY || 0 - this.menuTouch.clientY || 0) > 50
) {
this.onClose();
}
},
onTouchEnd() {
this.isTouchMove = false;
},
onSelectMenu(item) {
this.$emit('select', item);
this.isShowMenu = false;
},
onClose() {
this.isShowMenu = false;
},
itemStyle(item) {
if (item.color) {
return `color:${item.color};`;
}
return `color:#fff;`;
},
},
};
</script>
<style lang="scss" scoped>
.long-press-menu {
.menu-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 999;
}
.long-press-menu {
.menu-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 999;
}
.menu {
position: fixed;
border-radius: 4px;
overflow: hidden;
background-color: #fff;
z-index: 1000;
box-shadow: $im-box-shadow-dark;
.menu {
position: fixed;
border-radius: 4px;
overflow: hidden;
background-color: $im-bg-active;
z-index: 1000;
box-shadow: $im-box-shadow-dark;
.menu-item {
height: 28px;
min-width: 120rpx;
line-height: 28px;
font-size: $im-font-size-small;
display: flex;
padding: 6px 20px;
justify-content: flex-start;
.menu-item {
height: 28px;
min-width: 120rpx;
line-height: 28px;
font-size: $im-font-size-small;
display: flex;
padding: 6px 20px;
justify-content: flex-start;
&:hover {
background: $im-bg-active;
}
&:hover {
background: $im-bg-active-dark;
}
.menu-icon {
margin-right: 10rpx;
}
}
}
}
</style>
.menu-icon {
margin-right: 10rpx;
}
}
}
}
</style>