feat:聊天页面代码移植

This commit is contained in:
ctw committed 2025-11-25 16:52:10 +08:00
1 parent 6d26839aee
commit 3aaa1cb425
113 files changed
+7726 -166

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
<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>
</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];
}
}
}
</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 {
margin-right: 40rpx;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<template>
<view class="bar-group">
<slot></slot>
</view>
</template>
<script>
export default {
name: "bar-group"
}
</script>
<style lang="scss" scoped>
.bar-group {
margin: 20rpx 0;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<view class="btn-bar" :style="style">
<text v-if="icon" class="icon iconfont" :class="icon"></text>
<text class="title">{{ title }}</text>
</view>
</template>
<script>
export default {
name: "btn-bar",
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
required: false
},
type: {
type: String,
default: "normal"
},
color: {
type: String,
default: "#000"
}
},
computed: {
style() {
let color = "#000";
switch (this.type) {
case 'danger':
color = "#f14747";
break;
case 'primary':
color = "#35567f";
break;
}
return `color: ${color};`
}
}
}
</script>
<style lang="scss" scoped>
.btn-bar {
width: 100%;
height: 100rpx;
margin-top: 5rpx;
background-color: white;
line-height: 100rpx;
text-align: center;
display: flex;
justify-content: center;
.icon {
font-size: 40rpx;
font-weight: 600;
margin-right: 10rpx;
}
.title {
font-size: 32rpx;
font-weight: 600;
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="switch-bar">
<text class="title">{{ title }}</text>
<switch class="switch" :checked="checked" color="#18bc37" @change="onChange"></switch>
</view>
</template>
<script>
export default {
name: "switch-bar",
props: {
title: {
type: String,
required: true
},
checked: {
type: Boolean,
default: false
}
},
data() {
return {
value: this.checked
}
},
methods: {
onChange(e) {
this.value = true;
setTimeout(() => {
this.value = false;
}, 100)
//this.value = false;
this.$emit('change', e);
}
}
}
</script>
<style lang="scss" scoped>
.switch-bar {
width: 100%;
height: 100rpx;
font-size: 34rpx;
color: black;
margin-top: 5rpx;
background-color: white;
line-height: 100rpx;
display: flex;
.title {
flex: 1;
margin-left: 40rpx;
}
.switch {
margin-right: 40rpx;
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<uni-popup ref="popup" type="bottom" @change="onChange">
<view class="chat-at-box">
<view class="chat-at-top">
<view class="chat-at-tip"> 选择要提醒的人</view>
<button class="chat-at-btn" type="warn" size="mini" @click="onClean()">清空 </button>
<button class="chat-at-btn" type="primary" size="mini" @click="onOk()">确定({{ atUserIds.length }})
</button>
</view>
<scroll-view v-show="atUserIds.length > 0" scroll-x="true" scroll-left="120">
<view class="at-user-items">
<view v-for="m in checkedMembers" class="at-user-item" :key="m.userId">
<head-image :name="m.showNickName" :url="m.headImage" size="mini"></head-image>
</view>
</view>
</scroll-view>
<view class="search-bar">
<uni-search-bar v-model="searchText" cancelButton="none" radius="100" placeholder="搜索"></uni-search-bar>
</view>
<view class="member-items">
<virtual-scroller :items="memberItems">
<template v-slot="{ item }">
<view class="member-item" @click="onSwitchChecked(item)">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
size="small"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
<radio :checked="item.checked" :disabled="item.locked"
@click.stop="onSwitchChecked(item)" />
</view>
</template>
</virtual-scroller>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "chat-at-box",
props: {
ownerId: {
type: Number,
},
members: {
type: Array
}
},
data() {
return {
searchText: "",
showMembers: []
};
},
methods: {
init(atUserIds) {
this.showMembers = [];
let userId = this.userStore.userInfo.id;
if (this.ownerId == userId) {
this.showMembers.push({
userId: -1,
showNickName: "全体成员"
})
}
this.members.forEach((m) => {
if (!m.quit && m.userId != userId) {
m.checked = atUserIds.indexOf(m.userId) >= 0;
this.showMembers.push(m);
}
});
},
open() {
this.$refs.popup.open();
},
onSwitchChecked(member) {
member.checked = !member.checked;
},
onClean() {
this.showMembers.forEach((m) => {
m.checked = false;
})
},
onOk() {
this.$refs.popup.close();
},
onChange(e) {
if (!e.show) {
this.$emit("complete", this.atUserIds)
}
}
},
computed: {
atUserIds() {
return this.showMembers.filter(m => m.checked).map(m => m.userId);
},
checkedMembers() {
return this.showMembers.filter(m => m.checked);
},
memberItems() {
return this.showMembers.filter(m => m.showNickName.includes(this.searchText));
}
}
}
</script>
<style lang="scss" scoped>
.chat-at-box {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
//border-radius: 15rpx;
.chat-at-top {
display: flex;
align-items: center;
height: 70rpx;
padding: 10rpx;
.chat-at-tip {
flex: 1;
}
.chat-at-btn {
margin-left: 10rpx;
}
}
.at-user-items {
display: flex;
align-items: center;
height: 90rpx;
.at-user-item {
padding: 3rpx;
}
}
.member-items {
position: relative;
flex: 1;
overflow: hidden;
.member-item {
height: 110rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
margin-bottom: 1px;
&:hover {
background-color: $im-bg-active;
}
.member-name {
flex: 1;
padding-left: 20rpx;
font-size: $im-font-size;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="chat-group-readed">
<view class="uni-padding-wrap uni-common-mt">
<uni-segmented-control :current="current" :values="items" style-type="button"
@clickItem="onClickItem" />
</view>
<view class="content">
<view v-if="current === 0">
<virtual-scroller :items="readedMembers">
<template v-slot="{ item }">
<view class="member-item">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
</view>
</template>
</virtual-scroller>
</view>
<view v-if="current === 1">
<virtual-scroller :items="unreadMembers">
<template v-slot="{ item }">
<view class="member-item">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
</view>
</template>
</virtual-scroller>
</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "chat-group-readed",
data() {
return {
items: ['已读', '未读'],
current: 0,
readedMembers: [],
unreadMembers: []
};
},
props: {
msgInfo: {
type: Object,
required: true
},
groupMembers: {
type: Array
}
},
methods: {
open() {
this.$refs.popup.open();
this.loadReadedUser();
},
loadReadedUser() {
this.readedMembers = [];
this.unreadMembers = [];
this.$http({
url: `/message/group/findReadedUsers?groupId=${this.msgInfo.groupId}&messageId=${this.msgInfo.id}`,
method: 'Get'
}).then(userIds => {
this.groupMembers.forEach(member => {
// 发送者和已退群的不显示
if (member.userId == this.msgInfo.sendId || member.quit) {
return;
}
// 区分已读还是未读
if (userIds.find(userId => member.userId == userId)) {
this.readedMembers.push(member);
} else {
this.unreadMembers.push(member);
}
})
this.items[0] = `已读(${this.readedMembers.length})`;
this.items[1] = `未读(${this.unreadMembers.length})`;
let chatInfo = {
type: 'GROUP',
targetId: this.msgInfo.groupId
}
let msgInfo = {
id: this.msgInfo.id,
groupId: this.msgInfo.groupId,
readedCount: this.readedMembers.length
}
// 更新已读人数
this.chatStore.updateMessage(msgInfo, chatInfo)
})
},
onClickItem(e) {
this.current = e.currentIndex;
}
}
}
</script>
<style lang="scss" scoped>
.chat-group-readed {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
.member-item {
height: 120rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
.member-name {
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<view class="chat-item" :class="active ? 'active' : ''">
<!--rich-text中的表情包会屏蔽事件,所以这里用一个遮罩层捕获点击事件 -->
<view class="mask" @tap="showChatBox()"></view>
<view class="left">
<head-image :url="chat.headImage" :name="chat.showName" :online="online"></head-image>
</view>
<view class="chat-right">
<view class="chat-name">
<view class="chat-name-text">
<view>{{ chat.showName }}</view>
</view>
<view class="chat-time">{{ $date.toTimeText(chat.lastSendTime, true) }}</view>
</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 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" />
</view>
</view>
</view>
</template>
<script>
export default {
name: 'chatItem',
data() {
return {};
},
props: {
chat: {
type: Object,
},
index: {
type: Number,
},
active: {
type: Boolean,
default: false,
},
},
methods: {
showChatBox() {
// 初始化期间进入会话会导致消息不刷新
if (!this.configStore.appInit || this.chatStore.loading) {
uni.showToast({
title: '正在初始化页面,请稍后...',
icon: 'none',
});
return;
}
uni.navigateTo({
url: '/pages/message/chat-box?chatIdx=' + this.index,
});
},
},
computed: {
isShowSendName() {
if (!this.chat.sendNickName) {
return false;
}
let size = this.chat.messages.length;
if (size == 0) {
return false;
}
// 只有群聊的普通消息需要显示名称
let lastMsg = this.chat.messages[size - 1];
return this.$msgType.isNormal(lastMsg.type);
},
atText() {
if (this.chat.atMe) {
return '[有人@我]';
} else if (this.chat.atAll) {
return '[@全体成员]';
}
return '';
},
isTextMessage() {
if (this.chat.messages.length == 0) {
return false;
}
let idx = this.chat.messages.length - 1;
let messageType = this.chat.messages[idx].type;
return messageType == this.$enums.MESSAGE_TYPE.TEXT;
},
nodesText() {
let text = this.$str.html2Escape(this.chat.lastContent);
return this.$emo.transform(text, 'emoji-small');
},
online() {
if (this.chat.type == 'PRIVATE') {
let friend = this.friendStore.findFriend(this.chat.targetId);
return friend && friend.online;
}
return false;
},
},
};
</script>
<style scoped lang="scss">
.chat-item {
height: 96rpx;
display: flex;
margin-bottom: 2rpx;
position: relative;
padding: 18rpx 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg-active;
}
&.active {
background-color: $im-bg-active;
}
.mask {
position: absolute;
width: 100%;
height: 100%;
left: 0;
right: 0;
z-index: 99;
}
.left {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100rpx;
height: 100rpx;
}
.chat-right {
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 20rpx;
text-align: left;
overflow: hidden;
.chat-name {
display: flex;
.chat-name-text {
flex: 1;
font-size: $im-font-size-large;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.chat-time {
font-size: $im-font-size-smaller-extra;
color: $im-text-color-lighter;
text-align: right;
white-space: nowrap;
overflow: hidden;
}
}
.chat-content {
display: flex;
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
padding-top: 8rpx;
align-items: center;
.chat-at-text {
color: $im-color-danger;
}
.chat-send-name {
font-size: $im-font-size-smaller;
}
.chat-content-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon {
font-size: $im-font-size;
}
}
}
}
</style>
@@ -0,0 +1,546 @@
<template>
<view class="chat-message-item">
<view class="message-tip" v-if="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">
{{ msgInfo.content }}
</view>
<view class="message-tip" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TIME">
{{ $date.toTimeText(msgInfo.sendTime) }}
</view>
<view class="message-normal" v-else-if="isNormal" :class="{ 'message-mine': msgInfo.selfSend }">
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage"
:name="showName" size="small"></head-image>
<view class="content">
<view v-if="msgInfo.groupId && !msgInfo.selfSend" class="top">
<text class="name">{{ showName }}</text>
</view>
<view class="bottom">
<view class="message-content-wrapper">
<view v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<!-- up-parse支持点击a标签,但是不支持显示emo表情,也不支持换行 -->
<up-parse v-if="$url.containUrl(msgInfo.content)&&!$emo.containEmoji(msgInfo.content)"
class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
<!-- rich-text支持显示emo表情以及消息换行,但是不支持点击a标签 -->
<rich-text v-else class="message-text" :nodes="nodesText"></rich-text>
</long-press-menu>
</view>
<view class="message-image" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="image-box">
<image class="send-image" :style="imageStyle" mode="aspectFill"
:src="contentData.thumbUrl" lazy-load="true" @click.stop="onShowFullImage()">
</image>
<loading v-if="sending"></loading>
</view>
</long-press-menu>
</view>
<view class="message-file" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="file-box">
<view class="file-info">
<uni-link class="file-name" :text="contentData.name" showUnderLine="true"
color="#007BFF" :href="contentData.url"></uni-link>
<view class="file-size">{{ fileSize }}</view>
</view>
<view class="file-icon iconfont icon-file"></view>
<loading v-if="sending"></loading>
</view>
</long-press-menu>
</view>
<long-press-menu v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" :items="menuItems"
@select="onSelectMenu">
<view class="message-audio message-text" @click="onPlayAudio()">
<text class="iconfont icon-voice-play"></text>
<text class="chat-audio-text">{{ contentData.duration + '"' }}</text>
<text v-if="audioPlayState == 'PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState == 'PLAYING'" class="iconfont icon-pause"></text>
</view>
</long-press-menu>
<long-press-menu v-if="isAction" :items="menuItems" @select="onSelectMenu">
<view class="chat-realtime message-text" @click="$emit('call')">
<text v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VOICE"
class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VIDEO"
class="iconfont icon-chat-video"></text>
<text>{{ msgInfo.content }}</text>
</view>
</long-press-menu>
<view v-if="sending&&(isTextMessage||isAudioMessage)" class="sending">
<loading :size="40" icon-color="#656adf" :mask="false"></loading>
</view>
<view v-else-if="sendFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></view>
</view>
<view class="message-status" v-if="!isAction && msgInfo.selfSend && !msgInfo.groupId">
<text class="chat-readed" v-if="msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-else>未读</text>
</view>
<view class="chat-receipt" v-if="msgInfo.receipt" @click="onShowReadedBox">
<text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text>
<text v-else>{{ msgInfo.readedCount }}人已读</text>
</view>
</view>
</view>
</view>
<chat-group-readed ref="chatGroupReaded" :groupMembers="groupMembers" :msgInfo="msgInfo"></chat-group-readed>
</view>
</template>
<script>
export default {
name: "chat-message-item",
props: {
headImage: {
type: String,
required: true
},
showName: {
type: String,
required: true
},
msgInfo: {
type: Object,
required: true
},
groupMembers: {
type: Array
}
},
data() {
return {
audioPlayState: 'STOP',
innerAudioContext: null,
menu: {
show: false,
style: ""
}
}
},
methods: {
onSendFail() {
this.$emit("resend", this.msgInfo);
},
onPlayAudio() {
// 初始化音频播放器
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
let url = this.contentData.url;
this.innerAudioContext.src = url;
this.innerAudioContext.onEnded((e) => {
console.log('停止')
this.audioPlayState = "STOP"
this.emit();
})
this.innerAudioContext.onError((e) => {
this.audioPlayState = "STOP"
console.log("播放音频出错");
console.log(e)
this.emit();
});
}
if (this.audioPlayState == 'STOP') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING";
} else if (this.audioPlayState == 'PLAYING') {
this.innerAudioContext.pause();
this.audioPlayState = "PAUSE"
} else if (this.audioPlayState == 'PAUSE') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING"
}
this.emit();
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);
this.menu.show = false;
},
onShowFullImage() {
let imageUrl = this.contentData.originUrl;
uni.previewImage({
urls: [imageUrl]
})
},
onShowReadedBox() {
this.$refs.chatGroupReaded.open();
},
emit() {
this.$emit("audioStateChange", this.audioPlayState, this.msgInfo);
},
stopPlayAudio() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext = null;
this.audioPlayState = "STOP"
}
}
},
computed: {
sending() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.SENDING;
},
sendFail() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.FAILED;
},
contentData() {
return JSON.parse(this.msgInfo.content)
},
fileSize() {
let size = this.contentData.size;
if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M";
}
if (size > 1024) {
return Math.round(size / 1024) + "KB";
}
return size + "B";
},
menuItems() {
let items = [];
if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT) {
items.push({
key: 'COPY',
name: '复制',
icon: 'bars'
});
}
if (this.msgInfo.selfSend && this.msgInfo.id > 0) {
items.push({
key: 'RECALL',
name: '撤回',
icon: 'refreshempty'
});
}
items.push({
key: 'DELETE',
name: '删除',
icon: 'trash',
color: '#e64e4e'
});
if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.FILE) {
items.push({
key: 'DOWNLOAD',
name: '下载并打开',
icon: 'download'
});
}
return items;
},
isTextMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT
},
isAudioMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.AUDIO
},
isAction() {
return this.$msgType.isAction(this.msgInfo.type);
},
isNormal() {
const type = this.msgInfo.type;
return this.$msgType.isNormal(type) || this.$msgType.isAction(type)
},
nodesText() {
let color = this.msgInfo.selfSend ? 'white' : '';
let text = this.$str.html2Escape(this.msgInfo.content)
text = this.$url.replaceURLWithHTMLLinks(text, color)
return this.$emo.transform(text, 'emoji-normal')
},
imageStyle() {
console.log(uni.getSystemInfo())
let maxSize = uni.getSystemInfoSync().windowWidth * 0.6;
let minSize = uni.getSystemInfoSync().windowWidth * 0.2;
let width = this.contentData.width;
let height = this.contentData.height;
if (width && height) {
let ratio = Math.min(width, height) / Math.max(width, height);
let w = Math.max(width > height ? maxSize : ratio * maxSize, minSize);
let h = Math.max(width > height ? ratio * maxSize : maxSize, minSize);
return `width: ${w}px;height:${h}px;`
} else {
// 兼容历史版本,历史数据没有记录宽高
return `max-width: ${maxSize}px;min-width:100px;max-height: ${maxSize}px;min-height:100px;`
}
}
}
}
</script>
<style scoped lang="scss">
.chat-message-item {
padding: 2rpx 20rpx;
.message-tip {
line-height: 60rpx;
text-align: center;
color: $im-text-color-lighter;
font-size: $im-font-size-smaller-extra;
padding: 10rpx;
}
.message-normal {
position: relative;
margin-bottom: 22rpx;
padding-left: 110rpx;
min-height: 80rpx;
.avatar {
position: absolute;
top: 0;
left: 0;
}
.content {
text-align: left;
.top {
display: flex;
flex-wrap: nowrap;
align-items: center;
.name {
color: $im-text-color-lighter;
font-size: $im-font-size-smaller;
line-height: $im-font-size-smaller;
height: $im-font-size-smaller;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.bottom {
display: inline-block;
padding-right: 80rpx;
margin-top: 5rpx;
.message-content-wrapper {
position: relative;
display: flex;
align-items: center;
.sending {
position: relative;
margin: 0 6rpx;
.icon-loading {
color: $im-color-primary;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
margin: 0 5rpx;
}
.message-text {
position: relative;
line-height: 1.6;
margin-top: 10rpx;
padding: 16rpx 24rpx;
background-color: $im-bg;
border-radius: 20rpx;
color: $im-text-color;
font-size: $im-font-size;
text-align: left;
display: block;
word-break: break-word;
white-space: pre-line;
&:after {
content: "";
position: absolute;
left: -20rpx;
top: 26rpx;
width: 6rpx;
height: 6rpx;
border-style: solid dashed dashed;
border-color: $im-bg transparent transparent;
overflow: hidden;
border-width: 18rpx;
}
}
.message-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
.image-box {
position: relative;
.send-image {
cursor: pointer;
border-radius: 10rpx;
background: $im-bg;
border: 6rpx solid $im-color-primary-light-5;
}
}
.send-fail {
color: $im-color-danger;
font-size: $im-font-size;
cursor: pointer;
margin: 0 20px;
}
}
.message-file {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
.file-box {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-height: 60px;
border-radius: 4px;
padding: 10px 15px;
box-shadow: $im-box-shadow-dark;
.file-info {
flex: 1;
height: 100%;
text-align: left;
font-size: 14px;
width: 300rpx;
.file-name {
font-weight: 600;
margin-bottom: 15px;
word-break: break-all;
}
}
.file-icon {
font-size: 80rpx;
color: #d42e07;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
cursor: pointer;
margin: 0 20rpx;
}
}
.message-audio {
display: flex;
align-items: center;
.chat-audio-text {
padding-right: 8px;
}
.icon-voice-play {
font-size: 18px;
padding-right: 8px;
}
}
}
.chat-realtime {
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
padding-right: 8px;
}
}
.message-status {
line-height: $im-font-size-smaller-extra;
font-size: $im-font-size-smaller-extra;
padding-top: 2rpx;
.chat-readed {
display: block;
padding-top: 2rpx;
color: $im-text-color-lighter;
}
.chat-unread {
color: $im-color-danger;
}
}
.chat-receipt {
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
font-weight: 600;
.icon-ok {
font-size: 20px;
color: $im-color-success;
}
}
}
}
&.message-mine {
text-align: right;
padding-left: 0;
padding-right: 110rpx;
.avatar {
left: auto;
right: 0;
}
.content {
text-align: right;
.bottom {
padding-left: 80rpx;
padding-right: 0;
.message-content-wrapper {
flex-direction: row-reverse;
}
.message-text {
margin-left: 10px;
background-color: $im-color-primary-light-2;
color: #fff;
&:after {
left: auto;
right: -9px;
border-top-color: $im-color-primary-light-2;
}
}
.message-audio {
flex-direction: row-reverse;
.chat-audio-text {
padding-right: 0;
padding-left: 8px;
}
.icon-voice-play {
transform: rotateY(180deg);
}
}
.chat-realtime {
display: flex;
flex-direction: row-reverse;
.iconfont {
transform: rotateY(180deg);
}
}
}
}
}
}
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<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>
</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失效,
一直处于录音状态,这里允许用户再次点击发送语音并结束录音 */
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;
.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));
@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;
}
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;
&.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;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
}
.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;
}
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
.red {
color: $im-color-danger !important;
}
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<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';
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);
}
})
})
}
}
}
</script>
<style></style>
@@ -0,0 +1,82 @@
<template>
<view class="friend-item" @click="showFriendInfo()">
<head-image :name="friend.nickName" :online="friend.online" :url="friend.headImage" size="small"></head-image>
<view class="friend-info">
<view class="friend-name">{{ friend.nickName }}</view>
<view class="friend-online">
<image v-show="friend.onlineWeb" class="online" src="/static/image/online_web.png" title="电脑设备在线" />
<image v-show="friend.onlineApp" class="online" src="/static/image/online_app.png" title="移动设备在线" />
</view>
<slot></slot>
</view>
</view>
</template>
<script>
export default {
name: "frined-item",
data() {
return {}
},
methods: {
showFriendInfo() {
if (this.detail) {
uni.navigateTo({
url: "/pages/common/user-info?id=" + this.friend.id
})
}
}
},
props: {
friend: {
type: Object
},
detail: {
type: Boolean,
default: true
}
}
}
</script>
<style scope lang="scss">
.friend-item {
height: 90rpx;
display: flex;
margin-bottom: 1rpx;
position: relative;
padding: 10rpx;
padding-left: 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg;
}
.friend-info {
flex: 1;
display: flex;
padding-left: 20rpx;
text-align: left;
.friend-name {
flex: 1;
font-size: $im-font-size;
white-space: nowrap;
overflow: hidden;
}
.friend-online {
margin-top: 4rpx;
.online {
padding-right: 4rpx;
width: 24rpx;
height: 24rpx;
}
}
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<view class="group-item" @click="showGroupInfo()">
<head-image :name="group.showGroupName" :url="group.headImageThumb" size="small"></head-image>
<view class="group-name">
<view>{{ group.showGroupName }}</view>
</view>
</view>
</template>
<script>
export default {
name: "group-item",
data() {
return {}
},
methods: {
showGroupInfo() {
uni.navigateTo({
url: "/pages/group/group-info?id=" + this.group.id
})
},
},
props: {
group: {
type: Object
}
}
}
</script>
<style scope lang="scss">
.group-item {
height: 90rpx;
display: flex;
margin-bottom: 2rpx;
position: relative;
padding: 18rpx 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg-active;
}
&.active {
background-color: $im-bg-active;
}
.group-name {
font-size: $im-font-size;
padding-left: 20rpx;
text-align: left;
white-space: nowrap;
overflow: hidden;
}
}
</style>
@@ -0,0 +1,180 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="group-member-selector">
<view class="top-bar">
<view class="top-tip">选择成员</view>
<button class="top-btn" type="warn" size="mini" @click="onClean()">清空 </button>
<button class="top-btn" type="primary" size="mini" @click="onOk()">确定({{ checkedIds.length }})
</button>
</view>
<scroll-view v-show="checkedIds.length > 0" scroll-x="true" scroll-left="120">
<view class="checked-users">
<view v-for="m in checkedMembers" class="user-item" :key="m.userId">
<head-image :name="m.showNickName" :url="m.headImage" :size="60"></head-image>
</view>
</view>
</scroll-view>
<view class="search-bar">
<uni-search-bar v-model="searchText" cancelButton="none" placeholder="搜索"></uni-search-bar>
</view>
<view class="member-items">
<virtual-scroller :items="showMembers">
<template v-slot="{ item }">
<view class="member-item" @click="onSwitchChecked(item)">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}
</view>
<view class="member-checked">
<radio :checked="item.checked" :disabled="item.locked"
@click.stop="onSwitchChecked(item)" />
</view>
</view>
</template>
</virtual-scroller>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "group-member-selector",
props: {
group: {
type: Object
},
members: {
type: Array
},
maxSize: {
type: Number,
default: 50
}
},
data() {
return {
searchText: "",
};
},
methods: {
init(checkedIds, lockedIds, hideIds) {
this.members.forEach((m) => {
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
m.hide = hideIds.indexOf(m.userId) >= 0;
});
},
open() {
this.$refs.popup.open();
},
onSwitchChecked(m) {
if (!m.locked) {
m.checked = !m.checked;
}
// 达到选择上限
if (this.maxSize > 0 && this.checkedIds.length > this.maxSize) {
m.checked = false;
uni.showToast({
title: `最多选择${this.maxSize}位用户`,
icon: "none"
})
}
},
onClean() {
this.members.forEach((m) => {
if (!m.locked && m.checked) {
m.checked = false;
}
})
},
onOk() {
this.$refs.popup.close();
this.$emit("complete", this.checkedIds)
}
},
computed: {
checkedIds() {
return this.checkedMembers.map(m => m.userId)
},
checkedMembers() {
return this.members.filter((m) => !m.quit && !m.hide && m.checked);
},
showMembers() {
return this.members.filter(m => !m.quit && !m.hide && m.showNickName.includes(this.searchText))
}
}
}
</script>
<style lang="scss" scoped>
.group-member-selector {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
border-radius: 15rpx 15rpx 0 0;
overflow: hidden;
.top-bar {
display: flex;
align-items: center;
height: 70rpx;
padding: 10rpx 30rpx;
.top-tip {
flex: 1;
}
.top-btn {
margin-left: 10rpx;
}
}
.checked-users {
display: flex;
align-items: center;
height: 90rpx;
padding: 0 30rpx;
.user-item {
padding: 3rpx;
}
}
.member-items {
position: relative;
flex: 1;
overflow: hidden;
.member-item {
height: 120rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
.member-name {
display: flex;
align-items: center;
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
.uni-tag {
margin-left: 5rpx;
}
}
}
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<uni-popup ref="popup" type="center">
<uni-popup-dialog mode="base" :duration="2000" title="是否加入通话?" confirmText="加入" @confirm="onOk">
<div class="group-rtc-join">
<div class="host-info">
<div>发起人</div>
<head-image :name="rtcInfo.host.nickName" :url="rtcInfo.host.headImage" :size="80"></head-image>
</div>
<div class="user-info">
<div>{{ rtcInfo.userInfos.length + '人正在通话中' }}</div>
<scroll-view scroll-x="true" scroll-left="120">
<view class="user-list">
<view v-for="user in rtcInfo.userInfos" class="user-item" :key="user.id">
<head-image :name="user.nickName" :url="user.headImage" :size="80"></head-image>
</view>
</view>
</scroll-view>
</div>
</div>
</uni-popup-dialog>
</uni-popup>
</template>
<script>
export default {
data() {
return {
rtcInfo: {}
}
},
props: {
groupId: {
type: Number
}
},
methods: {
open(rtcInfo) {
this.rtcInfo = rtcInfo;
this.$refs.popup.open();
},
onOk() {
let users = this.rtcInfo.userInfos;
let mine = this.userStore.userInfo;
// 加入自己的信息
if (!users.find((user) => user.id == mine.id)) {
users.push({
id: mine.id,
nickName: mine.nickName,
headImage: mine.headImageThumb,
isCamera: false,
isMicroPhone: true
})
}
const userInfos = encodeURIComponent(JSON.stringify(users));
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${this.groupId}&isHost=false
&inviterId=${mine.id}&userInfos=${userInfos}`
})
}
}
}
</script>
<style lang="scss" scoped>
.group-rtc-join {
width: 100%;
.host-info {
font-size: 16px;
padding: 10px;
}
.user-info {
font-size: 16px;
padding: 10px;
}
.user-list {
display: flex;
align-items: center;
height: 90rpx;
.user-item {
padding: 3rpx;
}
}
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<view class="head-image none-pointer-events" @click="showUserInfo($event)" :title="name">
<image class="avatar-image" v-if="url" :src="url" :style="avatarImageStyle" lazy-load="true"
mode="aspectFill" />
<view class="avatar-text" v-if="!url" :style="avatarTextStyle">
{{ name?.substring(0, 1).toUpperCase() }}
</view>
<view v-if="online" class="online" title="用户当前在线">
</view>
</view>
</template>
<script>
export default {
name: "head-image",
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"
]
}
},
props: {
id: {
type: Number
},
size: {
type: [Number, String],
default: 'default'
},
url: {
type: String
},
name: {
type: String,
default: null
},
radius: {
type: String,
default: "50%"
},
online: {
type: Boolean,
default: false
},
},
methods: {
showUserInfo(e) {
if (this.id && this.id > 0) {
uni.navigateTo({
url: "/pages/common/user-info?id=" + this.id
})
}
}
},
computed: {
_size() {
if (typeof this.size === 'number') {
return this.size;
} else if (typeof this.size === 'string') {
return {
'default': 96,
'small': 84,
'smaller': 72,
'mini': 60,
'minier': 48,
'lage': 108,
'lager': 120,
} [this.size]
}
},
avatarImageStyle() {
return `width:${this._size}rpx;
height:${this._size}rpx;`
},
avatarTextStyle() {
return `width: ${this._size}rpx;
height:${this._size}rpx;
background: linear-gradient(145deg,#ffffff20 25%,#00000060),${this.textColor};
font-size:${this._size * 0.45}rpx;
border-radius: ${this.radius};
`
},
textColor() {
if (!this.name) {
return '#fff';
}
let hash = 0;
for (var i = 0; i < this.name.length; i++) {
hash += this.name.charCodeAt(i);
}
return this.colors[hash % this.colors.length];
}
}
}
</script>
<style scoped lang="scss">
.head-image {
position: relative;
cursor: pointer;
.avatar-image {
position: relative;
overflow: hidden;
border-radius: 50%;
vertical-align: bottom;
}
.avatar-text {
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.online {
position: absolute;
right: -10%;
bottom: 0;
width: 24rpx;
height: 24rpx;
background: limegreen;
border-radius: 50%;
border: 6rpx solid white;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<view @click="selectAndUpload()">
<slot></slot>
</view>
</template>
<script>
import UNI_APP from '@/.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);
}
})
}
}
}
</script>
<style></style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<view class="loading-box" :style="loadingStyle">
<view class="rotate iconfont icon-loading" :style="icontStyle"></view>
<slot></slot>
</view>
</template>
<script>
export default {
data() {
return {}
},
props: {
size: {
type: Number,
default: 100
},
iconColor: {
type: String,
default: ''
},
mask: {
type: Boolean,
default: true
}
},
computed: {
icontStyle() {
let style = `font-size:${this.size}rpx;`;
if(this.iconColor){
style += `color: ${this.iconColor};`
}else if(this.mask){
style += 'color: #eee;'
}
return style;
},
loadingStyle() {
return this.mask ? "background: rgba(0, 0, 0, 0.3);" : "";
}
}
}
</script>
<style lang="scss" scoped>
.loading-box {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
}
.rotate {
animation: rotate 2s ease-in-out infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg)
}
to {
transform: rotate(360deg)
}
}
</style>
@@ -0,0 +1,129 @@
<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>
</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;`;
}
}
}
</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;
}
.menu {
position: fixed;
border-radius: 4px;
overflow: hidden;
background-color: #fff;
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;
&:hover {
background: $im-bg-active;
}
.menu-icon {
margin-right: 10rpx;
}
}
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<view class="im-nav-bar">
<!-- #ifdef APP-PLUS -->
<view style="height: var(--status-bar-height)"></view>
<!-- #endif -->
<view class="im-nav-bar-content">
<!-- #ifndef MP-WEIXIN -->
<view class="back" @click="handleBackClick" v-if="back">
<uni-icons type="back" :size="iconFontSize"></uni-icons>
</view>
<!-- #endif -->
<view class="title" v-if="title">
<slot></slot>
</view>
<view class="btn">
<uni-icons class="btn-item" v-if="search" type="search" :size="iconFontSize"
@click="$emit('search')"></uni-icons>
<uni-icons class="btn-item" v-if="add" type="plusempty" :size="iconFontSize"
@click="$emit('add')"></uni-icons>
<uni-icons class="btn-item" v-if="more" type="more-filled" :size="iconFontSize"
@click="$emit('more')"></uni-icons>
</view>
</view>
</view>
</template>
<script>
export default {
name: "nav-bar",
props: {
back: {
type: Boolean,
default: false
},
title: {
type: Boolean,
default: true
},
search: {
type: Boolean,
default: false
},
add: {
type: Boolean,
default: false
},
more: {
type: Boolean,
default: false
},
iconFontSize: {
type: Number,
default: 24
}
},
data() {
return {}
},
computed: {
height() {
}
},
methods: {
handleBackClick() {
uni.navigateBack({
delta: 1
})
}
}
}
</script>
<style scoped lang="scss">
.im-nav-bar {
background: $im-bg-linear;
position: fixed;
top: 0;
width: 100%;
color: $im-text-color;
border-bottom: 1px solid $im-border-light;
font-size: $im-font-size-large;
z-index: 99;
.im-nav-bar-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: $im-nav-bar-height;
.title {}
.back {
position: absolute;
left: 0;
height: 100%;
display: flex;
align-items: center;
padding: 12px;
font-size: 22px;
box-sizing: border-box;
}
.btn {
position: absolute;
right: 0;
height: 100%;
display: flex;
padding: 12px;
align-items: center;
box-sizing: border-box;
.btn-item {
margin-left: 8px;
}
}
}
}
</style>
@@ -0,0 +1,60 @@
<template>
<scroll-view scroll-y="true" :style="{height: height}" upper-threshold="200" @scrolltolower="onScrollToBottom"
scroll-with-animation="true">
<view v-for="(item, idx) in showItems" :key="idx">
<slot :item="item">
</slot>
</view>
</scroll-view>
</template>
<script>
export default {
name: "virtual-scroller",
data() {
return {
page: 1,
isInitEvent: false,
lockTip: false
}
},
props: {
height: {
type: String,
default: '60vh'
},
items: {
type: Array
},
size: {
type: Number,
default: 30
}
},
methods: {
onScrollToBottom(e) {
if (this.showMaxIdx >= this.items.length) {
this.showTip();
} else {
this.page++;
}
},
showTip() {
uni.showToast({
title: "已滚动至底部",
icon: 'none'
});
}
},
computed: {
showMaxIdx() {
return Math.min(this.page * this.size, this.items.length);
},
showItems() {
return this.items.slice(0, this.showMaxIdx);
}
}
}
</script>
<style scoped></style>