添加聊天

This commit is contained in:
bobobobo
2025-12-30 23:28:59 +08:00
parent d0cf491201
commit 2294b3b76e
450 changed files with 37066 additions and 96 deletions

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<ToolbarItemContainer
:iconFile="toolbarConfig.icon"
:title="toolbarConfig.title"
:iconWidth="toolbarConfig.iconWidth"
:iconHeight="toolbarConfig.iconHeight"
:needDialog="false"
@onIconClick="handleIconClick"
>
<div
v-if="!isUniFrameWork"
:class="['image-upload', !isPC && 'image-upload-h5']"
>
<input
ref="inputRef"
title="图片"
type="file"
data-type="image"
accept="image/gif,image/jpeg,image/jpg,image/png,image/bmp,image/webp"
@change="handleWebFileChange"
>
</div>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { useUpload, UploadType } from '../uploadToolkit';
const {
inputRef,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
} = useUpload(UploadType.ALBUM);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
</style>

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<ToolbarItemContainer
:iconFile="toolbarConfig.icon"
:title="toolbarConfig.title"
:iconWidth="toolbarConfig.iconWidth"
:iconHeight="toolbarConfig.iconHeight"
:needDialog="false"
@onIconClick="handleIconClick"
>
<div
v-if="!isUniFrameWork"
:class="['image-upload', !isPC && 'image-upload-h5']"
>
<input
ref="inputRef"
title="视频"
type="file"
data-type="video"
accept="video/*"
@change="handleWebFileChange"
>
</div>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { useUpload, UploadType } from '../uploadToolkit';
const {
inputRef,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
} = useUpload(UploadType.CAMERA);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
</style>

View File

@@ -0,0 +1,2 @@
import Words from "./index.vue";
export default Words;

View File

@@ -0,0 +1,94 @@
<template>
<div class="tui-clear-history-message">
<ToolbarItemContainer
:iconFile="clearIcon"
title="清空历史消息"
:iconWidth="isUniFrameWork ? '32px' : '20px'"
:iconHeight="isUniFrameWork ? '25px' : '18px'"
:needDialog="false"
@onIconClick="onIconClick"
/>
<Dialog
:show="dialogShow"
:isH5="!isPC"
:center="true"
:title="TUITranslateService.t('TUIChat.确认要清空当前的聊天记录吗?')"
:isHeaderShow="isPC"
footerClass="clear-history-footer"
@submit="clearHistoryMessage()"
@update:show="(e) => (dialogShow = e)">
<p class="clear-history-tip">{{ TUITranslateService.t('TUIChat.清空后无法恢复') }}</p>
</Dialog>
</div>
</template>
<script lang="ts" setup>
import {
TUIChatService,
TUIStore,
StoreName,
IConversationModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine-lite';
import { onMounted, onUnmounted, ref } from '../../../../adapter-vue';
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import clearIconLight from '../../../../assets/icon/clear-history.svg';
import clearIconDark from '../../../../assets/icon/clear-history.svg';
import { isUniFrameWork } from '../../../../utils/env';
import TUIChatConfig from '../../config';
import AiRobotManager from '../../aiRobotManager';
import Dialog from "../../../common/Dialog";
import { isPC } from '../../../../utils/env';
const clearIcon = TUIChatConfig.getTheme() === 'dark' ? clearIconDark : clearIconLight;
const currentConversation = ref<IConversationModel>();
const dialogShow = ref(false);
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdate,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdate,
});
});
const onCurrentConversationUpdate = (conversation: IConversationModel) => {
currentConversation.value = conversation;
}
const onIconClick = async () => {
dialogShow.value = true;
};
const clearHistoryMessage = async () => {
dialogShow.value = false;
const { conversationID } = currentConversation.value as IConversationModel;
await AiRobotManager.sendBreakConversation(conversationID);
TUIChatService.clearHistoryMessage(conversationID);
}
</script>
<style lang="scss">
.clear-history-tip {
width: 100%;
font-family: PingFang SC;
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: justify;
color: #4F586B;
}
.clear-history-footer {
justify-content: center !important;
.btn {
border-radius: 16px !important;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div
ref="emojiPickerDialog"
:class="{
'emoji-picker': true,
'emoji-picker-h5': !isPC
}"
>
<ul
ref="emojiPickerListRef"
:class="['emoji-picker-list', !isPC && 'emoji-picker-h5-list']"
>
<li
v-for="(childrenItem, childrenIndex) in currentEmojiList"
:key="childrenIndex"
class="emoji-picker-list-item"
@click="select(childrenItem, childrenIndex)"
>
<img
v-if="currentTabItem.type === EMOJI_TYPE.BASIC"
class="emoji"
:src="currentTabItem.url + BASIC_EMOJI_URL_MAPPING[childrenItem]"
>
<img
v-else-if="currentTabItem.type === EMOJI_TYPE.BIG"
class="emoji-big"
:src="currentTabItem.url + childrenItem + '@2x.png'"
>
<img
v-else
class="emoji-custom emoji-big"
:src="currentTabItem.url + childrenItem"
>
</li>
</ul>
<ul class="emoji-picker-tab">
<li
v-for="(item, index) in list"
:key="index"
class="emoji-picker-tab-item"
@click="toggleEmojiTab(index)"
>
<Icon
v-if="item.type === EMOJI_TYPE.BASIC"
class="icon"
:file="faceIcon"
/>
<img
v-else-if="item.type === EMOJI_TYPE.BIG"
class="icon-big"
:src="item.url + item.list[0] + '@2x.png'"
>
<img
v-else
class="icon-custom icon-big"
:src="item.url + item.list[0]"
>
</li>
<li
v-if="isUniFrameWork"
class="send-btn"
@click="sendMessage"
>
发送
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from '../../../../adapter-vue';
import {
TUIChatService,
TUIStore,
StoreName,
IConversationModel,
SendMessageParams,
} from '@tencentcloud/chat-uikit-engine-lite';
import Icon from '../../../common/Icon.vue';
import faceIconLight from '../../../../assets/icon/face-light.svg';
import faceIconDark from '../../../../assets/icon/face-dark.svg';
import { EMOJI_TYPE } from '.././../../../constant';
import { isPC, isUniFrameWork } from '../../../../utils/env';
import { IEmojiGroupList, IEmojiGroup } from '../../../../interface';
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
import { EMOJI_GROUP_LIST, BASIC_EMOJI_URL_MAPPING, convertKeyToEmojiName } from '../../emoji-config';
import TUIChatConfig from '../../config';
const faceIcon = TUIChatConfig.getTheme() === 'dark' ? faceIconDark : faceIconLight;
const emits = defineEmits(['insertEmoji', 'onClose', 'sendMessage']);
const currentTabIndex = ref<number>(0);
const currentConversation = ref();
const emojiPickerDialog = ref();
const emojiPickerListRef = ref();
const featureConfig = TUIChatConfig.getFeatureConfig();
const list = ref<IEmojiGroupList>(initEmojiList());
const currentTabItem = ref<IEmojiGroup>(list?.value[0]);
const currentEmojiList = ref<string[]>(list?.value[0]?.list);
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdate,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdate,
});
});
const toggleEmojiTab = (index: number) => {
currentTabIndex.value = index;
currentTabItem.value = list?.value[index];
currentEmojiList.value = list?.value[index]?.list;
// web & h5 side scroll to top
if (!isUniFrameWork) {
emojiPickerListRef?.value && (emojiPickerListRef.value.scrollTop = 0);
}
};
const select = (item: any, index: number) => {
const options: any = {
emoji: { key: item, name: convertKeyToEmojiName(item) },
type: currentTabItem?.value?.type,
};
switch (currentTabItem?.value?.type) {
case EMOJI_TYPE.BASIC:
options.url = currentTabItem?.value?.url + BASIC_EMOJI_URL_MAPPING[item];
if (isUniFrameWork) {
uni.$emit('insert-emoji', options);
} else {
emits('insertEmoji', options);
}
break;
case EMOJI_TYPE.BIG:
sendFaceMessage(index, currentTabItem.value);
break;
case EMOJI_TYPE.CUSTOM:
sendFaceMessage(index, currentTabItem.value);
break;
default:
break;
}
isPC && emits('onClose');
};
const sendFaceMessage = (index: number, listItem: IEmojiGroup) => {
const options = {
to:
currentConversation?.value?.groupProfile?.groupID
|| currentConversation?.value?.userProfile?.userID,
conversationType: currentConversation?.value?.type,
payload: {
index: listItem.emojiGroupID,
data: listItem.list[index],
},
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
} as SendMessageParams;
TUIChatService.sendFaceMessage(options);
};
function sendMessage() {
uni.$emit('send-message-in-emoji-picker');
}
function onCurrentConversationUpdate(conversation: IConversationModel) {
currentConversation.value = conversation;
list.value = initEmojiList();
}
function initEmojiList() {
return EMOJI_GROUP_LIST.filter((item) => {
if (item.type === EMOJI_TYPE.BASIC) {
return featureConfig.InputEmoji;
}
if (item.type === EMOJI_TYPE.BIG) {
return featureConfig.InputStickers;
}
if (item.type === EMOJI_TYPE.CUSTOM) {
return featureConfig.InputStickers;
}
});
}
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

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

View File

@@ -0,0 +1,81 @@
<template>
<ToolbarItemContainer
ref="container"
:iconFile="faceIcon"
title="表情"
@onDialogShow="onDialogShow"
@onDialogClose="onDialogClose"
>
<EmojiPickerDialog
@insertEmoji="insertEmoji"
@sendMessage="sendMessage"
@onClose="onClose"
/>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import {
TUIStore,
StoreName,
IConversationModel,
} from '@tencentcloud/chat-uikit-engine-lite';
import { ref } from '../../../../adapter-vue';
import faceIconLight from '../../../../assets/icon/face-light.svg';
import faceIconDark from '../../../../assets/icon/face-dark.svg';
import EmojiPickerDialog from './emoji-picker-dialog.vue';
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { isH5 } from '../../../../utils/env';
import { ToolbarDisplayType } from '../../../../interface';
import TUIChatConfig from '../../config';
interface IEmits {
(e: 'sendMessage'): void;
(e: 'toggleComponent'): void;
(e: 'insertEmoji', emoji: any): void;
(e: 'dialogShowInH5', dialogRef: HTMLElement): void;
(e: 'dialogCloseInH5', dialogRef: HTMLElement): void;
(e: 'changeToolbarDisplayType', type: ToolbarDisplayType): void;
}
const faceIcon = TUIChatConfig.getTheme() === 'dark' ? faceIconDark : faceIconLight;
const emits = defineEmits<IEmits>();
const currentConversation = ref();
const container = ref<InstanceType<typeof ToolbarItemContainer>>();
TUIStore.watch(StoreName.CONV, {
currentConversation: (conversation: IConversationModel) => {
currentConversation.value = conversation;
},
});
const onDialogShow = (dialogRef: any) => {
if (!isH5) {
return;
}
emits('changeToolbarDisplayType', 'emojiPicker');
emits('dialogShowInH5', dialogRef.value);
};
const onDialogClose = (dialogRef: any) => {
if (!isH5) {
return;
}
emits('changeToolbarDisplayType', 'none');
emits('dialogCloseInH5', dialogRef.value);
};
const insertEmoji = (emojiObj) => {
emits('insertEmoji', emojiObj);
};
const sendMessage = () => {
emits('sendMessage');
};
const onClose = () => {
container.value?.toggleDialogDisplay(false);
};
defineExpose({
closeEmojiPicker: onClose,
});
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,25 @@
.emoji-picker-h5 {
width: 100%;
&-list {
justify-content: space-between;
}
&-list::after {
content: "";
display: block;
flex: 1 1 auto;
}
.send-btn {
width: 50px;
height: 30px;
background-color: #55C06A;
position: absolute;
right: 10px;
font-size: 16px;
color: #fff;
text-align: center;
line-height: 30px;
}
}

View File

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

View File

@@ -0,0 +1,55 @@
.emoji-picker {
width: 405px;
height: 300px;
display: flex;
flex-direction: column;
&-list {
flex: 1;
display: flex;
flex-wrap: wrap;
overflow-y: auto;
margin: 2px;
&::-webkit-scrollbar {
display: none;
}
&-item {
cursor: pointer;
padding: 5px;
.emoji {
width: 30px;
height: 30px;
}
.emoji-big {
width: 70px;
height: 70px;
}
}
}
&-tab {
display: flex;
align-items: center;
&-item {
padding: 0 10px;
cursor: pointer;
.icon {
margin: 10px;
width: 20px;
height: 20px;
&-big {
margin: 2px 0;
width: 30px;
height: 30px;
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,209 @@
<template>
<ToolbarItemContainer
ref="container"
:iconFile="evaluateIcon"
title="自定义消息"
:needBottomPopup="true"
:iconWidth="isUniFrameWork ? '26px' : '20px'"
:iconHeight="isUniFrameWork ? '26px' : '20px'"
@onDialogShow="onDialogShow"
@onDialogClose="onDialogClose"
>
<div :class="['evaluate', !isPC && 'evaluate-h5']">
<div :class="['evaluate-header', !isPC && 'evaluate-h5-header']">
<div
:class="[
'evaluate-header-content',
!isPC && 'evaluate-h5-header-content',
]"
>
{{ TUITranslateService.t("Evaluate.请对本次服务进行评价") }}
</div>
<div
v-if="!isPC"
:class="[
'evaluate-header-close',
!isPC && 'evaluate-h5-header-close',
]"
@click.stop="closeDialog"
>
{{ TUITranslateService.t("关闭") }}
</div>
</div>
<div :class="['evaluate-content', !isPC && 'evaluate-h5-content']">
<ul
:class="[
'evaluate-content-list',
!isPC && 'evaluate-h5-content-list',
]"
>
<li
v-for="(item, index) in starList"
:key="index"
:class="[
'evaluate-content-list-item',
!isPC && 'evaluate-h5-content-list-item',
]"
@click.stop="selectStar(index)"
>
<Icon
v-if="index <= currentStarIndex"
:file="starLightIcon"
:width="isPC ? '20px' : '30px'"
:height="isPC ? '20px' : '30px'"
/>
<Icon
v-else
:file="starIcon"
:width="isPC ? '20px' : '30px'"
:height="isPC ? '20px' : '30px'"
/>
</li>
</ul>
<textarea
v-model="comment"
:class="[
'evaluate-content-text',
!isPC && 'evaluate-h5-content-text',
]"
/>
<div
:class="[
'evaluate-content-button',
!isPC && 'evaluate-h5-content-button',
]"
>
<button
:class="['btn', isEvaluateValid ? 'btn-valid' : 'btn-invalid']"
@click="submitEvaluate"
>
{{ TUITranslateService.t("Evaluate.提交评价") }}
</button>
</div>
</div>
<div :class="['evaluate-adv', !isPC && 'evaluate-h5-adv']">
{{ TUITranslateService.t("Evaluate.服务评价工具") }}
{{ "(" + TUITranslateService.t("Evaluate.使用") }}
<a @click="openLink(Link.customMessage)">
{{ TUITranslateService.t(`Evaluate.${Link.customMessage.label}`) }}
</a>
{{ TUITranslateService.t("Evaluate.搭建") + ")" }}
</div>
</div>
</ToolbarItemContainer>
</template>
<script setup lang="ts">
import TUIChatEngine, {
TUITranslateService,
TUIStore,
StoreName,
IConversationModel,
TUIChatService,
SendMessageParams,
SendMessageOptions,
} from '@tencentcloud/chat-uikit-engine-lite';
import { ref, computed } from '../../../../adapter-vue';
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import custom from '../../../../assets/icon/custom.svg';
import Link from '../../../../utils/documentLink';
import Icon from '../../../common/Icon.vue';
import starIcon from '../../../../assets/icon/star.png';
import starLightIcon from '../../../../assets/icon/star-light.png';
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
import { isPC, isH5, isUniFrameWork } from '../../../../utils/env';
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
const evaluateIcon = custom;
const props = defineProps({
starTotal: {
type: Number,
default: 5,
},
});
const emits = defineEmits(['onDialogPopupShowOrHide']);
const container = ref();
const starList = ref<number>(props.starTotal);
const currentStarIndex = ref<number>(-1);
const comment = ref('');
const currentConversation = ref<IConversationModel>();
TUIStore.watch(StoreName.CONV, {
currentConversation: (conversation: IConversationModel) => {
currentConversation.value = conversation;
},
});
const isEvaluateValid = computed(() => comment.value.length || currentStarIndex.value >= 0);
const onDialogShow = () => {
emits('onDialogPopupShowOrHide', true);
};
const onDialogClose = () => {
resetEvaluate();
emits('onDialogPopupShowOrHide', false);
};
const openLink = () => {
if (isPC || isH5) {
window.open(Link?.customMessage?.url);
}
};
const closeDialog = () => {
container?.value?.toggleDialogDisplay(false);
};
const resetEvaluate = () => {
currentStarIndex.value = -1;
comment.value = '';
};
const selectStar = (starIndex?: any) => {
if (currentStarIndex.value === starIndex) {
currentStarIndex.value = currentStarIndex.value - 1;
} else {
currentStarIndex.value = starIndex;
}
};
const submitEvaluate = () => {
// The evaluate message must have at least one star or comment to be submitted.
if (currentStarIndex.value < 0 && !comment.value.length) {
return;
}
const payload = {
data: JSON.stringify({
businessID: CHAT_MSG_CUSTOM_TYPE.EVALUATE,
version: 1,
score: currentStarIndex.value + 1,
comment: comment.value,
}),
description: '对本次的服务评价',
extension: '对本次的服务评价',
};
const options = {
to:
currentConversation?.value?.groupProfile?.groupID
|| currentConversation?.value?.userProfile?.userID,
conversationType: currentConversation?.value?.type,
payload,
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
};
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
conversation: currentConversation.value,
payload: options.payload,
messageType: TUIChatEngine.TYPES.MSG_CUSTOM,
};
const sendMessageOptions: SendMessageOptions = {
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
};
TUIChatService.sendCustomMessage(options as SendMessageParams, sendMessageOptions);
// close dialog after submit evaluate
container?.value?.toggleDialogDisplay(false);
};
</script>
<style scoped lang="scss" src="./style/index.scss"></style>

View File

@@ -0,0 +1,57 @@
.evaluate {
background: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
&-header {
&-content {
font-weight: 500;
color: #1c1c1c;
}
}
&-adv {
font-weight: 500;
color: #999;
a {
color: #006eff;
}
}
&-content {
&-text {
background: #f8f8f8;
border: 1px solid #ececec;
}
&-list {
&-item {
font-weight: 400;
color: #50545c;
}
}
}
&-H5 {
&-main {
background: rgba(0, 0, 0, 0.5);
.evaluate-main-content {
background: #fff;
p {
a {
color: #3370ff;
}
}
.close {
font-family: PingFangSC-Regular;
font-weight: 400;
color: #3370ff;
letter-spacing: 0;
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
.evaluate-h5 {
position: static;
width: 100%;
height: fit-content;
border-radius: 0;
background: #fff;
padding: 23px !important;
box-sizing: border-box;
&-header {
display: flex;
justify-content: space-between;
&-content {
font-size: 18px;
}
&-close {
font-size: 18px;
line-height: 27px;
font-weight: 400;
color: #3370ff;
}
}
&-content {
order: 1;
&-list {
&-item {
width: 40px;
height: 24px;
text-align: center;
cursor: auto;
font-size: 12px;
}
}
&-text {
font-size: 16px;
width: 100%;
}
&-button {
width: 100%;
display: flex;
.btn {
flex: 1;
padding: 14px 0;
font-size: 18px;
cursor: auto;
}
}
}
&-adv {
font-size: 14px;
font-weight: normal;
text-align: left;
color: #000;
}
}

View File

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

View File

@@ -0,0 +1,93 @@
.evaluate {
position: absolute;
z-index: 5;
width: 315px;
top: -255px;
padding: 12px;
display: flex;
flex-direction: column;
border-radius: 8px;
background: url("https://web.sdk.qcloud.com/im/assets/images/login-background.png") no-repeat;
background-color: #fff;
background-size: cover;
background-position-x: 128px;
background-position-y: 77px;
user-select: none;
&-header {
&-content {
font-style: normal;
font-size: 12px;
line-height: 17px;
text-align: center;
}
}
&-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
&-list {
flex: 1;
display: flex;
&-item {
width: 24px;
height: 24px;
text-align: center;
cursor: pointer;
padding: 4px 0;
font-size: 12px;
padding-right: 15px;
&:last-child {
padding-right: 0 !important;
}
}
}
&-text {
box-sizing: border-box;
width: 288px;
height: 90px;
margin: 12px 0;
padding: 12px;
border-radius: 2px;
resize: none;
}
&-button {
.btn {
border: none;
border-radius: 5px;
font-size: 12px;
text-align: center;
line-height: 24px;
padding: 2px 46px;
font-weight: 400;
color: #fff;
}
.btn-valid {
background-color: #3370ff;
cursor: pointer;
}
.btn-invalid{
background-color: rgb(160, 207, 255);
cursor: not-allowed;
}
}
}
&-adv {
font-size: 12px;
text-align: center;
a {
display: inline-block;
}
}
}

View File

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

View File

@@ -0,0 +1,37 @@
<template>
<ToolbarItemContainer
:iconFile="toolbarConfig.icon"
:title="toolbarConfig.title"
:iconWidth="toolbarConfig.iconWidth"
:iconHeight="toolbarConfig.iconHeight"
:needDialog="false"
@onIconClick="handleIconClick"
>
<div :class="['file-upload', !isPC && 'file-upload-h5']">
<input
ref="inputRef"
title="文件"
type="file"
data-type="file"
accept="*"
@change="handleWebFileChange"
>
</div>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { useUpload, UploadType } from '../uploadToolkit';
const {
inputRef,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
} = useUpload(UploadType.FILE);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
</style>

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<ToolbarItemContainer
:iconFile="toolbarConfig.icon"
:title="toolbarConfig.title"
:iconWidth="toolbarConfig.iconWidth"
:iconHeight="toolbarConfig.iconHeight"
:needDialog="false"
@onIconClick="handleIconClick"
>
<div
v-if="!isUniFrameWork"
:class="['image-upload', !isPC && 'image-upload-h5']"
>
<input
ref="inputRef"
title="图片"
type="file"
data-type="image"
accept="image/gif,image/jpeg,image/jpg,image/png,image/bmp,image/webp"
@change="handleWebFileChange"
>
</div>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { useUpload, UploadType } from '../uploadToolkit';
const {
inputRef,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
} = useUpload(UploadType.IMAGE);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
</style>

View File

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

View File

@@ -0,0 +1,304 @@
<template>
<div
:class="[
'message-input-toolbar',
'message-input-toolbar-h5',
'message-input-toolbar-uni',
]"
>
<div v-if="props.displayType === 'emojiPicker'">
<EmojiPickerDialog />
</div>
<swiper
v-else
:class="['message-input-toolbar-swiper']"
:indicator-dots="isSwiperIndicatorDotsEnable"
:autoplay="false"
:circular="false"
>
<swiper-item
:class="[
'message-input-toolbar-list',
'message-input-toolbar-h5-list',
'message-input-toolbar-uni-list',
]"
>
<CameraUpload
v-if="featureConfig.InputCamera"
/>
<AlbumUpload
v-if="featureConfig.InputAlbum"
/>
<template v-if="currentExtensionList.length > 0">
<div
v-for="(extension, index) in currentExtensionList.slice(0, slicePos)"
:key="index"
>
<ToolbarItemContainer
v-if="extension"
:iconFile="genExtensionIcon(extension)"
:title="genExtensionText(extension)"
iconWidth="25px"
iconHeight="25px"
:needDialog="false"
@onIconClick="onExtensionClick(extension)"
/>
</div>
</template>
<template v-if="neededCountFirstPage === 1">
<Words
v-if="featureConfig.InputQuickReplies"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
<Evaluate
v-else-if="featureConfig.InputEvaluation"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
</template>
<template v-if="neededCountFirstPage > 1">
<Words
v-if="featureConfig.InputQuickReplies"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
<Evaluate
v-if="featureConfig.InputEvaluation"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
</template>
</swiper-item>
<swiper-item
v-if="neededCountFirstPage <= 1"
:class="[
'message-input-toolbar-list',
'message-input-toolbar-h5-list',
'message-input-toolbar-uni-list',
]"
>
<div
v-for="(extension, index) in currentExtensionList.slice(slicePos)"
:key="index"
>
<ToolbarItemContainer
v-if="extension"
:iconFile="genExtensionIcon(extension)"
:title="genExtensionText(extension)"
iconWidth="25px"
iconHeight="25px"
:needDialog="false"
@onIconClick="onExtensionClick(extension)"
/>
</div>
<template v-if="neededCountFirstPage === 1">
<Evaluate
v-if="featureConfig.InputEvaluation"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
</template>
<template v-else>
<Words
v-if="featureConfig.InputQuickReplies"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
<Evaluate
v-if="featureConfig.InputEvaluation"
@onDialogPopupShowOrHide="handleSwiperDotShow"
/>
</template>
</swiper-item>
</swiper>
<UserSelector
ref="userSelectorRef"
:type="selectorShowType"
:currentConversation="currentConversation"
:isGroup="isGroup"
@submit="onUserSelectorSubmit"
@cancel="onUserSelectorCancel"
/>
</div>
</template>
<script setup lang="ts">
import TUIChatEngine, {
IConversationModel,
TUIStore,
StoreName,
TUIReportService,
} from '@tencentcloud/chat-uikit-engine-lite';
import TUICore, { ExtensionInfo, TUIConstants } from '@tencentcloud/tui-core-lite';
import { ref, onUnmounted, onMounted } from '../../../adapter-vue';
import AlbumUpload from './album-upload/index.vue';
import CameraUpload from './camera-upload/index.vue';
import Evaluate from './evaluate/index.vue';
import Words from './words/index.vue';
import ToolbarItemContainer from './toolbar-item-container/index.vue';
import EmojiPickerDialog from './emoji-picker/emoji-picker-dialog.vue';
import UserSelector from './user-selector/index.vue';
import TUIChatConfig from '../config';
import { enableSampleTaskStatus } from '../../../utils/enableSampleTaskStatus';
import { ToolbarDisplayType } from '../../../interface';
import OfflinePushInfoManager, { PUSH_SCENE } from '../offlinePushInfoManager/index';
interface IProps {
displayType: ToolbarDisplayType;
}
const props = withDefaults(defineProps<IProps>(), {
});
const emits = defineEmits(['changeToolbarDisplayType']);
const currentConversation = ref<IConversationModel>();
const isGroup = ref<boolean>(false);
const selectorShowType = ref<string>('');
const userSelectorRef = ref();
const currentUserSelectorExtension = ref<ExtensionInfo | null>();
const currentExtensionList = ref<ExtensionInfo[]>([]);
const isSwiperIndicatorDotsEnable = ref<boolean>(false);
const featureConfig = TUIChatConfig.getFeatureConfig();
const neededCountFirstPage = ref<number>(8);
const slicePos = ref<number>(0);
const computeToolbarPaging = () => {
if (featureConfig.InputAlbum && featureConfig.InputCamera) {
neededCountFirstPage.value -= 2;
} else if (featureConfig.InputAlbum || featureConfig.InputCamera) {
neededCountFirstPage.value -= 1;
}
slicePos.value = neededCountFirstPage.value;
neededCountFirstPage.value -= currentExtensionList.value.length;
if (neededCountFirstPage.value === 1) {
isSwiperIndicatorDotsEnable.value = (featureConfig.InputEvaluation && featureConfig.InputQuickReplies);
} else if (neededCountFirstPage.value < 1) {
isSwiperIndicatorDotsEnable.value = featureConfig.InputEvaluation || featureConfig.InputQuickReplies;
}
};
onMounted(() => {
TUIStore.watch(StoreName.CUSTOM, {
activeConversation: onActiveConversationUpdate,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CUSTOM, {
activeConversation: onActiveConversationUpdate,
});
});
const onActiveConversationUpdate = (conversationID: string) => {
if (!conversationID) {
return;
}
if (conversationID !== currentConversation.value?.conversationID) {
getExtensionList();
computeToolbarPaging();
currentConversation.value = TUIStore.getData(StoreName.CONV, 'currentConversation');
isGroup.value = conversationID.startsWith(TUIChatEngine.TYPES.CONV_GROUP);
}
};
const getExtensionList = () => {
const chatType = TUIChatConfig.getChatType();
const params: Record<string, boolean | string> = { chatType };
// Backward compatibility: When callkit does not have chatType judgment, use filterVoice and filterVideo to filter
if (chatType === TUIConstants.TUIChat.TYPE.CUSTOMER_SERVICE) {
params.filterVoice = true;
params.filterVideo = true;
enableSampleTaskStatus('customerService');
}
// uni-app build ios app has null in last index need to filter
currentExtensionList.value = [
...TUICore.getExtensionList(TUIConstants.TUIChat.EXTENSION.INPUT_MORE.EXT_ID, params),
].filter((extension: ExtensionInfo) => {
if (extension?.data?.name === 'search') {
return featureConfig.MessageSearch;
}
return true;
});
reportExtension(currentExtensionList.value);
};
function reportExtension(extensionList: ExtensionInfo[]) {
extensionList.forEach((extension: ExtensionInfo) => {
const _name = extension?.data?.name;
if (_name === 'voiceCall') {
TUIReportService.reportFeature(203, 'voice-call');
} else if (_name === 'videoCall') {
TUIReportService.reportFeature(203, 'video-call');
} else if (_name === 'quickRoom') {
TUIReportService.reportFeature(204);
}
});
}
// handle extensions onclick
const onExtensionClick = (extension: ExtensionInfo) => {
// uniapp vue2 build wx lose listener proto
const extensionModel = currentExtensionList.value.find(
targetExtension => targetExtension?.data?.name === extension?.data?.name,
);
switch (extensionModel?.data?.name) {
case 'voiceCall':
onCallExtensionClicked(extensionModel, 1);
break;
case 'videoCall':
onCallExtensionClicked(extensionModel, 2);
break;
case 'search':
extensionModel?.listener?.onClicked?.();
break;
default:
break;
}
};
const onCallExtensionClicked = (extension: ExtensionInfo, callType: number) => {
selectorShowType.value = extension?.data?.name;
if (currentConversation?.value?.type === TUIChatEngine.TYPES.CONV_C2C) {
extension?.listener?.onClicked?.({
userIDList: [currentConversation?.value?.conversationID?.slice(3)],
type: callType,
callParams: {
offlinePushInfo: OfflinePushInfoManager.getOfflinePushInfo(PUSH_SCENE.CALL),
},
});
} else if (isGroup.value) {
currentUserSelectorExtension.value = extension;
userSelectorRef?.value?.toggleShow && userSelectorRef.value.toggleShow(true);
}
};
const genExtensionIcon = (extension: any) => extension?.icon;
const genExtensionText = (extension: any) => extension?.text;
const onUserSelectorSubmit = (selectedInfo: any) => {
currentUserSelectorExtension.value?.listener?.onClicked?.({
...selectedInfo,
callParams: {
offlinePushInfo: OfflinePushInfoManager.getOfflinePushInfo(PUSH_SCENE.CALL),
},
});
currentUserSelectorExtension.value = null;
};
const onUserSelectorCancel = () => {
currentUserSelectorExtension.value = null;
};
const handleSwiperDotShow = (showStatus: boolean) => {
isSwiperIndicatorDotsEnable.value = (neededCountFirstPage.value <= 1 && !showStatus);
emits('changeToolbarDisplayType', showStatus ? 'dialog' : 'tools');
};
</script>
<script lang="ts">
export default {
options: {
styleIsolation: 'shared',
},
};
</script>
<style lang="scss">
@import '../../../assets/styles/common';
@import './style/uni';
</style>

View File

@@ -0,0 +1,111 @@
/* stylelint-disable */
.message-input-toolbar {
border-top: 1px solid #e5e5e5;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
z-index: 100;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
&-list {
display: flex;
flex-direction: row;
align-items: center;
.extension-list {
list-style: none;
display: flex;
&-item {
width: 20px;
height: 20px;
padding: 12px 10px 1px;
cursor: pointer;
}
}
}
}
.message-input-toolbar-h5 {
padding: 5px 10px;
box-sizing: border-box;
flex-direction: column;
}
.message-input-toolbar-uni {
background-color: #ebf0f6;
flex-direction: column;
z-index: 100;
&-list {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 25%);
grid-template-rows: repeat(2, 100px);
}
}
// uniapp swiper style
wx-swiper .wx-swiper-wrapper,
wx-swiper .wx-swiper-slides,
wx-swiper .wx-swiper-slide-frame,
.message-input-toolbar-list {
overflow: visible !important;
}
.message-input-toolbar {
.bottom-popup,
.bottom-popup-h5,
.bottom-popup-uni {
position: sticky !important;
}
}
.message-input-toolbar-swiper {
width: 100%;
height: 220px;
::v-deep .uni-swiper-wrapper,
wx-swiper .wx-swiper-wrapper {
overflow: visible !important;
.uni-swiper-slides,
.wx-swiper-slides,
wx-swiper .wx-swiper-slides {
overflow: visible !important;
.uni-swiper-slide-frame,
.wx-swiper-slide-frame,
wx-swiper .wx-swiper-slide-frame {
overflow: visible !important;
.message-input-toolbar-list {
overflow: visible !important;
}
.toolbar-item-container-uni {
position: static !important;
}
.toolbar-item-container-dialog {
position: absolute !important;
background: transparent;
left: -10px;
bottom: -5px;
.bottom-popup-uni {
position: sticky !important;
}
}
}
}
}
}

View File

@@ -0,0 +1,138 @@
<template>
<div
ref="toolbarItemRef"
:class="[
'toolbar-item-container',
!isPC && 'toolbar-item-container-h5',
isUniFrameWork && 'toolbar-item-container-uni',
]"
>
<div
:class="[
'toolbar-item-container-icon',
isUniFrameWork && 'toolbar-item-container-uni-icon',
]"
@click="toggleToolbarItem"
>
<Icon
:file="props.iconFile"
class="icon"
:width="props.iconWidth"
:height="props.iconHeight"
/>
</div>
<div
v-if="isUniFrameWork"
:class="['toolbar-item-container-uni-title']"
>
{{ props.title }}
</div>
<div
v-show="showDialog"
ref="dialogRef"
:class="[
'toolbar-item-container-dialog',
isDark && 'toolbar-item-container-dialog-dark',
!isPC && 'toolbar-item-container-h5-dialog',
isUniFrameWork && 'toolbar-item-container-uni-dialog',
]"
>
<BottomPopup
v-if="props.needBottomPopup && !isPC"
class="toolbar-bottom-popup"
:show="showDialog"
@touchmove.stop.prevent
@onClose="onPopupClose"
>
<slot />
</BottomPopup>
<slot v-else />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from '../../../../adapter-vue';
import { outsideClick } from '@tencentcloud/universal-api';
import Icon from '../../../common/Icon.vue';
import BottomPopup from '../../../common/BottomPopup/index.vue';
import { isPC, isUniFrameWork } from '../../../../utils/env';
import TUIChatConfig from '../../config';
const props = defineProps({
iconFile: {
type: String,
required: true,
},
title: {
type: String,
default: '',
},
needDialog: {
type: Boolean,
default: true,
},
iconWidth: {
type: String,
default: '20px',
},
iconHeight: {
type: String,
default: '20px',
},
// Whether to display the bottom popup dialog on mobile devices
// Invalid on PC
needBottomPopup: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['onIconClick', 'onDialogClose', 'onDialogShow']);
const isDark = ref(TUIChatConfig.getTheme() === 'dark');
const showDialog = ref(false);
const toolbarItemRef = ref();
const dialogRef = ref();
watch(() => showDialog.value, (newVal) => {
if (!newVal) {
emits('onDialogClose', dialogRef);
} else {
emits('onDialogShow', dialogRef);
}
});
const toggleToolbarItem = () => {
emits('onIconClick', dialogRef);
if (isPC) {
outsideClick.listen({
domRefs: toolbarItemRef.value,
handler: closeToolbarItem,
});
}
if (!props.needDialog) {
return;
}
toggleDialogDisplay(!showDialog.value);
};
const closeToolbarItem = () => {
showDialog.value = false;
};
const toggleDialogDisplay = (showStatus: boolean) => {
if (showDialog.value === showStatus) {
return;
}
showDialog.value = showStatus;
};
const onPopupClose = () => {
showDialog.value = false;
};
defineExpose({
toggleDialogDisplay,
});
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,6 @@
.toolbar-item-container {
&-dialog {
background: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
}

View File

@@ -0,0 +1,7 @@
.toolbar-item-container-h5 {
&-dialog {
position: static !important;
width: 100%;
box-shadow: none;
}
}

View File

@@ -0,0 +1,5 @@
@import "../../../../../assets/styles/common";
@import "./color";
@import "./web";
@import "./h5";
@import "./uni";

View File

@@ -0,0 +1,37 @@
.toolbar-item-container-uni {
width: 100%;
height: 100%;
display: flex;
gap: 6px;
flex-direction: column;
justify-content: center;
align-items: center;
position: static;
&-icon {
background: #fff;
border-radius: 15px;
width: 60px;
height: 60px;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
&-title {
font-size: 14px;
color: #8F959D;
}
&-dialog{
position: absolute !important;
background: transparent;
left: -10px;
bottom: -5px;
.toolbar-bottom-popup{
position: sticky;
}
}
}

View File

@@ -0,0 +1,24 @@
.toolbar-item-container {
position: relative;
&-icon {
padding: 8px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
&-dialog {
z-index: 5;
position: absolute;
background: #fff;
box-shadow: 0 2px 4px -3px rgba(32, 77, 141, 0.03), 0 6px 10px 1px rgba(32, 77, 141, 0.06), 0 3px 14px 2px rgba(32, 77, 141, 0.05);
width: fit-content;
height: fit-content;
bottom: 35px;
}
&-dialog-dark {
background: #22262E;
box-shadow: 0 8px 40px 0 rgba(23, 25, 31, 0.6), 0 4px 12px 0 rgba(23, 25, 31, 0.8);
}
}

View File

@@ -0,0 +1,135 @@
import imageIconLight from '../../../../assets/icon/image-light.svg';
import imageIconDark from '../../../../assets/icon/image-dark.svg';
import imageUniIcon from '../../../../assets/icon/image-uni.png';
import cameraUniIcon from '../../../../assets/icon/camera-uni.png';
import videoIconLight from '../../../../assets/icon/video-light.svg';
import videoIconDark from '../../../../assets/icon/video-dark.svg';
import videoUniIcon from '../../../../assets/icon/video-uni.png';
import fileIconLight from '../../../../assets/icon/file-light.svg';
import fileIconDark from '../../../../assets/icon/file-dark.svg';
export const ICON_SIZE_CONFIG = {
WEB: {
WIDTH: '20px',
HEIGHT: '18px',
},
UNI: {
WIDTH: '32px',
HEIGHT: '25px',
},
};
export const TOOLBAR_ICON_MAP = {
IMAGE_WEB_LIGHT: imageIconLight,
IMAGE_WEB_DARK: imageIconDark,
IMAGE_UNI: imageUniIcon,
CAMERA_UNI: cameraUniIcon,
VIDEO_WEB_LIGHT: videoIconLight,
VIDEO_WEB_DARK: videoIconDark,
VIDEO_UNI: videoUniIcon,
FILE_WEB_LIGHT: fileIconLight,
FILE_WEB_DARK: fileIconDark,
};
export enum UploadType {
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
FILE = 'FILE',
ALBUM = 'ALBUM',
CAMERA = 'CAMERA',
}
export enum SourceType {
ALBUM = 'album',
CAMERA = 'camera',
}
export enum PlatformType {
WEB = 'WEB',
UNI = 'UNI',
}
export enum ThemeType {
LIGHT = 'light',
DARK = 'dark',
}
export enum MediaType {
IMAGE = 'image',
VIDEO = 'video',
FILE = 'file',
}
export enum ToolbarTitle {
IMAGE = '图片',
PHOTO = '照片',
VIDEO = '视频',
FILE = '文件',
CAMERA = '拍照',
RECORD = '录制',
SHOOT = '拍摄',
}
export const TOOLBAR_DISPLAY_CONFIG = {
[UploadType.IMAGE]: {
[PlatformType.WEB]: {
title: ToolbarTitle.IMAGE,
getIcon: (theme: string) => theme === ThemeType.DARK ? TOOLBAR_ICON_MAP.IMAGE_WEB_DARK : TOOLBAR_ICON_MAP.IMAGE_WEB_LIGHT,
},
[PlatformType.UNI]: {
title: ToolbarTitle.PHOTO,
icon: TOOLBAR_ICON_MAP.IMAGE_UNI,
},
},
[UploadType.VIDEO]: {
[PlatformType.WEB]: {
title: ToolbarTitle.VIDEO,
getIcon: (theme: string) => theme === ThemeType.DARK ? TOOLBAR_ICON_MAP.VIDEO_WEB_DARK : TOOLBAR_ICON_MAP.VIDEO_WEB_LIGHT,
},
[PlatformType.UNI]: {
title: ToolbarTitle.VIDEO,
icon: TOOLBAR_ICON_MAP.VIDEO_UNI,
},
},
[UploadType.FILE]: {
[PlatformType.WEB]: {
title: ToolbarTitle.FILE,
getIcon: (theme: string) => theme === ThemeType.DARK ? TOOLBAR_ICON_MAP.FILE_WEB_DARK : TOOLBAR_ICON_MAP.FILE_WEB_LIGHT,
},
},
[UploadType.ALBUM]: {
[PlatformType.UNI]: {
title: ToolbarTitle.PHOTO,
icon: TOOLBAR_ICON_MAP.IMAGE_UNI,
},
},
[UploadType.CAMERA]: {
[PlatformType.UNI]: {
title: ToolbarTitle.SHOOT,
icon: TOOLBAR_ICON_MAP.CAMERA_UNI,
},
},
} as const;
export const MEDIA_CHOOSE_CONFIG = {
IMAGE: {
COUNT: 1,
MEDIA_TYPE: [MediaType.IMAGE],
SIZE_TYPE: ['original', 'compressed'],
},
VIDEO: {
COUNT: 1,
MEDIA_TYPE: [MediaType.VIDEO],
MAX_DURATION: 60,
COMPRESSED: false,
},
MIXED: {
COUNT: 1,
MEDIA_TYPE: [MediaType.IMAGE, MediaType.VIDEO],
SIZE_TYPE: ['original', 'compressed'],
},
};

View File

@@ -0,0 +1,11 @@
export { useUpload, genSourceType } from './useUpload';
export {
UploadType,
SourceType,
PlatformType,
ThemeType,
MediaType,
ToolbarTitle,
} from './constants';
export * from './constants';
export * from './utils';

View File

@@ -0,0 +1,183 @@
import { ref, computed } from '../../../../adapter-vue';
import { TUIStore, StoreName, IConversationModel } from '@tencentcloud/chat-uikit-engine-lite';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { isPC, isUniFrameWork } from '../../../../utils/env';
import TUIChatConfig from '../../config';
import {
TOOLBAR_DISPLAY_CONFIG,
ICON_SIZE_CONFIG,
MEDIA_CHOOSE_CONFIG,
UploadType,
SourceType,
PlatformType,
} from './constants';
import {
sendImageMessage,
sendVideoMessage,
sendFileMessage,
handleWebFileSelect,
} from './utils';
export function genSourceType(uploadType: UploadType): SourceType {
if (uploadType === UploadType.CAMERA) {
return SourceType.CAMERA;
}
return SourceType.ALBUM;
}
export function useUpload(uploadType: UploadType) {
const inputRef = ref();
const currentConversation = ref<IConversationModel>();
const theme = TUIChatConfig.getTheme();
TUIStore.watch(StoreName.CONV, {
currentConversation: (conversation: IConversationModel) => {
currentConversation.value = conversation;
},
});
const sourceType = genSourceType(uploadType);
const toolbarConfig = computed(() => {
const config = TOOLBAR_DISPLAY_CONFIG[uploadType];
if (isUniFrameWork) {
const displayConfig = (config as any)[PlatformType.UNI];
return {
icon: displayConfig.icon,
title: displayConfig.title,
iconWidth: ICON_SIZE_CONFIG.UNI.WIDTH,
iconHeight: ICON_SIZE_CONFIG.UNI.HEIGHT,
};
} else {
const displayConfig = (config as any)[PlatformType.WEB];
return {
icon: displayConfig.getIcon ? displayConfig.getIcon(theme) : displayConfig.icon,
title: displayConfig.title,
iconWidth: ICON_SIZE_CONFIG.WEB.WIDTH,
iconHeight: ICON_SIZE_CONFIG.WEB.HEIGHT,
};
}
});
const handleIconClick = () => {
if (isUniFrameWork) {
handleUniAppClick();
} else {
handleWebClick();
}
};
const handleUniAppClick = () => {
switch (uploadType) {
case UploadType.IMAGE:
chooseImageInUniApp();
break;
case UploadType.VIDEO:
chooseVideoInUniApp();
break;
case UploadType.ALBUM:
chooseMediaInUniApp();
break;
case UploadType.CAMERA:
chooseCameraInUniApp();
break;
default:
break;
}
};
const handleWebClick = () => {
if (inputRef.value?.click) {
inputRef.value.click();
}
};
const chooseImageInUniApp = () => {
TUIGlobal?.chooseImage({
count: MEDIA_CHOOSE_CONFIG.IMAGE.COUNT,
sourceType: [sourceType],
success: function (res: Record<string, any>) {
if (currentConversation.value) {
sendImageMessage(currentConversation.value, res);
}
},
});
};
const chooseVideoInUniApp = () => {
TUIGlobal?.chooseVideo({
count: MEDIA_CHOOSE_CONFIG.VIDEO.COUNT,
sourceType: [sourceType],
compressed: MEDIA_CHOOSE_CONFIG.VIDEO.COMPRESSED,
success: function (res: Record<string, any>) {
if (currentConversation.value) {
sendVideoMessage(currentConversation.value, res);
}
},
});
};
const chooseMediaInUniApp = () => {
TUIGlobal?.chooseImage({
count: MEDIA_CHOOSE_CONFIG.IMAGE.COUNT,
sourceType: [SourceType.ALBUM, SourceType.CAMERA],
success: function (res: Record<string, any>) {
if (currentConversation.value) {
sendImageMessage(currentConversation.value, res);
}
},
});
};
const chooseCameraInUniApp = () => {
TUIGlobal?.chooseVideo({
count: MEDIA_CHOOSE_CONFIG.VIDEO.COUNT,
sourceType: [SourceType.ALBUM, SourceType.CAMERA],
compressed: MEDIA_CHOOSE_CONFIG.VIDEO.COMPRESSED,
success: function (res: Record<string, any>) {
if (currentConversation.value) {
sendVideoMessage(currentConversation.value, res);
}
},
});
};
const handleWebFileChange = (event: Event) => {
switch (uploadType) {
case UploadType.IMAGE:
handleWebFileSelect(event, (file) => {
if (currentConversation.value) {
sendImageMessage(currentConversation.value, file);
}
});
break;
case UploadType.VIDEO:
handleWebFileSelect(event, (file) => {
if (currentConversation.value) {
sendVideoMessage(currentConversation.value, file);
}
});
break;
case UploadType.FILE:
handleWebFileSelect(event, (file) => {
if (currentConversation.value) {
sendFileMessage(currentConversation.value, file);
}
});
break;
}
};
return {
inputRef,
currentConversation,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
};
}

View File

@@ -0,0 +1,116 @@
import TUIChatEngine, {
TUIChatService,
IConversationModel,
SendMessageParams,
SendMessageOptions,
} from '@tencentcloud/chat-uikit-engine-lite';
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
export function createSendMessageOptions(
currentConversation: IConversationModel | undefined,
file: any,
): SendMessageParams {
return {
to:
currentConversation?.groupProfile?.groupID
|| currentConversation?.userProfile?.userID,
conversationType: currentConversation?.type,
payload: {
file,
},
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
} as SendMessageParams;
}
export function genOfflinePushInfo(
currentConversation: IConversationModel,
payload: any,
messageType: any,
): SendMessageOptions {
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
conversation: currentConversation,
payload,
messageType,
};
return {
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
};
}
export function sendImageMessage(
currentConversation: IConversationModel,
files: any,
): void {
if (!files) {
return;
}
const options = createSendMessageOptions(currentConversation, files);
const sendMessageOptions = genOfflinePushInfo(
currentConversation,
options.payload,
TUIChatEngine.TYPES.MSG_IMAGE,
);
TUIChatService.sendImageMessage(options, sendMessageOptions);
}
export function sendVideoMessage(
currentConversation: IConversationModel,
file: any,
): void {
if (!file) {
return;
}
const options = createSendMessageOptions(currentConversation, file);
const sendMessageOptions = genOfflinePushInfo(
currentConversation,
options.payload,
TUIChatEngine.TYPES.MSG_VIDEO,
);
TUIChatService.sendVideoMessage(options, sendMessageOptions);
}
export function sendFileMessage(
currentConversation: IConversationModel,
file: any,
): void {
if (!file) {
return;
}
const options = createSendMessageOptions(currentConversation, file);
const sendMessageOptions = genOfflinePushInfo(
currentConversation,
options.payload,
TUIChatEngine.TYPES.MSG_FILE,
);
TUIChatService.sendFileMessage(options, sendMessageOptions);
}
export function handleWebFileSelect(
event: any,
sendCallback: (file: any) => void,
): void {
if (event?.target?.files?.length <= 0) {
return;
}
sendCallback(event.target);
event.target.value = '';
}
export function isValidFile(file: any): boolean {
return !!file;
}
export function clearFileInput(inputRef: any): void {
if (inputRef.value) {
inputRef.value.value = '';
}
}

View File

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

View File

@@ -0,0 +1,148 @@
<template>
<Dialog
:show="show"
:isH5="!isPC"
:isHeaderShow="false"
:isFooterShow="false"
:background="false"
@update:show="toggleShow"
>
<Transfer
:isSearch="true"
:title="title"
:list="searchMemberList"
:isH5="!isPC"
:isRadio="false"
:total="searchMemberCount"
@search="search"
@submit="submit"
@cancel="cancel"
@getMore="loadMoreMembers"
/>
</Dialog>
</template>
<script setup lang="ts">
import {
TUIGroupService,
TUIUserService,
TUIStore,
StoreName,
IGroupModel,
} from '@tencentcloud/chat-uikit-engine-lite';
import { ref, computed } from '../../../../adapter-vue';
import Dialog from '../../../common/Dialog/index.vue';
import Transfer from '../../../common/Transfer/index.vue';
import { isPC } from '../../../../utils/env';
const props = defineProps({
// type: voiceCall/groupCall/...
type: {
type: String,
default: '',
},
currentConversation: {
type: Object,
default: () => ({}),
},
isGroup: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['submit', 'cancel']);
const show = ref<boolean>(false);
const groupID = ref<string>('');
const memberList = ref<any[]>([]);
const memberCount = ref<number>(0);
const searchMemberList = ref<any[]>([]);
const searchMemberCount = ref<number>(0);
const selfUserID = ref<string>('');
const titleMap: any = {
voiceCall: '发起群语音',
videoCall: '发起群视频',
};
const title = computed(() => {
return titleMap[props.type] ? titleMap[props.type] : '';
});
TUIUserService.getUserProfile().then((res: any) => {
if (res?.data?.userID) {
selfUserID.value = res.data.userID;
}
});
TUIStore.watch(StoreName.GRP, {
currentGroup: (group: IGroupModel) => {
memberCount.value = group?.memberCount > 0 ? group?.memberCount - 1 : 0;
searchMemberCount.value = memberCount.value;
groupID.value = group?.groupID;
},
currentGroupMemberList: (list: any[]) => {
memberList.value = list?.filter(
(user: any) => user?.userID !== selfUserID.value,
);
searchMemberList.value = memberList.value;
},
});
const loadMoreMembers = async () => {
try {
await TUIGroupService.getGroupMemberList({
groupID: groupID.value,
count: 50,
});
} catch (error) {
console.log(error);
}
};
const search = async (searchInfo: string) => {
try {
const res = await TUIGroupService.getGroupMemberProfile({
groupID: groupID.value,
userIDList: [searchInfo],
});
const results = res?.data?.memberList?.filter(
(member: any) => member?.userID !== selfUserID.value,
);
if (searchInfo.trim()) {
searchMemberList.value = results;
searchMemberCount.value = results?.length;
} else {
searchMemberList.value = memberList.value;
searchMemberCount.value = memberCount.value;
}
} catch {
searchMemberList.value = memberList.value;
searchMemberCount.value = memberCount.value;
}
};
const submit = (selectedMemberList: string[]) => {
const userIDList: string[] = [];
selectedMemberList?.forEach((user: any) => {
user?.userID && userIDList.push(user.userID);
});
if (props.type === 'voiceCall') {
emits('submit', { userIDList, groupID: groupID.value, type: 1 });
} else if (props.type === 'videoCall') {
emits('submit', { userIDList, groupID: groupID.value, type: 2 });
}
searchMemberList.value = memberList.value;
toggleShow(false);
};
const cancel = () => {
searchMemberList.value = memberList.value;
emits('cancel');
toggleShow(false);
};
const toggleShow = (showStatus: boolean) => {
show.value = showStatus;
};
defineExpose({
toggleShow,
});
</script>

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<ToolbarItemContainer
:iconFile="toolbarConfig.icon"
:title="toolbarConfig.title"
:iconWidth="toolbarConfig.iconWidth"
:iconHeight="toolbarConfig.iconHeight"
:needDialog="false"
@onIconClick="handleIconClick"
>
<div
v-if="!isUniFrameWork"
:class="['video-upload', !isPC && 'video-upload-h5']"
>
<input
ref="inputRef"
title="视频"
type="file"
data-type="video"
accept="video/*"
@change="handleWebFileChange"
>
</div>
</ToolbarItemContainer>
</template>
<script lang="ts" setup>
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import { useUpload, UploadType } from '../uploadToolkit';
const {
inputRef,
toolbarConfig,
isPC,
isUniFrameWork,
handleIconClick,
handleWebFileChange,
} = useUpload(UploadType.VIDEO);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
</style>

View File

@@ -0,0 +1,2 @@
import Words from "./index.vue";
export default Words;

View File

@@ -0,0 +1,95 @@
<template>
<ToolbarItemContainer
ref="container"
:iconFile="wordsIcon"
title="常用语"
:needBottomPopup="true"
:iconWidth="isUniFrameWork ? '26px' : '20px'"
:iconHeight="isUniFrameWork ? '26px' : '20px'"
@onDialogShow="onDialogShow"
@onDialogClose="onDialogClose"
>
<div :class="['words', !isPC && 'words-h5']">
<div :class="['words-header', !isPC && 'words-h5-header']">
<span :class="['words-header-title', !isPC && 'words-h5-header-title']">
{{ TUITranslateService.t("Words.常用语-快捷回复工具") }}
</span>
<span
v-if="!isPC"
:class="['words-header-close', !isPC && 'words-h5-header-close']"
@click="closeDialog"
>
关闭
</span>
</div>
<ul :class="['words-list', !isPC && 'words-h5-list']">
<li
v-for="(item, index) in wordsList"
:key="index"
:class="['words-list-item', !isPC && 'words-h5-list-item']"
@click="selectWord(item)"
>
{{ TUITranslateService.t(`Words.${item.value}`) }}
</li>
</ul>
</div>
</ToolbarItemContainer>
</template>
<script setup lang="ts">
import {
TUITranslateService,
TUIStore,
StoreName,
IConversationModel,
SendMessageParams,
TUIChatService,
} from '@tencentcloud/chat-uikit-engine-lite';
import { ref } from '../../../../adapter-vue';
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
import wordsIconLight from '../../../../assets/icon/words-light.svg';
import wordsIconDark from '../../../../assets/icon/words-dark.svg';
import { wordsList } from '../../utils/wordsList';
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
import { isPC, isUniFrameWork } from '../../../../utils/env';
import TUIChatConfig from '../../config';
const wordsIcon = TUIChatConfig.getTheme() === 'dark' ? wordsIconDark : wordsIconLight;
const emits = defineEmits(['onDialogPopupShowOrHide']);
const currentConversation = ref<IConversationModel>();
const container = ref();
TUIStore.watch(StoreName.CONV, {
currentConversation: (conversation: IConversationModel) => {
currentConversation.value = conversation;
},
});
const selectWord = (item: any) => {
const options = {
to:
currentConversation?.value?.groupProfile?.groupID
|| currentConversation?.value?.userProfile?.userID,
conversationType: currentConversation?.value?.type,
payload: {
text: TUITranslateService.t(`Words.${item.value}`),
},
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
} as SendMessageParams;
TUIChatService.sendTextMessage(options);
// close dialog after submit evaluate
container?.value?.toggleDialogDisplay(false);
};
const closeDialog = () => {
container?.value?.toggleDialogDisplay(false);
};
const onDialogShow = () => {
emits('onDialogPopupShowOrHide', true);
};
const onDialogClose = () => {
emits('onDialogPopupShowOrHide', false);
};
</script>
<style scoped lang="scss" src="./style/index.scss"></style>

View File

@@ -0,0 +1,8 @@
.words {
background-color: #ffffff;
&-header {
&-close {
color: #3370ff;
}
}
}

View File

@@ -0,0 +1,29 @@
.words-h5 {
width: 100%;
box-sizing: border-box;
max-height: 80vh;
height: fit-content;
overflow: hidden;
display: flex;
flex-direction: column;
&-header {
&-title {
font-size: 18px;
line-height: 40px;
}
}
&-list {
flex: 1;
overflow-y: scroll;
&-item {
cursor: none;
-webkit-tap-highlight-color: transparent;
-moz-tap-highlight-color: transparent;
padding: 12px 0;
font-size: 16px;
color: #50545c;
line-height: 18px;
border-bottom: 1px solid #eeeeee;
}
}
}

View File

@@ -0,0 +1,5 @@
@import url("../../../../../assets/styles/common.scss");
@import "./color.scss";
@import "./web.scss";
@import "./h5.scss";

View File

@@ -0,0 +1,32 @@
.words {
z-index: 5;
width: 315px;
padding: 12px;
display: flex;
flex-direction: column;
width: 19.13rem;
height: 12.44rem;
overflow-y: auto;
&-header {
display: flex;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
}
&-list {
flex: 1;
display: flex;
flex-direction: column;
cursor: pointer;
&-item {
cursor: pointer;
padding: 4px 0;
font-size: 14px;
color: #50545c;
line-height: 18px;
}
&-item:hover {
color: #006eff;
}
}
}