This commit is contained in:
pengxiaolong
2025-05-13 19:39:53 +08:00
parent 37da6765b8
commit c006a8e63d
1232 changed files with 96963 additions and 883 deletions

View File

@@ -0,0 +1,232 @@
<template>
<Overlay
:maskColor="'transparent'"
@onOverlayClick="() => emits('closeConversationActionMenu')"
>
<div
id="conversation-actions-menu"
ref="actionsMenuDomRef"
:class="[
isPC && 'actions-menu-pc',
'actions-menu',
!isHiddenActionsMenu && 'cancel-hidden',
]"
:style="{
top: `${_actionsMenuPosition.top}px`,
left: `${_actionsMenuPosition.left}px`,
}"
>
<div
:class="['actions-menu-item']"
@click.stop="deleteConversation()"
>
{{ TUITranslateService.t("TUIConversation.删除会话") }}
</div>
<div
v-if="!(props.selectedConversation && props.selectedConversation.isPinned)"
:class="['actions-menu-item']"
@click.stop="handleItem({ name: CONV_OPERATION.ISPINNED })"
>
{{ TUITranslateService.t("TUIConversation.置顶会话") }}
</div>
<div
v-if="props.selectedConversation && props.selectedConversation.isPinned"
:class="['actions-menu-item']"
@click.stop="handleItem({ name: CONV_OPERATION.DISPINNED })"
>
{{ TUITranslateService.t("TUIConversation.取消置顶") }}
</div>
<div
v-if="!(props.selectedConversation && props.selectedConversation.isMuted)"
:class="['actions-menu-item']"
@click.stop="handleItem({ name: CONV_OPERATION.MUTE })"
>
{{ TUITranslateService.t("TUIConversation.消息免打扰") }}
</div>
<div
v-if="props.selectedConversation && props.selectedConversation.isMuted"
:class="['actions-menu-item']"
@click.stop="handleItem({ name: CONV_OPERATION.NOTMUTE })"
>
{{ TUITranslateService.t("TUIConversation.取消免打扰") }}
</div>
</div>
<Dialog
:show="isShowDeleteConversationDialog"
:center="true"
:isHeaderShow="isPC"
@submit="handleItem({ name: CONV_OPERATION.DELETE })"
@update:show="updateShowDeleteConversationDialog"
>
<p class="delDialog-title">
{{ TUITranslateService.t(deleteConversationDialogTitle) }}
</p>
</Dialog>
</Overlay>
</template>
<script setup lang="ts">
import {
ref,
nextTick,
onMounted,
computed,
getCurrentInstance,
} from '../../../adapter-vue';
import TUIChatEngine, {
IConversationModel,
TUIStore,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { CONV_OPERATION } from '../../../constant';
import { isPC, isUniFrameWork } from '../../../utils/env';
import Overlay from '../../common/Overlay/index.vue';
import Dialog from '../../common/Dialog/index.vue';
interface IProps {
actionsMenuPosition: {
top: number;
left?: number;
conversationHeight?: number;
};
selectedConversation: IConversationModel | undefined;
selectedConversationDomRect: DOMRect | undefined;
}
const emits = defineEmits(['closeConversationActionMenu']);
const props = defineProps<IProps>();
const thisInstance = getCurrentInstance()?.proxy || getCurrentInstance();
const actionsMenuDomRef = ref<HTMLElement | null>();
const isHiddenActionsMenu = ref(true);
const isShowDeleteConversationDialog = ref<boolean>(false);
const currentConversation = TUIStore.getConversationModel(
props.selectedConversation?.conversationID || '',
);
const _actionsMenuPosition = ref<{
top: number;
left?: number;
conversationHeight?: number;
}>(props.actionsMenuPosition);
onMounted(() => {
checkExceedBounds();
});
const deleteConversationDialogTitle = computed(() => {
return props.selectedConversation?.type === TUIChatEngine.TYPES.CONV_C2C
? 'TUIConversation.删除后,将清空该聊天的消息记录'
: props.selectedConversation?.type === TUIChatEngine.TYPES.CONV_GROUP ? 'TUIConversation.删除后,将清空该群聊的消息记录' : '';
});
function checkExceedBounds() {
// When the component is initially rendered, it executes and self-checks whether the boundary exceeds the screen, and handles it in nextTick.
nextTick(() => {
if (isUniFrameWork) {
// check exceed bounds
const query = TUIGlobal?.createSelectorQuery().in(thisInstance);
query
.select(`#conversation-actions-menu`)
.boundingClientRect((data) => {
if (data) {
// check if actionsMenu is exceed bottom of the screen
if (data.bottom > TUIGlobal?.getWindowInfo?.().windowHeight) {
_actionsMenuPosition.value = {
...props.actionsMenuPosition,
top:
props.actionsMenuPosition.top
- (props.actionsMenuPosition.conversationHeight || 0)
- data.height,
};
}
// check if actionsMenu is exceed right of the screen
if (_actionsMenuPosition.value.left + data.width + 5 > TUIGlobal.getWindowInfo().windowWidth) {
_actionsMenuPosition.value.left = TUIGlobal.getWindowInfo().windowWidth - data.width - 5;
}
}
isHiddenActionsMenu.value = false;
})
.exec();
} else {
// Handling the situation where the native Vue menu is lower than the screen
const rect = actionsMenuDomRef.value?.getBoundingClientRect();
// The PC side sets the position of actionsMenu according to the position of the mouse click, otherwise the default value of 167px is used
if (isPC && typeof props.actionsMenuPosition.left !== 'undefined') {
_actionsMenuPosition.value.left = props.actionsMenuPosition.left;
}
if (rect && rect.bottom > window.innerHeight) {
_actionsMenuPosition.value.top
= props.actionsMenuPosition.top
- (props.actionsMenuPosition.conversationHeight || 0)
- rect.height;
}
isHiddenActionsMenu.value = false;
}
});
}
const handleItem = (params: { name: string }) => {
const { name } = params;
const conversationModel = currentConversation;
if (!name || !conversationModel || !conversationModel.conversationID) {
return;
}
switch (name) {
case CONV_OPERATION.DELETE:
conversationModel?.deleteConversation();
break;
case CONV_OPERATION.ISPINNED:
conversationModel?.pinConversation();
break;
case CONV_OPERATION.DISPINNED:
conversationModel?.pinConversation();
break;
case CONV_OPERATION.MUTE:
conversationModel?.muteConversation();
break;
case CONV_OPERATION.NOTMUTE:
conversationModel?.muteConversation();
break;
}
emits('closeConversationActionMenu');
};
const deleteConversation = () => {
isShowDeleteConversationDialog.value = true;
};
const updateShowDeleteConversationDialog = (isShow: boolean) => {
if (!isShow) {
emits('closeConversationActionMenu');
}
isShowDeleteConversationDialog.value = isShow;
};
</script>
<style scoped lang="scss">
.cancel-hidden {
opacity: 1 !important;
}
.actions-menu {
position: absolute;
left: 164px;
border-radius: 8px;
border: 1px solid #e0e0e0;
box-shadow: 0 -4px 12px 0 rgba(0, 0, 0, 0.06);
background-color: #fff;
overflow: hidden;
opacity: 0;
.actions-menu-item {
cursor: pointer;
padding: 10px 20px;
font-size: 12px;
word-break: keep-all;
}
&.actions-menu-pc .actions-menu-item:hover {
background-color: #eee;
}
}
</style>

View File

@@ -0,0 +1,3 @@
import ConversationHeader from './index.vue';
export default ConversationHeader;

View File

@@ -0,0 +1,96 @@
<template>
<div
:ref="convHeaderRef"
class="tui-conversation-header"
>
<ul
v-if="menuList.length > 0"
class="list"
>
<li
v-for="(item, index) in menuList"
:key="index"
class="list-item"
>
<main
class="tui-conversation-header-item"
@click.stop="handleMenu(item)"
>
<Icon
v-if="item.icon && !item.data.children"
class="tui-conversation-header-item-icon"
:file="item.icon"
/>
<i
v-else
class="plus"
/>
<h1 class="tui-conversation-header-item-title">
{{ item.text }}
</h1>
</main>
</li>
</ul>
<ul
v-if="showChildren.length > 0"
class="tui-conversation-header-children list"
>
<li
v-for="(childrenItem, childrenIndex) in showChildren"
:key="childrenIndex"
class="list-item"
>
<main
class="tui-conversation-header-item"
@click="handleMenu(childrenItem)"
>
<Icon
v-if="childrenItem.icon"
class="tui-conversation-header-item-icon"
:file="childrenItem.icon"
/>
<h1 class="tui-conversation-header-item-title">
{{ childrenItem.text }}
</h1>
</main>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from '../../../adapter-vue';
import Icon from '../../common/Icon.vue';
import Server, { IMenuItem } from './server';
const showChildren = ref<Array<IMenuItem>>([]);
const convHeaderRef = ref<HTMLElement | undefined>();
const menuList = computed(() => {
return Server.getInstance().getMenu();
});
onMounted(() => {
showChildren.value = [];
});
const handleMenu = (item: IMenuItem) => {
const { data: { children }, listener = { onClicked: () => {} } } = item;
if (children) {
showChildren.value = showChildren.value.length > 0 ? [] : children;
} else {
listener.onClicked(item);
closeChildren();
}
};
const closeChildren = () => {
showChildren.value = [];
};
defineExpose({
closeChildren,
});
</script>
<style lang="scss" scoped src="../style/index.scss"></style>

View File

@@ -0,0 +1,78 @@
import TUICore, { TUIConstants } from '@tencentcloud/tui-core';
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
import { isPC } from '../../../utils/env';
import createGroup from '../../../assets/icon/start-group.svg';
import C2C from '../../../assets/icon/icon-c2c.svg';
import { CONV_CREATE_TYPE } from '../../../constant';
export interface IMenuItem {
icon?: string;
text: string;
data: {
name: string;
children?: any[];
};
listener?: {
onClicked: (...args: any[]) => void;
};
}
export default class ConversationHeaderServer {
static instance: ConversationHeaderServer;
static getInstance(): ConversationHeaderServer {
if (!ConversationHeaderServer.instance) {
ConversationHeaderServer.instance = new ConversationHeaderServer();
}
return ConversationHeaderServer.instance;
}
public getMenu(): any[] {
const list = this.generateMenuList();
if (!isPC && list.length > 0) {
return [{
text: TUITranslateService.t('TUIConversation.发起会话'),
data: {
name: 'all',
children: list,
},
}];
}
return list;
}
private generateMenuList(): any[] {
const list = [
{
icon: C2C,
text: TUITranslateService.t('TUIConversation.发起单聊'),
data: {
name: CONV_CREATE_TYPE.TYPEC2C,
},
listener: {
onClicked: this.createConversation.bind(this),
},
},
{
icon: createGroup,
text: TUITranslateService.t('TUIConversation.发起群聊'),
data: {
name: CONV_CREATE_TYPE.TYPEGROUP,
},
listener: {
onClicked: this.createConversation.bind(this),
},
},
];
return list;
}
private createConversation(item: IMenuItem) {
// Create a conversation and notify conversationServer via TUICore.callService
TUICore.callService({
serviceName: TUIConstants.TUIConversation.SERVICE.NAME,
method: TUIConstants.TUIConversation.SERVICE.METHOD.CREATE_CONVERSATION,
params: item,
});
}
}

View File

@@ -0,0 +1,310 @@
<template>
<div
ref="conversationListInnerDomRef"
class="tui-conversation-list"
>
<ActionsMenu
v-if="isShowOverlay"
:selectedConversation="currentConversation"
:actionsMenuPosition="actionsMenuPosition"
:selectedConversationDomRect="currentConversationDomRect"
@closeConversationActionMenu="closeConversationActionMenu"
/>
<div
v-for="(conversation, index) in conversationList"
:id="`convlistitem-${index}`"
:key="index"
:class="[
'tui-conversation-content',
isMobile && 'tui-conversation-content-h5 disable-select',
]"
>
<div
:class="[
isPC && 'isPC',
'tui-conversation-item',
currentConversationID === conversation.conversationID &&
'tui-conversation-item-selected',
conversation.isPinned && 'tui-conversation-item-pinned',
]"
@click="enterConversationChat(conversation.conversationID)"
@longpress="showConversationActionMenu($event, conversation, index)"
@contextmenu="showConversationActionMenu($event, conversation, index, true)"
>
<aside class="left">
<Avatar
useSkeletonAnimation
:url="conversation.getAvatar()"
size="30px"
/>
<div
v-if="userOnlineStatusMap && isShowUserOnlineStatus(conversation)"
:class="[
'online-status',
Object.keys(userOnlineStatusMap).length > 0 &&
Object.keys(userOnlineStatusMap).includes(
conversation.userProfile.userID
) &&
userOnlineStatusMap[conversation.userProfile.userID]
.statusType === 1
? 'online-status-online'
: 'online-status-offline',
]"
/>
<span
v-if="conversation.unreadCount > 0 && !conversation.isMuted"
class="num"
>
{{
conversation.unreadCount > 99 ? "99+" : conversation.unreadCount
}}
</span>
<span
v-if="conversation.unreadCount > 0 && conversation.isMuted"
class="num-notify"
/>
</aside>
<div class="content">
<div class="content-header">
<label class="content-header-label">
<p class="name">{{ conversation.getShowName() }}</p>
</label>
<div class="middle-box">
<span
v-if="conversation.draftText && conversation.conversationID !== currentConversationID"
class="middle-box-draft"
>{{ TUITranslateService.t('TUIChat.[草稿]') }}</span>
<span
v-else-if="
conversation.type === 'GROUP' &&
conversation.groupAtInfoList &&
conversation.groupAtInfoList.length > 0
"
class="middle-box-at"
>{{ conversation.getGroupAtInfo() }}</span>
<div class="middle-box-content">
{{ conversation.getLastMessage("text") }}
</div>
</div>
</div>
<div class="content-footer">
<span class="time">{{ conversation.getLastMessage("time") }}</span>
<Icon
v-if="conversation.isMuted"
:file="muteIcon"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
interface IUserStatus {
statusType: number;
customStatus: string;
}
interface IUserStatusMap {
[userID: string]: IUserStatus;
}
import { ref, onMounted, onUnmounted } from '../../../adapter-vue';
import TUIChatEngine, {
TUIStore,
StoreName,
TUIConversationService,
TUITranslateService,
IConversationModel,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal, isIOS, addLongPressListener } from '@tencentcloud/universal-api';
import Icon from '../../common/Icon.vue';
import Avatar from '../../common/Avatar/index.vue';
import ActionsMenu from '../actions-menu/index.vue';
import muteIcon from '../../../assets/icon/mute.svg';
import { isPC, isH5, isUniFrameWork, isMobile } from '../../../utils/env';
const emits = defineEmits(['handleSwitchConversation', 'getPassingRef']);
const currentConversation = ref<IConversationModel>();
const currentConversationID = ref<string>();
const currentConversationDomRect = ref<DOMRect>();
const isShowOverlay = ref<boolean>(false);
const conversationList = ref<IConversationModel[]>([]);
const conversationListDomRef = ref<HTMLElement | undefined>();
const conversationListInnerDomRef = ref<HTMLElement | undefined>();
const actionsMenuPosition = ref<{
top: number;
left: number | undefined;
conversationHeight: number | undefined;
}>({
top: 0,
left: undefined,
conversationHeight: undefined,
});
const displayOnlineStatus = ref(false);
const userOnlineStatusMap = ref<IUserStatusMap>();
let lastestOpenActionsMenuTime: number | null = null;
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
conversationList: onConversationListUpdated,
currentConversation: onCurrentConversationUpdated,
});
TUIStore.watch(StoreName.USER, {
displayOnlineStatus: onDisplayOnlineStatusUpdated,
userStatusList: onUserStatusListUpdated,
});
if (!isUniFrameWork && isIOS && !isPC) {
addLongPressHandler();
}
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
conversationList: onConversationListUpdated,
currentConversation: onCurrentConversationUpdated,
});
TUIStore.unwatch(StoreName.USER, {
displayOnlineStatus: onDisplayOnlineStatusUpdated,
userStatusList: onUserStatusListUpdated,
});
});
const isShowUserOnlineStatus = (conversation: IConversationModel): boolean => {
return (
displayOnlineStatus.value
&& conversation.type === TUIChatEngine.TYPES.CONV_C2C
);
};
const showConversationActionMenu = (
event: Event,
conversation: IConversationModel,
index: number,
isContextMenuEvent?: boolean,
) => {
if (isContextMenuEvent) {
event.preventDefault();
if (isUniFrameWork) {
return;
}
}
currentConversation.value = conversation;
lastestOpenActionsMenuTime = Date.now();
getActionsMenuPosition(event, index);
};
const closeConversationActionMenu = () => {
// Prevent continuous triggering of overlay tap events
if (
lastestOpenActionsMenuTime
&& Date.now() - lastestOpenActionsMenuTime > 300
) {
currentConversation.value = undefined;
isShowOverlay.value = false;
}
};
const getActionsMenuPosition = (event: Event, index: number) => {
if (isUniFrameWork) {
if (typeof conversationListDomRef.value === 'undefined') {
emits('getPassingRef', conversationListDomRef);
}
const query = TUIGlobal?.createSelectorQuery().in(conversationListDomRef.value);
query.select(`#convlistitem-${index}`).boundingClientRect((data) => {
if (data) {
actionsMenuPosition.value = {
// The uni-page-head of uni-h5 is not considered a member of the viewport, so the height of the head is manually increased.
top: data.bottom + (isH5 ? 44 : 0),
// @ts-expect-error in uniapp event has touches property
left: event.touches[0].pageX,
conversationHeight: data.height,
};
isShowOverlay.value = true;
}
}).exec();
} else {
const rect = ((event.currentTarget || event.target) as HTMLElement)?.getBoundingClientRect() || {};
if (rect) {
actionsMenuPosition.value = {
top: rect.bottom,
left: isPC ? (event as MouseEvent).clientX : undefined,
conversationHeight: rect.height,
};
}
isShowOverlay.value = true;
}
};
const enterConversationChat = (conversationID: string) => {
emits('handleSwitchConversation', conversationID);
TUIConversationService.switchConversation(conversationID);
};
function addLongPressHandler() {
if (!conversationListInnerDomRef.value) {
return;
}
addLongPressListener({
element: conversationListInnerDomRef.value,
onLongPress: (event, target) => {
const index = (Array.from(conversationListInnerDomRef.value!.children) as HTMLElement[]).indexOf(target!);
showConversationActionMenu(event, conversationList.value[index], index);
},
options: {
eventDelegation: {
subSelector: '.tui-conversation-content',
},
},
});
}
function onCurrentConversationUpdated(conversation: IConversationModel) {
currentConversation.value = conversation;
}
function onConversationListUpdated(list: IConversationModel[]) {
conversationList.value = list;
}
function onCurrentConversationIDUpdated(id: string) {
currentConversationID.value = id;
}
function onDisplayOnlineStatusUpdated(status: boolean) {
displayOnlineStatus.value = status;
}
function onUserStatusListUpdated(list: Map<string, IUserStatus>) {
if (list.size !== 0) {
userOnlineStatusMap.value = [...list.entries()].reduce(
(obj, [key, value]) => {
obj[key] = value;
return obj;
},
{} as IUserStatusMap,
);
}
}
// Expose to the parent component and close actionsMenu when a sliding event is detected
defineExpose({ closeChildren: closeConversationActionMenu });
</script>
<style lang="scss" scoped src="./style/index.scss"></style>
<style lang="scss" scoped>
.disable-select {
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
}
</style>

View File

@@ -0,0 +1,77 @@
.tui-conversation {
&-item {
&-pinned {
background: #eef0f3;
}
&-selected,
&-toggled {
background: rgba(0, 110, 255, 0.1);
}
.left {
.num {
background: red;
color: #fff;
&-notify {
background: red;
color: #fff;
}
}
}
.content-header {
&-label {
color: #000;
}
.name {
font-weight: 400;
letter-spacing: 0;
color: #000;
}
}
.middle-box {
&-at, &-draft {
color: #fb5059 !important;
font-family: PingFangSC-Regular;
font-weight: 400;
}
&-content {
font-weight: 400;
color: #999;
letter-spacing: 0;
}
}
.content-footer {
color: #999;
.time {
color: #bbb;
}
}
}
&-content {
.dialog {
background: #fff;
&-item {
background: #fff;
border: 1px solid #e0e0e0;
box-shadow: 0 -4px 12px 0 rgba(0, 0, 0, 0.06);
}
.conversation-options {
font-family: PingFangSC-Regular;
font-weight: 400;
color: #4f4f4f;
letter-spacing: 0;
}
}
}
}

View File

@@ -0,0 +1,43 @@
.tui-conversation-list-h5 {
.tui-conversation-content {
.dialog {
left: auto;
right: 18px;
padding: 0;
.conversation-options {
padding: 12px;
font-size: 16px;
}
&-item-up {
top: -70px;
}
}
.tui-conversation-item {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.content {
.name {
font-size: 16px;
}
.middle-box {
p {
font-size: 14px;
}
}
}
.time {
font-size: 14px;
}
}
}
}

View File

@@ -0,0 +1,4 @@
@import '../../../../assets/styles/common';
@import './color';
@import './web';
@import './h5';

View File

@@ -0,0 +1,186 @@
.tui-conversation-list {
font-family: PingFangSC-Regular;
font-weight: 400;
letter-spacing: 0;
flex: 1;
overflow: auto;
}
.tui-conversation {
&-item {
padding: 12px;
display: flex;
align-items: center;
cursor: pointer;
box-sizing: border-box;
overflow: hidden;
.left {
position: relative;
width: 36px;
height: 36px;
.num {
position: absolute;
display: inline-block;
right: 0;
top: -5px;
min-width: 10px;
width: fit-content;
padding: 0 2.5px;
height: 15px;
font-size: 10px;
text-align: center;
line-height: 15px;
border-radius: 7.5px;
}
.num-notify {
position: absolute;
display: inline-block;
right: 2px;
top: -2px;
width: 6px;
height: 6px;
font-size: 10px;
text-align: center;
line-height: 15px;
border-radius: 65%;
}
.avatar {
width: 30px;
height: 30px;
border-radius: 5px;
}
.online-status {
box-sizing: border-box;
position: absolute;
width: 10px;
height: 10px;
left: 24px;
top: 22px;
border: 2px solid #fff;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
border-radius: 50%;
&-online {
background: #29cc85;
}
&-offline {
background: #a4a4a4;
}
}
}
.content-footer {
line-height: 16px;
display: flex;
flex-direction: column;
.time {
font-size: 12px;
line-height: 16px;
display: inline-block;
white-space: nowrap;
}
}
.content {
display: flex;
flex: 1;
padding-left: 8px;
justify-content: space-between;
box-sizing: border-box;
overflow: hidden;
.content-footer {
align-items: flex-end;
.icon {
display: inline-block;
width: 16px;
height: 16px;
margin: 0;
}
}
}
.content-header {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
&-label {
flex: 1;
font-size: 14px;
}
.name {
width: 110px;
letter-spacing: 0;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.middle-box {
flex: 1;
display: flex;
align-items: center;
&-at,
&-draft {
font-size: 12px;
}
&-content {
flex: 1;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 16px;
}
}
}
&-content {
position: relative;
.tui-conversation-item:hover {
background: rgba(0, 110, 255, 0.1);
}
.dialog {
position: absolute;
z-index: 5;
padding: 2px 20px;
cursor: pointer;
&-item {
top: 30px;
left: 164px;
border-radius: 8px;
}
.conversation-options {
padding: 5px 0;
height: 17px;
font-size: 12px;
line-height: 17px;
}
&-item-up {
top: -50px;
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
import ConversationNetwork from './index.vue';
export default ConversationNetwork;

View File

@@ -0,0 +1,33 @@
<template>
<div
v-if="isNotNetwork"
class="network"
>
<i class="icon icon-error">!</i>
<p class="network-content">
{{
TUITranslateService.t("TUIConversation.网络异常,请您检查网络设置")
}}
</p>
</div>
</template>
<script lang="ts" setup>
import TUIChatEngine, {
TUIStore,
StoreName,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import {
ref,
} from '../../../adapter-vue';
const isNotNetwork = ref(false);
TUIStore.watch(StoreName.USER, {
netStateChange: (value: string) => {
isNotNetwork.value = (value === TUIChatEngine.TYPES.NET_STATE_DISCONNECTED);
},
});
</script>
<style lang="scss" scoped src="../style/index.scss"></style>

View File

@@ -0,0 +1,2 @@
import { TUIChatKit } from '../../index.ts';
TUIChatKit?.init(); // Add optional chaining operator to fix sample main package integration errors

View File

@@ -0,0 +1,5 @@
import TUIConversation from "./index.vue";
import TUIConversationServer from "./server";
new TUIConversationServer();
export default TUIConversation;

View File

@@ -0,0 +1,110 @@
<template>
<div
class="tui-conversation"
@click="handleClickConv"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<TUISearch searchType="global" />
<ConversationHeader
v-if="isShowConversationHeader"
ref="headerRef"
/>
<ConversationNetwork />
<ConversationList
ref="conversationListDomRef"
class="tui-conversation-list"
@handleSwitchConversation="handleSwitchConversation"
@getPassingRef="getPassingRef"
/>
</div>
</template>
<script lang="ts" setup>
import { TUIStore, StoreName } from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { ref } from '../../adapter-vue';
import TUISearch from '../TUISearch/index.vue';
import ConversationList from './conversation-list/index.vue';
import ConversationHeader from './conversation-header/index.vue';
import ConversationNetwork from './conversation-network/index.vue';
import { onHide } from '@dcloudio/uni-app';
// #ifdef MP-WEIXIN
// uniapp packaged mini-programs are integrated by default, and the default initialization entry file is imported here
// TUIChatKit init needs to be encapsulated because uni vue2 will report an error when compiling H5 directly through conditional compilation
import './entry.ts';
// #endif
const emits = defineEmits(['handleSwitchConversation']);
const totalUnreadCount = ref(0);
const headerRef = ref<typeof ConversationHeader>();
const conversationListDomRef = ref<typeof ConversationList>();
const touchX = ref<number>(0);
const touchY = ref<number>(0);
const isShowConversationHeader = ref<boolean>(true);
TUIStore.watch(StoreName.CONV, {
totalUnreadCount: (count: number) => {
totalUnreadCount.value = count;
},
});
TUIStore.watch(StoreName.CUSTOM, {
isShowConversationHeader: (showStatus: boolean) => {
isShowConversationHeader.value = showStatus !== false;
},
});
const handleSwitchConversation = (conversationID: string) => {
TUIGlobal?.navigateTo({
url: '/TUIKit/components/TUIChat/index',
});
emits('handleSwitchConversation', conversationID);
};
const closeChildren = () => {
headerRef?.value?.closeChildren();
conversationListDomRef?.value?.closeChildren();
};
const handleClickConv = () => {
closeChildren();
};
onHide(closeChildren);
const handleTouchStart = (e: any) => {
touchX.value = e.changedTouches[0].clientX;
touchY.value = e.changedTouches[0].clientY;
};
const handleTouchEnd = (e: any) => {
const x = e.changedTouches[0].clientX;
const y = e.changedTouches[0].clientY;
let turn = '';
if (x - touchX.value > 50 && Math.abs(y - touchY.value) < 50) {
// Swipe right
turn = 'right';
} else if (x - touchX.value < -50 && Math.abs(y - touchY.value) < 50) {
// Swipe left
turn = 'left';
}
if (y - touchY.value > 50 && Math.abs(x - touchX.value) < 50) {
// Swipe down
turn = 'down';
} else if (y - touchY.value < -50 && Math.abs(x - touchX.value) < 50) {
// Swipe up
turn = 'up';
}
// Operate according to the direction
if (turn === 'down' || turn === 'up') {
closeChildren();
}
};
const getPassingRef = (ref) => {
ref.value = conversationListDomRef.value;
};
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,163 @@
import TUICore, { TUIConstants } from '@tencentcloud/tui-core';
import {
TUITranslateService,
TUIConversationService,
TUIStore,
StoreName,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { CONV_CREATE_TYPE } from '../../constant';
import { isUniFrameWork } from '../../utils/env';
import createGroupIcon from '../../assets/icon/start-group.svg';
import createC2CIcon from '../../assets/icon/icon-c2c.svg';
import { enableSampleTaskStatus } from '../../utils/enableSampleTaskStatus';
export default class TUIConversationServer {
static instance: TUIConversationServer;
private onCallParamsMap: Map<string, any>;
private onCallCallbackMap: Map<string, () => void>;
public constants: typeof TUIConstants;
constructor() {
TUICore.registerService(TUIConstants.TUIConversation.SERVICE.NAME, this);
TUICore.registerExtension(TUIConstants.TUISearch.EXTENSION.SEARCH_MORE.EXT_ID, this);
this.onCallParamsMap = new Map();
this.onCallCallbackMap = new Map();
this.constants = TUIConstants;
}
static getInstance(): TUIConversationServer {
if (!TUIConversationServer.instance) {
TUIConversationServer.instance = new TUIConversationServer();
}
return TUIConversationServer.instance;
}
public getOnCallParams(method: string): any {
return this.onCallParamsMap.get(method);
}
public getOnCallCallback(method: string): (() => void) | undefined {
return this.onCallCallbackMap.get(method);
}
public onCall(method: string, params: Record<string, any>, callback: () => void): void {
this.onCallParamsMap.set(method, params);
this.onCallCallbackMap.set(method, callback);
switch (method) {
case TUIConstants.TUIConversation.SERVICE.METHOD.CREATE_CONVERSATION:
this.createConversation(params);
break;
case TUIConstants.TUIConversation.SERVICE.METHOD.HIDE_CONVERSATION_HEADER:
this.hideConversationHeader();
break;
}
}
public onGetExtension(extensionID: string) {
if (extensionID === TUIConstants.TUISearch.EXTENSION.SEARCH_MORE.EXT_ID) {
const list = [
{
weight: 100,
icon: createC2CIcon,
text: TUITranslateService.t('TUIConversation.发起单聊'),
data: {
name: CONV_CREATE_TYPE.TYPEC2C,
},
listener: {
onClicked: this.createConversation.bind(this),
},
},
{
weight: 100,
icon: createGroupIcon,
text: TUITranslateService.t('TUIConversation.发起群聊'),
data: {
name: CONV_CREATE_TYPE.TYPEGROUP,
},
listener: {
onClicked: this.createConversation.bind(this),
},
},
];
return list;
}
}
private createConversation(item: any) {
// Tell TUIContact to call the SelectFriend component to select a friend
TUICore.callService({
serviceName: TUIConstants.TUIContact.SERVICE.NAME,
method: TUIConstants.TUIContact.SERVICE.METHOD.SELECT_FRIEND,
params: {
title: item.text,
isRadio: item.data.name !== CONV_CREATE_TYPE.TYPEGROUP,
isNeedSearch: !TUIStore.getData(StoreName.APP, 'isOfficial'),
},
callback: async (memberList: any[]) => {
if (!memberList || memberList.length === 0) {
// Return to the previous page
return this.routerForward(null);
}
if (item.data.name === CONV_CREATE_TYPE.TYPEGROUP) {
// After selecting members, if you want to create a group chat, you need to create a group
this.createGroup(memberList);
} else {
const { userID } = memberList[0];
// Generate Conversation
await this.generateConversation(`C2C${userID}`);
this.routerForward(`C2C${userID}`);
}
},
});
}
private createGroup(memberList: any[]) {
TUICore.callService({
serviceName: TUIConstants.TUIGroup.SERVICE.NAME,
method: TUIConstants.TUIGroup.SERVICE.METHOD.CREATE_GROUP,
params: {
title: TUITranslateService.t('TUIConversation.发起群聊'),
memberList,
},
callback: async (group: any) => {
let conversationID: string | null = null;
if (group) {
const { groupID } = group;
await this.generateConversation(`GROUP${groupID}`);
conversationID = `GROUP${groupID}`;
}
this.routerForward(conversationID);
},
});
}
private async routerForward(conversationID: string | null): Promise<void> {
if (isUniFrameWork) {
await TUIGlobal?.reLaunch({
url: '/TUIKit/components/TUIConversation/index',
});
if (conversationID) {
TUIGlobal?.navigateTo({
url: '/TUIKit/components/TUIChat/index',
});
}
}
}
private generateConversation(conversationID: string) {
TUIConversationService.switchConversation(conversationID)
.then(() => {
if (conversationID.startsWith('GROUP')) {
enableSampleTaskStatus('groupChat');
}
console.warn('打开会话成功');
})
.catch((err: any) => {
console.warn('打开会话失败', err.code, err.msg);
});
}
private hideConversationHeader = () => {
TUIStore.update(StoreName.CUSTOM, 'isShowConversationHeader', false);
};
}

View File

@@ -0,0 +1,12 @@
.tui-conversation {
background: #fff;
}
.network {
&-content {
font-family: PingFangSC-Regular;
font-weight: 400;
color: #e54545;
letter-spacing: 0;
}
}

View File

@@ -0,0 +1,3 @@
@import '../../../assets/styles/common';
@import './web';
@import './color';

View File

@@ -0,0 +1,107 @@
.tui-conversation {
width: 100%;
height: 100%;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
&-list {
overflow: auto;
}
}
.tui-conversation-header {
width: 100%;
position: relative;
.list {
display: flex;
align-items: center;
border-bottom: 1px solid #F4F5F9;
padding: 7px 0;
&-item {
flex: 1;
display: flex;
position: relative;
padding: 7px;
cursor: pointer;
user-select: none;
}
}
&-item{
flex: 1;
display: flex;
align-items: center;
&-title {
padding: 0 8px;
font-size: 16px;
font-weight: normal;
}
}
&-children {
position: absolute;
top: 100%;
z-index: 3;
padding: 7px 9px;
border-bottom: none;
background-color: #fff;
box-shadow: 0 3px 7px 0 #0003;
flex-direction: column;
}
}
.network {
padding: 0 12px;
display: flex;
align-items: center;
.icon-error{
display: flex;
justify-content: center;
align-items: center;
width: 15px;
height: 15px;
border-radius: 15px;
background: red;
color: #fff;
font-style: normal;
}
&-content {
padding: 5px;
font-size: 12px;
line-height: 22px;
}
}
.plus {
display: inline-block;
width: 30px;
height: 30px;
position: relative;
}
.plus::before,
.plus::after {
content: "";
position: absolute;
background-color: #232832;
border-radius: 0.5px;
width: 1px;
height: 14px;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
}
.plus::after {
transform: rotate(90deg);
width: 0.5px;
}