This commit is contained in:
Your Name
2026-03-11 09:49:47 +08:00
parent 02ae537b4c
commit 38ad60f4bb
290 changed files with 36917 additions and 123 deletions
@@ -0,0 +1,33 @@
<template>
<image :style="avatarStyle" :src="avatarSrc" @error="handleImageError"/>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import defaultAvatarIcon from '../../assets/base/default-avatar.png'
const avatarSrc = ref('');
const props = defineProps({
src: {
type: String,
default: defaultAvatarIcon
},
avatarStyle: {
type: [String, Object],
default: () => ({})
}
})
watch(() => props.src, () => {
avatarSrc.value = props.src;
}, {
immediate: true,
});
function handleImageError() {
avatarSrc.value = defaultAvatarIcon;
}
</script>
<style></style>
@@ -0,0 +1,582 @@
<template>
<!-- 音频/视频通话主容器 -->
<div class="TUICall-container" v-if="callParticipantInfo?.selfInfo?.status !== CallStatus.IDLE">
<!-- 顶部菜单栏 -->
<view class='topBar' v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="call-duration">
<text>{{ formatDuration(duration) }}</text>
</view>
</view>
<!-- 本地头像遮罩层 -->
<view @click="!isLocalStreamMain && toggleViewSize()" :class="isLocalStreamMain ? 'big-overlay' : 'small-overlay'"
v-if="mediaType === CallMediaType.VIDEO && !isCameraOpen">
<Avatar :avatarStyle="isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.selfInfo.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 本地流 -->
<div
:class="(mediaType === CallMediaType.AUDIO || !isCameraOpen) ? 'player-audio' : (isLocalStreamMain ? 'big-video' : 'small-video')"
@click="!isLocalStreamMain && toggleViewSize()">
<TRTCPusher :key="pusherId" :id="pusherId" v-if="pusherId" />
</div>
<!-- 远端头像遮罩层 -->
<view :class="!isLocalStreamMain ? 'big-overlay' : 'small-overlay'" @click="isLocalStreamMain && toggleViewSize()"
v-if="mediaType === CallMediaType.AUDIO || (mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED && !callParticipantInfo?.allParticipants[0]?.isCameraOpened)">
<Avatar :avatarStyle="!isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="!isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 远端流 -->
<view
:class="(mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING || !callParticipantInfo?.allParticipants[0]?.isCameraOpened) ? 'player-audio' : (!isLocalStreamMain ? 'big-video' : 'small-video')"
@click="isLocalStreamMain && toggleViewSize()">
<TRTCPlayer :id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" ref="player"
:stream-id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" />
</view>
<!-- 用户信息 -->
<view v-if="mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING"
class="voice-invite-message">
<Avatar :avatarStyle="avatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<text class="nick-name">
{{ callParticipantInfo?.allParticipants[0]?.name || callParticipantInfo?.allParticipants[0]?.id }}
</text>
</view>
<!-- 通话状态提示 -->
<div class="tips">
<text v-if="showConnectedTip">已接通</text>
<div v-if="!showConnectedTip && callParticipantInfo.selfInfo.status !== CallStatus.CONNECTED">
<text v-if="callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
{{ mediaType === CallMediaType.AUDIO ? '邀请你进行语音通话' : '邀请你进行视频通话' }}
</text>
<text v-else>等待对方接受</text>
</div>
</div>
<!-- 主叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role === CallRole.CALLER">
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="CallerHangupHandler">
<image v-if="isCallBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>挂断</text>
</view>
</view>
</view>
<!-- 被叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
<view class="btn-operate" style="gap: 40px">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="reject">
<image :src="IMG_HANGUP" mode="aspectFit" />
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #51C271;" @click="acceptHandler">
<image v-if="isAcceptBtnClickable" :src="IMG_ACCEPT" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>接听</text>
</view>
</view>
</view>
<!-- 语音通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.AUDIO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image mode="aspectFit" v-if="isHangupBtnClickable" :src="IMG_HANGUP" />
<image mode="aspectFit" v-else class="img-loading" :src="IMG_LOADING"></image>
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
</view>
</view>
<!-- 视频通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="cameraHandler">
<view class="call-operate" :style="buttonBgColor(isCameraOpen)">
<image mode="aspectFit" class="btn-image" :src="isCameraOpen ? IMG_CAMERA_TRUE : IMG_CAMERA_FALSE"></image>
</view>
<text>{{ isCameraOpen ? '摄像头已开启' : '摄像头已关闭' }}</text>
</view>
</view>
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image v-if="isHangupBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<view v-if="isCameraOpen" class="switch-camera">
<image :src="IMG_SWITCH_CAMERA" @click="switchCamera" mode="aspectFit"/>
</view>
<text>挂断</text>
</view>
</view>
</view>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { onHide, onUnload } from '@dcloudio/uni-app'
import Avatar from '../Avatar/Avatar.vue';
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
import IMG_HANGUP from '../../assets/call/hangup.svg';
import IMG_ACCEPT from '../../assets/call/accept.svg';
import IMG_AUDIO_TRUE from '../../assets/call/microphone-open.svg';
import IMG_AUDIO_FALSE from '../../assets/call/microphone-close.svg';
import IMG_SPEAKER_TRUE from '../../assets/call/speaker-open.svg';
import IMG_SPEAKER_FALSE from '../../assets/call/speaker-close.svg';
import IMG_CAMERA_TRUE from '../../assets/call/camera-open.svg';
import IMG_CAMERA_FALSE from '../../assets/call/camera-close.svg';
import IMG_SWITCH_CAMERA from '../../assets/call/switch-camera.svg';
import IMG_DEFAULT_AVATAR from '../../assets/base/default-avatar.png';
import IMG_LOADING from '../../assets/call/loading.png';
import { formatDuration } from '../../utils/index';
import { CallMediaType, AudioPlayBackDevice } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole } from '../../constants/call';
import { useCallListState, useCallParticipantState, useDeviceState } from '../../index';
const {
inviterId,
mediaType,
duration,
pusherId,
accept,
reject,
hangup,
} = useCallListState();
const { callParticipantInfo } = useCallParticipantState();
const {
currentAudioRoute,
openLocalCamera, closeLocalCamera,
openLocalMicrophone, closeLocalMicrophone,
switchCamera, setAudioRoute,
} = useDeviceState();
const isCallBtnClickable = ref((inviterId.value || '').length > 0 ? true : false);
const isAcceptBtnClickable = ref(true);
const isHangupBtnClickable = ref(true);
const isLocalStreamMain = ref(mediaType.value === CallMediaType.AUDIO ? false : true);
const isMicrophoneOpen = computed(() => callParticipantInfo?.value.selfInfo?.isMicrophoneOpened);
const isCameraOpen = computed(() => callParticipantInfo?.value.selfInfo?.isCameraOpened);
const isSpeakerOpen = computed(() => currentAudioRoute.value !== AudioPlayBackDevice.EAR);
const showConnectedTip = ref(false);
const bigOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 1
});
const smallOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 99
});
const avatarStyle = ref({
width: '100px',
height: '100px',
borderRadius: '12px',
display: 'block',
margin: '140px auto 15px'
});
function toggleViewSize() {
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = !isLocalStreamMain.value
}
}
async function cameraHandler() {
if (isCameraOpen.value) {
await closeLocalCamera();
} else {
await openLocalCamera();
}
}
async function microPhoneHandler() {
if (isMicrophoneOpen.value) {
await closeLocalMicrophone();
} else {
await openLocalMicrophone();
}
}
async function CallerHangupHandler() {
try {
if (!isCallBtnClickable.value) return;
await hangup();
} catch (error) {
isCallBtnClickable.value = true;
}
}
async function acceptHandler() {
try {
if (!isAcceptBtnClickable.value) {
console.warn(`previous accept is ongoing, please avoid repeat accept`);
return;
}
isAcceptBtnClickable.value = false;
await accept();
isAcceptBtnClickable.value = true;
} catch (error) {
isAcceptBtnClickable.value = true;
}
}
async function hangupHandler() {
try {
if (!isHangupBtnClickable.value) {
console.warn(`previous hangup is ongoing, please avoid repeat hangup`);
return;
}
isHangupBtnClickable.value = false;
await hangup();
isHangupBtnClickable.value = true;
} catch (error) {
isHangupBtnClickable.value = true;
}
}
const buttonBgColor = computed(() => (isActive: boolean) => {
return isActive ? { backgroundColor: '#FFFFFF', } : {
backgroundColor: '#22262E',
opacity: '0.5'
};
})
watch(() => callParticipantInfo?.value?.selfInfo, (newObj, oldObj) => {
const newStatus = newObj?.status;
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = newStatus === CallStatus.CALLING
}
if (mediaType.value === CallMediaType.AUDIO) {
isLocalStreamMain.value = false;
}
if (newStatus === CallStatus.CONNECTED && oldObj?.status === CallStatus.CALLING) {
showConnectedTip.value = true;
setTimeout(() => {
showConnectedTip.value = false;
}, 1000);
} else {
showConnectedTip.value = false;
}
if (newStatus === CallStatus.IDLE) {
isHangupBtnClickable.value = false;
isAcceptBtnClickable.value = true;
isHangupBtnClickable.value = true;
}
});
watch(inviterId, (newVal, oldVal) => {
isCallBtnClickable.value = (newVal || '').length > 0 ? true : false;
});
onUnload(() => {
const callStatus = callParticipantInfo?.value?.selfInfo?.status;
const callRole = callParticipantInfo?.value?.selfInfo?.role;
if (callStatus === CallStatus.IDLE) return;
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
hangup();
} else {
reject();
}
}
if (callStatus === CallStatus.CONNECTED) {
hangup();
}
});
</script>
<style scoped lang="scss">
.TUICall-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
bottom: 0;
left: 0;
margin: 0;
background-color: #ffffff;
.topBar {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
width: 100%;
z-index: 10;
.call-duration {
text-align: center;
font-family: PingFang SC;
font-weight: 500;
font-size: 12px;
color: #D5E0F2;
}
}
.big-video {
position: absolute;
width: 100%;
height: 100%;
z-index: 10;
background-color: black;
}
.big-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.big-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.big-overlay-mask {
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 2;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.small-video {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 100;
}
.small-overlay {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 98;
.small-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 99;
}
.small-overlay-mask {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
object-fit: cover;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.player-audio {
width: 0;
height: 0;
}
.voice-invite-message {
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
text-align: center;
font-family: PingFang SC;
color: #D5E0F2;
font-weight: 500;
z-index: 100;
.nick-name {
font-size: 18px;
text-align: center;
}
}
.tips {
height: 14px;
position: fixed;
top: 70%;
left: 50%;
font-size: 14px;
font-family: PingFang SC;
transform: translate(-50%, -50%);
color: #D5E0F2;
font-weight: 500;
z-index: 100;
}
.footer {
position: absolute;
bottom: 5vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-size: 14px;
color: #f0e9e9;
font-weight: 400;
z-index: 100;
.btn-operate {
display: flex;
flex-direction: initial;
text-align: center;
align-items: center;
justify-content: center;
}
.btn-operate-item {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
.switch-camera {
position: absolute;
right: 98px;
width: 32px;
top: 60%;
margin-left: 40px;
height: 32px;
image {
width: 100%;
height: 100%;
}
}
.call-operate {
width: 60px;
height: 60px;
border-radius: 50%;
margin: 10px 10vw;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
image {
width: 30px;
height: 30px;
background: none;
}
.img-loading {
animation: rotate 1.5s linear infinite;
width: 30px;
height: 30px
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
}
}
}
}
</style>
@@ -0,0 +1,208 @@
<template>
<view class="conversation-list">
<!-- 会话列表 -->
<view v-for="conversation in conversationList" :key="conversation.conversationID" class="conversation-item"
@click="handleItemClick(conversation)">
<!-- 头像和未读数 -->
<view class="avatar-container">
<Avatar :avatarStyle='avatarStyle' :src='getConversationAvatar(conversation)'>
</Avatar>
<view v-if="conversation.unreadCount > 0" class="badge">
{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
</view>
</view>
<!-- 会话内容 -->
<view class="content">
<!-- 昵称和时间 -->
<view class="header">
<text class="nickname">{{ getConversationDisplayName(conversation) }}</text>
<text class="time" v-if="conversation.lastMessage?.lastTime">{{
calculateTimestamp(conversation.lastMessage?.lastTime) }}</text>
</view>
<!-- 最后的消息 -->
<view v-if="conversation.lastMessage?.type" class="footer">
<text class="last-message">
{{ getLastMessagePreview(conversation.lastMessage) }}
</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../index';
import Avatar from '../Avatar/Avatar.vue'
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
import defaultGroupAvatarIcon from '../../assets/base/default-group-avatar.png';
import { handleCallKitSignaling, isCallSignaling } from '../../utils/processCallSignaling';
import { calculateTimestamp } from '../../utils/time';
import { MessageType } from '../../constants/chat'
const avatarStyle = {
width: '48px',
height: '48px',
borderRadius: '4px',
marginRight: '12px'
}
const props = defineProps({
onConversationSelect: {
type: Function,
default: null
}
})
const { conversationList, setActiveConversation } = useConversationListState();
const handleItemClick = (conversation: any) => {
if (props.onConversationSelect) {
props.onConversationSelect(conversation)
} else {
setActiveConversation(conversation.conversationID)
}
};
const getLastMessagePreview = (lastMessage?: any) => {
if (!lastMessage) return '';
switch (lastMessage.type) {
case MessageType.MSG_TEXT:
return lastMessage.payload.text;
case MessageType.MSG_IMAGE:
return '[图片]';
case MessageType.MSG_VIDEO:
return '[视频]';
case MessageType.MSG_CUSTOM:
return handleCustomMessage(lastMessage);
case MessageType.MSG_GRP_TIP:
return '[群提示消息]';
default:
return '[未知消息]';
}
};
const getConversationDisplayName = (conversation: any) => {
// 群组会话:使用群组名称
if (conversation.type === 'GROUP') {
return conversation.groupProfile?.name || conversation.conversationID;
}
// C2C 会话:备注 -> 昵称 -> 用户ID
return conversation.remark || conversation.userProfile?.nick || conversation.userProfile?.userID;
};
const getConversationAvatar = (conversation: any) => {
// 群组会话:使用群组头像
if (conversation.type === 'GROUP') {
return conversation.groupProfile?.avatar || defaultGroupAvatarIcon;
}
// C2C 会话:使用用户头像 -> 默认头像
return conversation.userProfile?.avatar || defaultAvatarIcon;
};
function handleCustomMessage(message) {
if (!isCallSignaling(message)) {
return '[自定义消息]'
}
return handleCallKitSignaling(message).callTip
}
</script>
<style lang="scss" scoped>
.conversation-list {
background-color: #F9FAFC;
height: 100%;
overflow-y: auto;
}
.conversation-item {
display: flex;
padding: 12px 16px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
&:active {
background-color: #f9f9f9;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.nickname {
font-family: PingFang SC;
font-size: 17px;
font-weight: 400;
color: #000000E5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.time {
font-family: PingFang HK;
font-style: Regular;
font-weight: 400;
font-size: 12px;
color: #00000066;
margin-left: 8px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.last-message {
font-family: PingFang SC;
font-weight: 400;
font-style: Regular;
font-size: 14px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.avatar-container {
width: 48px;
height: 48px;
position: relative;
margin-right: 12px;
}
.badge {
position: absolute;
top: -6px;
right: -6px;
background-color: #E54545;
color: white;
font-size: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1;
line-height: 18px;
}
</style>
@@ -0,0 +1,244 @@
<template>
<view class="modal-overlay" @click="handleClose">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">创建群聊</text>
<view class="close-btn" @click="handleClose">×</view>
</view>
<scroll-view class="form-container" scroll-y>
<view class="form-item">
<text class="form-label">群名称</text>
<input class="form-input" v-model="groupForm.name" placeholder="请输入群名称" />
</view>
<view class="form-item">
<text class="form-label">群ID可选</text>
<input class="form-input" v-model="groupForm.groupID" placeholder="请输入群ID,不填则自动生成" />
</view>
<view class="form-item">
<text class="form-label">群介绍可选</text>
<textarea class="form-textarea" v-model="groupForm.introduction" placeholder="请输入群介绍" />
</view>
<view class="form-item">
<text class="form-label">群头像可选</text>
<input class="form-input" v-model="groupForm.avatar" placeholder="请输入群头像URL" />
</view>
</scroll-view>
<view class="modal-footer">
<button class="cancel-btn" @click="handleClose">取消</button>
<button class="submit-btn" @click="handleSubmit">创建群聊</button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
interface Emits {
(e: 'close', value: boolean): void
(e: 'submit', groupInfo: any): void
}
const emit = defineEmits<Emits>()
const groupForm = reactive({
name: '',
groupID: '',
introduction: '',
avatar: ''
})
// 关闭弹窗
const handleClose = () => {
emit('close', false)
resetForm()
}
// 提交表单
const handleSubmit = () => {
if (!groupForm.name.trim()) {
uni.showToast({
title: '请输入群名称',
icon: 'none'
})
return
}
const groupInfo = {
name: groupForm.name,
groupID: groupForm.groupID || '',
introduction: groupForm.introduction || '',
avatar: groupForm.avatar || '',
}
emit('submit', groupInfo)
handleClose()
}
const resetForm = () => {
groupForm.name = ''
groupForm.groupID = ''
groupForm.introduction = ''
groupForm.avatar = ''
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-height: 80vh;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
box-sizing: border-box;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 30rpx 24rpx;
border-bottom: 1rpx solid #e8e8e8;
position: relative;
box-sizing: border-box;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
flex: 1;
text-align: center;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
position: absolute;
right: 30rpx;
}
.form-container {
flex: 1;
max-height: 80vh;
padding: 30rpx;
overflow: hidden;
box-sizing: border-box;
}
.form-item {
margin-bottom: 32rpx;
box-sizing: border-box;
}
.form-label {
display: block;
font-size: 28rpx;
color: #1a1a1a;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
line-height: 1.5;
box-sizing: border-box;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 30rpx 40rpx;
border-top: 1rpx solid #e8e8e8;
box-sizing: border-box;
}
.cancel-btn {
flex: 1;
height: 80rpx;
background-color: #f8f9fa;
color: #666;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-btn {
flex: 1;
height: 80rpx;
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.cancel-btn:active {
background-color: #e8e8e8;
}
.submit-btn:active {
background-color: #06a854;
}
</style>
@@ -0,0 +1,421 @@
<template>
<view class="group-members">
<!-- 群成员网格 -->
<view class="members-grid">
<!-- 群成员头像 -->
<view
v-for="(member, index) in displayMembers"
:key="member.userID"
class="member-item"
@click="onMemberClick(member)"
>
<view class="member-avatar-container">
<Avatar
:avatarStyle="avatarStyle"
:src="member.avatar || defaultAvatarIcon"
/>
<!-- 群主标识 -->
<view v-if="member.role === GroupMemberRole.OWNER" class="owner-badge">
<text class="owner-text">群主</text>
</view>
</view>
<text class="member-nick">{{ member.nameCard || member.nick || member.userID }}</text>
</view>
<!-- 添加成员按钮 -->
<view v-if="!props.readonly" class="member-item add-member" @click="onAddMember">
<view class="member-avatar-container">
<view class="add-icon">+</view>
</view>
</view>
<!-- 删除成员按钮 (仅群主可见) -->
<view
v-if="!props.readonly && currentUserRole === GroupMemberRole.OWNER"
class="member-item remove-member"
@click="onRemoveMember"
>
<view class="member-avatar-container">
<view class="remove-icon"></view>
</view>
</view>
</view>
<!-- 折叠/展开按钮 -->
<view
v-if="!props.readonly && totalMembers > displayLimit"
class="toggle-button"
@click="toggleExpanded"
>
<text class="toggle-text">
{{ isExpanded ? '收起' : `查看更多成员(${totalMembers - displayLimit})` }}
</text>
<text class="toggle-icon">{{ isExpanded ? '▲' : '▼' }}</text>
</view>
<!-- 添加成员弹窗 -->
<view v-if="showAddMemberModal" class="modal-overlay" @click="showAddMemberModal = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">添加群成员</text>
<text class="close-btn" @click="showAddMemberModal = false">×</text>
</view>
<view class="modal-body">
<UserPicker
ref="userPickerRef"
:maxCount="maxAddCount"
:excludeUserIDs="currentMemberIDs"
:inModal="true"
@confirm="handleAddMembers"
/>
</view>
</view>
</view>
<!-- 删除成员弹窗 -->
<view v-if="showRemoveMemberModal" class="modal-overlay" @click="showRemoveMemberModal = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">删除群成员</text>
<text class="close-btn" @click="showRemoveMemberModal = false">×</text>
</view>
<view class="modal-body">
<UserPicker
ref="removeUserPickerRef"
:maxCount="maxRemoveCount"
:dataSource="removableMembers"
mode="remove"
:enableDelete="false"
:enableSearch="false"
:inModal="true"
@confirm="handleRemoveMembers"
/>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { GroupMemberRole } from '../../states/GroupSettingState/types';
import { useGroupSettingState } from '../../states/GroupSettingState/GroupSettingState';
import Avatar from '../Avatar/Avatar.vue';
import UserPicker from '../UserPicker/UserPicker.vue';
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
interface GroupMember {
userID: string;
nick?: string;
avatar?: string;
role?: string;
nameCard?: string;
}
interface Props {
displayLimit?: number;
maxAddCount?: number;
maxRemoveCount?: number;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
displayLimit: 12,
maxAddCount: 50,
maxRemoveCount: 50,
readonly: false
});
const emit = defineEmits<{
memberClick: [member: GroupMember];
addMembers: [userIDs: string[]];
removeMembers: [userIDs: string[]];
}>();
const {
allMembers,
currentUserRole,
} = useGroupSettingState();
const isExpanded = ref(false);
const showAddMemberModal = ref(false);
const showRemoveMemberModal = ref(false);
const userPickerRef = ref();
const removeUserPickerRef = ref();
const avatarStyle = {
width: '100%',
height: '100%'
};
const totalMembers = computed(() => allMembers.value?.length || 0);
const displayMembers = computed(() => {
const members = allMembers.value || [];
if (props.readonly || isExpanded.value) {
return members;
}
return members.slice(0, props.displayLimit);
});
const removableMembers = computed(() => {
// 过滤掉群主
return allMembers.value?.filter(member => member.role !== GroupMemberRole.OWNER) || [];
});
const currentMemberIDs = computed(() => {
return allMembers.value?.map(member => member.userID) || [];
});
const onMemberClick = (member: GroupMember) => {
emit('memberClick', member);
};
const onAddMember = () => {
showAddMemberModal.value = true;
};
const onRemoveMember = () => {
if (currentUserRole.value !== GroupMemberRole.OWNER) {
uni.showToast({
title: '只有群主可以删除成员',
icon: 'none'
});
return;
}
showRemoveMemberModal.value = true;
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const handleAddMembers = async (userIDs: string[]) => {
try {
await emit('addMembers', userIDs);
showAddMemberModal.value = false;
uni.showToast({
title: '添加成员成功',
icon: 'success'
});
} catch (error) {
console.error('添加成员失败:', error);
uni.showToast({
title: '添加成员失败',
icon: 'none'
});
}
};
const handleRemoveMembers = async (userIDs: string[]) => {
try {
await emit('removeMembers', userIDs);
showRemoveMemberModal.value = false;
uni.showToast({
title: '删除成员成功',
icon: 'success'
});
} catch (error) {
console.error('删除成员失败:', error);
uni.showToast({
title: '删除成员失败',
icon: 'none'
});
}
};
</script>
<style lang="scss" scoped>
.group-members {
background: white;
padding: 0 30rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.members-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-bottom: 20rpx;
}
.member-item {
width: calc((100% - 100rpx) / 6); // 6列布局
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20rpx;
}
.member-avatar-container {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
position: relative;
margin-bottom: 12rpx;
}
.add-member .member-avatar-container,
.remove-member .member-avatar-container {
overflow: visible;
}
.add-icon,
.remove-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
font-weight: 300;
line-height: 1;
border-radius: 50%;
background: #f8f9fa;
color: #999;
transition: all 0.2s ease;
}
.add-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.08);
}
.add-icon:active {
background: rgba(7, 193, 96, 0.15);
transform: scale(0.95);
}
.remove-icon {
color: #fa5151;
background: rgba(250, 81, 81, 0.08);
}
.remove-icon:active {
background: rgba(250, 81, 81, 0.15);
transform: scale(0.95);
}
.owner-badge {
position: absolute;
bottom: 0rpx;
left: 50%;
transform: translateX(-50%);
background: #ff9500;
color: white;
font-size: 18rpx;
padding: 2rpx 6rpx;
border-radius: 6rpx;
white-space: nowrap;
z-index: 10;
line-height: 1;
}
.owner-text {
font-size: 18rpx;
line-height: 1;
}
.member-nick {
font-size: 24rpx;
color: #333;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 20rpx;
margin-top: 10rpx;
background: #f8f9fa;
border-radius: 12rpx;
transition: background-color 0.2s;
}
.toggle-button:active {
background: #e9ecef;
}
.toggle-text {
font-size: 28rpx;
color: #666;
}
.toggle-icon {
font-size: 24rpx;
color: #999;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 95vw;
max-width: 900rpx;
height: 90vh;
max-height: 950rpx;
background: white;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
cursor: pointer;
}
.modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,721 @@
<template>
<view class="group-settings">
<!-- 顶部群信息 -->
<view class="group-header">
<view class="group-avatar">
<Avatar :avatarStyle="groupAvatarStyle" :src="avatar || defaultGroupAvatarIcon"></Avatar>
</view>
<view class="group-basic-info">
<div class="group-name">{{ groupName || '群聊' }}</div>
<div class="group-id">群ID: {{ groupID || '--' }}</div>
</view>
<view class="edit-name-btn" @click.stop="editGroupName" v-if="currentUserRole === GroupMemberRole.OWNER">
<text class="edit-icon"></text>
</view>
</view>
<!-- 设置项列表 -->
<view class="settings-list">
<!-- 群成员 -->
<view class="setting-item member-section">
<view class="item-left">
<text class="item-label">群成员</text>
</view>
<view class="item-right">
<text class="member-count">{{ memberCount || 0 }}</text>
</view>
</view>
<GroupMembers @addMembers="onAddMembers" @removeMembers="onRemoveMembers" />
<!-- 群公告 -->
<view class="setting-item" @click="showGroupNotice">
<view class="item-left">
<text class="item-label">群公告</text>
</view>
<view class="item-right">
<text class="item-value notice-preview">{{ noticePreview }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
<!-- 群类型 -->
<view class="setting-item group-type-item" @click="showGroupType">
<view class="item-left">
<text class="item-label">群类型</text>
</view>
<view class="item-right">
<text class="item-value">{{ groupTypeText }}</text>
</view>
</view>
<!-- 退出群聊/解散群组按钮 -->
<view class="exit-section">
<div class="exit-btn" @click="confirmExit">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散群组' : '退出群聊' }}
</div>
</view>
<!-- 编辑群名称弹窗 -->
<view v-if="showEditGroupName" class="modal-overlay" @click="showEditGroupName = false">
<view class="edit-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">编辑群名称</text>
<input v-model="editGroupNameValue" class="edit-input" maxlength="30" />
<view class="modal-buttons">
<button class="btn-cancel" @click="showEditGroupName = false">取消</button>
<button class="btn-confirm" @click="saveGroupName">保存</button>
</view>
</view>
</view>
</view>
<!-- 编辑群公告弹窗 -->
<view v-if="showEditGroupNotice" class="modal-overlay" @click="showEditGroupNotice = false">
<view class="edit-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">{{ currentUserRole === GroupMemberRole.OWNER ? '编辑群公告' : '群公告' }}</text>
<textarea v-if="currentUserRole === GroupMemberRole.OWNER" v-model="editGroupNoticeValue"
class="edit-textarea" maxlength="130" />
<view v-else class="notice-content">
<text>{{ notification || '暂无公告' }}</text>
</view>
<view class="modal-buttons">
<button class="btn-cancel" @click="showEditGroupNotice = false">取消</button>
<button v-if="currentUserRole === GroupMemberRole.OWNER" class="btn-confirm"
@click="saveGroupNotice">保存</button>
</view>
</view>
</view>
</view>
<!-- 确认退出弹窗 -->
<view v-if="showExitConfirm" class="modal-overlay" @click="showExitConfirm = false">
<view class="confirm-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">{{ currentUserRole === GroupMemberRole.OWNER ? '解散群组' : '退出群聊' }}</text>
<text class="modal-desc">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散后群组将被永久删除,所有成员将被移除' : '退出后将不再接收此群聊的消息' }}
</text>
<view class="modal-buttons">
<button class="btn-cancel" @click="showExitConfirm = false">取消</button>
<button class="btn-confirm danger" @click="handleQuitGroup">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散' : '退出' }}
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { GroupType, GroupInviteType, GroupMemberRole } from '../../states/GroupSettingState/types';
import { useGroupSettingState } from '../../states/GroupSettingState/GroupSettingState';
import Avatar from '../Avatar/Avatar.vue';
import GroupMembers from './GroupMembers.vue';
import defaultGroupAvatarIcon from '../../assets/base/default-group-avatar.png';
const {
// 状态数据
groupID,
groupType,
groupName,
avatar,
notification,
memberCount,
allMembers,
currentUserID,
currentUserRole,
inviteOption,
// 业务方法
quitGroup,
dismissGroup,
getGroupMemberList,
updateGroupProfile,
addGroupMember,
deleteGroupMember
} = useGroupSettingState();
// 弹窗状态
const showExitConfirm = ref(false);
const showEditGroupName = ref(false);
const showEditGroupNotice = ref(false);
// 编辑值
const editGroupNameValue = ref('');
const editGroupNoticeValue = ref('');
const groupAvatarStyle = {
width: '100%',
height: '100%'
}
// 群公告预览
const noticePreview = computed(() => {
const notice = notification.value || '暂无公告';
return notice.length > 15 ? notice.substring(0, 15) + '...' : notice;
});
// 群类型文本
const groupTypeText = computed(() => {
const types = {
[GroupType.PUBLIC]: '公开群',
[GroupType.MEETING]: '会议群',
[GroupType.WORK]: '工作群'
};
return types[groupType.value as GroupType] || '普通群';
});
// 加群方式文本
const joinMethodText = computed(() => {
const methods = {
[GroupInviteType.FREE_ACCESS]: '自由加入',
[GroupInviteType.NEED_PERMISSION]: '需要验证',
[GroupInviteType.DISABLE_APPLY]: '禁止加入'
};
return methods[inviteOption.value as GroupInviteType] || '未知方式';
});
// 方法定义
const editGroupName = () => {
console.log('editGroupName 被调用,currentUserRole:', currentUserRole.value);
if (currentUserRole.value !== GroupMemberRole.OWNER) {
uni.showToast({
title: '只有群主可以修改群名称',
icon: 'none'
});
return;
}
editGroupNameValue.value = '';
showEditGroupName.value = true;
};
const onAddMembers = async (userIDs: string[]) => {
try {
await addGroupMember({ userIDList: userIDs });
} catch (error) {
console.error('添加群成员失败:', error);
throw error;
}
};
const onRemoveMembers = async (userIDs: string[]) => {
try {
await deleteGroupMember({ userIDList: userIDs });
} catch (error) {
console.error('删除群成员失败:', error);
throw error;
}
};
const showGroupNotice = () => {
if (currentUserRole.value === GroupMemberRole.OWNER) {
editGroupNoticeValue.value = notification.value || '';
}
showEditGroupNotice.value = true;
};
// 保存方法
const saveGroupName = async () => {
if (!editGroupNameValue.value.trim()) {
uni.showToast({
title: '群名称不能为空',
icon: 'none'
});
return;
}
try {
await updateGroupProfile({
name: editGroupNameValue.value.trim()
});
uni.showToast({
title: '群名称修改成功',
icon: 'success'
});
showEditGroupName.value = false;
} catch (error) {
console.error('修改群名称失败:', error);
uni.showToast({
title: '修改群名称失败',
icon: 'none'
});
}
};
const saveGroupNotice = async () => {
try {
await updateGroupProfile({
notification: editGroupNoticeValue.value.trim()
});
uni.showToast({
title: '群公告修改成功',
icon: 'success'
});
showEditGroupNotice.value = false;
} catch (error) {
console.error('修改群公告失败:', error);
uni.showToast({
title: '修改群公告失败',
icon: 'none'
});
}
};
const confirmExit = () => {
showExitConfirm.value = true;
};
const handleQuitGroup = async () => {
try {
if (currentUserRole.value === GroupMemberRole.OWNER) {
// 群主调用解散群组
await dismissGroup();
} else {
// 群成员调用退出群组
await quitGroup();
}
uni.showToast({
title: currentUserRole.value === GroupMemberRole.OWNER ? '解散群组成功' : '退出群聊成功',
icon: 'success'
});
// 返回上一页
uni.navigateBack();
} catch (error) {
console.error(currentUserRole.value === GroupMemberRole.OWNER ? '解散群组失败:' : '退出群聊失败:', error);
uni.showToast({
title: currentUserRole.value === GroupMemberRole.OWNER ? '解散群组失败' : '退出群聊失败',
icon: 'none'
});
} finally {
showExitConfirm.value = false;
}
};
</script>
<style lang="scss" scoped>
.group-settings {
background: #F9FAFC;
min-height: 100vh;
padding: 0;
}
/* 群信息头部 */
.group-header {
background: #FFFFFF;
padding: 40rpx 30rpx;
display: flex;
align-items: center;
border-bottom: 1rpx solid #e5e5e5;
position: relative;
}
.group-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
overflow: hidden;
margin-right: 30rpx;
background: white;
border: 1rpx solid #e5e5e5;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
.group-basic-info {
flex: 1;
}
.group-basic-info .group-name {
font-size: 36rpx;
font-weight: 600;
color: #000;
margin-bottom: 12rpx;
line-height: 1.2;
}
.group-basic-info .group-id {
font-size: 26rpx;
color: #999;
font-weight: 400;
}
.edit-name-btn {
position: absolute;
top: 40rpx;
right: 30rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.edit-name-btn:active {
background-color: #e9ecef;
transform: scale(0.95);
}
.edit-icon {
font-size: 28rpx;
}
/* 设置项列表 */
.settings-list {
background: white;
margin-bottom: 20rpx;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
margin-top: 16rpx;
/* 8px 间距 */
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
background: white;
position: relative;
min-height: 96rpx;
transition: background-color 0.2s ease;
}
.setting-item:active {
background-color: #f8f8f8;
}
.setting-item:last-child {
border-bottom: none;
}
.item-left .item-label {
font-family: PingFang SC;
font-weight: 400;
font-size: 28rpx;
line-height: 100%;
letter-spacing: 0px;
color: #000;
}
.item-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.item-value {
font-size: 30rpx;
color: #999;
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.notice-preview {
max-width: 320rpx;
}
.item-arrow {
color: #c7c7cc;
font-size: 28rpx;
font-weight: normal;
opacity: 0.6;
}
/* 群成员区域 */
.member-section {
border-bottom: none !important;
padding-bottom: 0;
}
/* 群类型和我的群昵称之间无间隙 */
.setting-item:not(.member-section):not(.group-type-item) {
margin-bottom: 0;
}
/* 群类型和退出群聊之间增加间隙 */
.group-type-item {
margin-bottom: 16rpx;
/* 8px 间距 */
}
.member-section .item-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.member-count {
font-size: 30rpx;
color: #999;
}
/* 退出群聊区域 */
.exit-section {
background: white;
padding: 20rpx 0;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
margin-bottom: 40rpx;
border: none;
/* 移除边框 */
}
.exit-btn {
width: 100%;
padding: 32rpx 30rpx;
background: transparent;
border: none;
color: #E54545;
font-family: PingFang SC;
font-weight: 400;
font-size: 28rpx;
/* 14px */
line-height: 100%;
letter-spacing: 0px;
text-align: center;
transition: background-color 0.2s ease;
}
.exit-btn:active {
background-color: #f8f8f8;
}
/* 确认弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 60rpx 40rpx;
backdrop-filter: blur(4rpx);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirm-modal {
background: white;
border-radius: 24rpx;
width: 580rpx;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(40rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content {
padding: 64rpx 48rpx 48rpx;
text-align: center;
}
.modal-title {
display: block;
font-size: 38rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 24rpx;
line-height: 1.3;
}
.modal-desc {
display: block;
font-size: 32rpx;
color: #666;
line-height: 1.5;
margin-bottom: 48rpx;
}
.modal-buttons {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.modal-buttons button {
flex: 1;
padding: 32rpx 0;
border: none;
background: transparent;
font-size: 34rpx;
font-weight: 500;
position: relative;
line-height: 1;
transition: all 0.2s ease;
}
.modal-buttons button::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.05);
opacity: 0;
transition: opacity 0.2s ease;
}
.modal-buttons button:active::after {
opacity: 1;
}
.btn-cancel {
color: #666;
border-right: 1rpx solid #f0f0f0;
}
.btn-confirm {
color: #007aff;
font-weight: 600;
}
.btn-confirm.danger {
color: #ff453a;
}
/* 编辑弹窗样式 */
.edit-modal {
background: white;
border-radius: 24rpx;
width: 580rpx;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease-out;
}
.edit-input {
height: 100rpx;
width: 100%;
padding: 0 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #fafafa;
box-sizing: border-box;
transition: all 0.3s ease;
color: #333;
}
.edit-input:focus {
border-color: #007aff;
background: white;
outline: none;
box-shadow: 0 0 0 4rpx rgba(0, 122, 255, 0.1);
}
.edit-textarea {
width: 100%;
min-height: 260rpx;
padding: 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #fafafa;
resize: none;
box-sizing: border-box;
line-height: 1.6;
transition: all 0.3s ease;
color: #333;
}
.edit-textarea:focus {
border-color: #007aff;
background: white;
outline: none;
box-shadow: 0 0 0 4rpx rgba(0, 122, 255, 0.1);
}
.notice-content {
width: 100%;
min-height: 260rpx;
padding: 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #f8f9fa;
color: #666;
line-height: 1.6;
box-sizing: border-box;
border-radius: 16rpx;
}
/* 响应式设计 */
@media (max-width: 375px) {
.group-header {
padding: 30rpx 20rpx;
}
.group-avatar {
width: 100rpx;
height: 100rpx;
}
.group-basic-info .group-name {
font-size: 32rpx;
}
.setting-item {
padding: 25rpx;
}
.item-left .item-label {
font-size: 30rpx;
}
.item-value {
font-size: 26rpx;
}
}
</style>
@@ -0,0 +1,31 @@
.panel-box {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin: 10px;
}
.panel-item {
background-color: #F9FAFC;
width: 64px;
height: 64px;
border-radius: 14px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
}
.panel-icon {
width: 30px;
height: 30px;
}
.panel-text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #00000066;
opacity: 0.8;
}
@@ -0,0 +1,49 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleClick">
<image class="panel-icon" :src="CameraPickerIcon"></image>
</div>
<text class="panel-text">拍照</text>
</div>
</template>
<script lang="ts" setup>
import { useMessageInputState } from '../../../index';
import CameraPickerIcon from '../../../assets/chat/camera-picker.svg'
import { MessageContentType } from '../../../constants/chat'
const { sendMessage } = useMessageInputState();
const emit = defineEmits(['closePanel']);
const handleClick = () => {
uni.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sizeType: ['original', 'compressed'],
sourceType: ['camera'],
camera: 'back',
success: function (res: any) {
emit('closePanel');
if (res.type === MessageContentType.IMAGE) {
sendMessage({
type: MessageContentType.IMAGE,
content: res
})
}
if (res.type === MessageContentType.VIDEO) {
sendMessage({
type: MessageContentType.VIDEO,
content: res
})
}
},
fail: function (err: any) {
console.error('chooseMedia failed:', err);
}
});
}
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,48 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleClick">
<image class="panel-icon" :src="PhotoPickerIcon"></image>
</div>
<text class="panel-text">图片</text>
</div>
</template>
<script lang="ts" setup>
import { useMessageInputState } from '../../../states/MessageInputState';
import PhotoPickerIcon from '../../../assets/chat/photo-picker.svg'
import { MessageContentType } from '../../../constants/chat'
const { sendMessage } = useMessageInputState();
const emit = defineEmits(['closePanel']);
const handleClick = () => {
uni.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sizeType: ['original', 'compressed'],
sourceType: ['album'],
success: function (res: any) {
emit('closePanel');
if (res.type === MessageContentType.IMAGE) {
sendMessage({
type: MessageContentType.IMAGE,
content: res
})
}
if (res.type === MessageContentType.VIDEO) {
sendMessage({
type: MessageContentType.VIDEO,
content: res
})
}
},
fail: function (err: any) {
console.error('chooseMedia failed:', err);
}
});
}
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,42 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleCall">
<image class="panel-icon" :src="CallVideoIcon"></image>
</div>
<text class="panel-text">视频通话</text>
</div>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../../states/ConversationListState';
import { removeC2C } from '../../../utils'
import CallVideoIcon from '../../../assets/chat/call-video.svg'
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const emit = defineEmits(['closePanel', 'showUserPicker']);
const { activeConversation } = useConversationListState();
const handleCall = () => {
emit('closePanel');
if (activeConversation.value?.type === 'GROUP') {
// 群聊:触发显示用户选择器事件
emit('showUserPicker', { type: 2 });
} else {
// 单聊:直接呼叫
const userID = removeC2C(activeConversation.value.conversationID);
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: 2,
},
});
}
};
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,43 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleCall">
<image class="panel-icon" :src="CallVoiceIcon"></image>
</div>
<text class="panel-text">语音通话</text>
</div>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../../states/ConversationListState';
import { removeC2C } from '../../../utils'
import CallVoiceIcon from '../../../assets/chat/call-voice.svg'
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const emit = defineEmits(['closePanel', 'showUserPicker']);
const { activeConversation } = useConversationListState();
const handleCall = () => {
emit('closePanel');
if (activeConversation.value?.type === 'GROUP') {
// 群聊:触发显示用户选择器事件
emit('showUserPicker', { type: 1 });
} else {
// 单聊:直接呼叫
const userID = removeC2C(activeConversation.value.conversationID);
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: 1,
},
});
}
};
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,328 @@
<template>
<!-- 消息输入容器-->
<div class="message-input-container" :style="{ paddingBottom: keyboardHeight > 0 ? keyboardHeight + 'px' : '' }">
<!-- 正常状态可以发送消息 -->
<view class="input-container" v-if="canSendMessage">
<!-- 消息输入框 -->
<input class="message-input" type="text" :value="inputRawValue" confirm-type="send" @confirm="sendInputMessage"
@input="updateRawValue($event.target.value)" :adjust-position="false"
:style="{ borderColor: inputRawValue ? '#006eff' : 'transparent' }" />
<!-- 更多功能按钮当输入框为空时显示 -->
<view class="icon-btn" v-if="!inputRawValue" @click="toggleMorePanel">
<image :src="MorePanelIcon"></image>
</view>
<!-- 发送按钮当输入框有内容时显示 -->
<view class="send-btn" v-if="inputRawValue" @click="sendInputMessage">
<text>发送</text>
</view>
</view>
<!-- 退出群聊状态显示提示信息 -->
<view class="input-container disabled-container" v-else>
<view class="disabled-message">
<text class="disabled-text">你已退出群聊无法发送消息</text>
</view>
</view>
<!-- 更多功能面板 -->
<view class="more-panel" v-if="showMorePanel" @touchmove.stop.prevent>
<view class="panel-content">
<ImagePicker @closePanel="closePanel" />
<PhotoPicker @closePanel="closePanel" />
<VideoCallPicker @closePanel="closePanel" @showUserPicker="handleShowUserPicker" />
<VoiceCallPicker @closePanel="closePanel" @showUserPicker="handleShowUserPicker" />
</view>
</view>
<!-- 用户选择器弹窗 -->
<view v-if="showUserPicker" class="user-picker-modal">
<view class="modal-mask" @click="closeUserPicker"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">选择通话用户</text>
<view class="close-btn" @click="closeUserPicker">×</view>
</view>
<UserPicker :maxCount="1" :dataSource="filteredGroupMembers" :enableDelete="false" :inModal="true"
@confirm="handleUserSelect" />
</view>
</view>
</div>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true,
}
}
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMessageInputState } from '../../states/MessageInputState';
import { useConversationListState } from '../../states/ConversationListState';
import { useGroupSettingState } from '../../states/GroupSettingState';
import VideoCallPicker from './AttachmentPicker/VideoCallPicker.vue';
import VoiceCallPicker from './AttachmentPicker/voiceCallPicker.vue';
import ImagePicker from './AttachmentPicker/CameraPicker.vue';
import PhotoPicker from './AttachmentPicker/PhotoPicker.vue';
import UserPicker from '../UserPicker/UserPicker.vue';
import MorePanelIcon from '../../assets/chat/more-panel.svg';
import { TUIBridge } from '../../TUIBridge';
import { EVENT } from '../../constants/event';
import { MessageContentType } from '../../constants/chat'
const { inputRawValue, updateRawValue, sendMessage } = useMessageInputState();
const { activeConversation } = useConversationListState();
const { allMembers, isInGroup, currentUserID, getGroupMemberList } = useGroupSettingState();
const showMorePanel = ref(false);
const showUserPicker = ref(false);
const currentCallType = ref(1);
const keyboardHeight = ref(0);
// 判断是否可以发送消息
const canSendMessage = computed(() => {
// 如果不是群聊,可以发送消息
if (activeConversation.value?.type !== 'GROUP') {
return true;
}
// 如果是群聊,检查是否在群里
return isInGroup.value !== false;
});
// 过滤掉自己的群成员列表
const filteredGroupMembers = computed(() => {
return (allMembers.value || []).filter(member => member.userID !== currentUserID.value);
});
const toggleMorePanel = () => {
showMorePanel.value = !showMorePanel.value;
};
const closePanel = () => {
showMorePanel.value = false;
};
const sendInputMessage = async () => {
if (!inputRawValue.value) return;
sendMessage({
type: MessageContentType.TEXT,
content: inputRawValue.value
});
inputRawValue.value = '';
};
const handleShowUserPicker = ({ type }) => {
currentCallType.value = type;
showUserPicker.value = true;
};
const handleUserSelect = (selectedUserIDs: string[]) => {
if (selectedUserIDs.length > 0) {
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: selectedUserIDs,
type: currentCallType.value,
// groupID: activeConversation.value.groupID,
},
});
}
closeUserPicker();
};
const closeUserPicker = () => {
showUserPicker.value = false;
};
onMounted(async () => {
if (activeConversation.value?.type === 'GROUP') {
await getGroupMemberList({ count: 100 });
}
// Listen for keyboard height changes
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height;
if (res.height > 0) {
// Close more panel when keyboard pops up
showMorePanel.value = false;
uni.$emit('TUIChat:keyboardHeightChange', res.height);
}
});
})
onUnmounted(() => {
uni.offKeyboardHeightChange();
})
</script>
<style lang="scss" scoped>
.message-input-container {
position: relative;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.4);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.4);
/* 添加背景色覆盖安全区域 */
background-color: #FFFFFF;
}
.input-container {
width: 100%;
height: 48px;
background-color: #FFFFFF;
border-top: 1px solid #e5e5e5;
display: flex;
align-items: center;
z-index: 100;
/* 移除额外间距,只保留外层容器的安全区域适配 */
padding-bottom: 0;
}
.icon-btn {
width: 28px;
height: 28px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
image {
width: 100%;
height: 100%;
}
}
.message-input {
flex: 1;
height: 36px;
padding: 0 15px;
background-color: #F0F2F7;
border-radius: 4px;
font-size: 16px;
border: 1px solid transparent;
margin: 0 10px;
}
.disabled-container {
justify-content: center;
align-items: center;
background-color: #f8f9fa;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.55);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.55);
}
.disabled-message {
padding: 12px 16px;
text-align: center;
}
.disabled-text {
font-size: 14px;
color: #999;
line-height: 1.4;
}
.send-btn {
min-width: 60px;
height: 36px;
padding: 0 12px;
display: flex;
justify-content: center;
align-items: center;
background-color: #006eff;
color: #fff;
border-radius: 4px;
font-size: 14px;
margin: 0 5px;
}
.more-panel {
width: 100%;
height: 125px;
background-color: #fff;
z-index: 99;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.55);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.55);
}
.panel-content {
height: 100%;
padding: 10px 0;
display: flex;
flex-wrap: wrap;
border-top: 1px solid #eee;
justify-content: center;
padding: 10px;
}
.user-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
width: 95%;
max-width: 500px;
height: 90vh;
max-height: 800px;
background-color: #fff;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e5e5e5;
background-color: #fff;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s;
}
.close-btn:hover {
background-color: #f5f5f5;
}
</style>
@@ -0,0 +1,46 @@
<template>
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
<div v-if="isCallSignaling(message)" @click="rePlayCall(handleCallKitSignaling(message).callType)"
style="display: flex; align-items: center;">
<image class="callMessage-icon"
:src="handleCallKitSignaling(message).callType === 1 ? CallVoiceIcon : CallVideoIcon" />
<text class="text">{{ handleCallKitSignaling(message).callTip }}</text>
</div>
<div v-else>
<text class="text">{{ message?.payload }}</text>
</div>
</view>
</template>
<script lang="ts" setup>
import { useMessageListState } from '../../../index';
import CallVoiceIcon from '../../../assets/chat/voice-call-message.svg';
import CallVideoIcon from '../../../assets/chat/call-video.svg';
import { removeC2C } from '../../../utils';
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const { activeConversationID } = useMessageListState();
const props = defineProps({
message: {
type: Object,
},
})
async function rePlayCall(callType) {
const userID = removeC2C(activeConversationID.value)
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: callType,
},
});
}
</script>
<style lang="scss" scoped>
@import './Message.module.scss';
</style>
@@ -0,0 +1,131 @@
<template>
<view class="group-tip-message">
<text class="tip-text">{{ renderText() }}</text>
</view>
</template>
<script lang="ts" setup>
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
interface Props {
message: any;
}
const props = defineProps<Props>();
const renderText = () => {
const messageContent = props.message.getMessageContent()
if (messageContent.businessID === 'group_create') {
return `${messageContent.showName || ''} 创建群组`;
}
if (isCallSignaling(props.message) && props.message.conversationType === 'GROUP') {
return handleCallKitSignaling(props.message);
}
return resolveGroupTipMessage(props.message).text;
};
const resolveGroupTipMessage = (message: any) => {
const ret: {
text: string;
} = {
text: '',
};
let showName: string = message?.nick || message?.payload?.userIDList?.join(',');
if (message?.payload?.memberList?.length > 0) {
showName = '';
message?.payload?.memberList?.map((user: any) => {
const _showName = user?.nick || user?.userID;
showName += `${_showName},`;
return user;
});
showName = showName?.slice(0, -1);
}
switch (message.payload.operationType) {
case 1: // GRP_TIP_MBR_JOIN
ret.text = `${showName} 加入群组`;
break;
case 2: // GRP_TIP_MBR_QUIT
ret.text = `${showName} 退出群组`;
break;
case 3: // GRP_TIP_MBR_KICKED_OUT
ret.text = `${showName} 被踢出群组`;
break;
case 4: // GRP_TIP_MBR_SET_ADMIN
ret.text = `${showName} 成为管理员`;
break;
case 5: // GRP_TIP_MBR_CANCELED_ADMIN
ret.text = `${showName} 被撤销管理员`;
break;
case 6: // GRP_TIP_GRP_PROFILE_UPDATED
ret.text = handleGroupProfileUpdated(message);
break;
case 7: // GRP_TIP_MBR_PROFILE_UPDATED
message.payload.memberList.forEach((member: any) => {
if (member.muteTime > 0) {
ret.text = `${showName} 被禁言`;
} else {
ret.text = `${showName} 被取消禁言`;
}
});
break;
default:
ret.text = `[群提示消息]`;
break;
}
return ret;
}
const handleGroupProfileUpdated = (message: any) => {
const { nick, payload } = message;
const { newGroupProfile, memberList, operatorID } = payload;
let text = '';
const showName: string = nick || operatorID;
const key: string = Object.keys(newGroupProfile)[0];
switch (key) {
case 'muteAllMembers':
if (newGroupProfile[key]) {
text = `管理员 ${showName} 开启全员禁言`;
} else {
text = `管理员 ${showName} 取消全员禁言`;
}
break;
case 'ownerID':
if (memberList && memberList.length > 0) {
text = `${memberList[0].nick || memberList[0].userID} 成为新的群主`;
}
break;
case 'groupName':
text = `${showName} 修改群名为 ${newGroupProfile[key]}`;
break;
case 'notification':
text = `${showName} 发布新公告`;
break;
default:
break;
}
return text;
}
</script>
<style scoped>
.group-tip-message {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx 32rpx;
margin: 8rpx 0;
}
.tip-text {
font-size: 24rpx;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 80%;
word-break: break-all;
}
</style>
@@ -0,0 +1,91 @@
<template>
<div class="image-message">
<image :src="message?.payload.imageInfoArray[0]?.url" :style="imgStyle" @click="previewImage" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
const props = defineProps({
message: {
type: Object,
},
})
const imgStyle = ref({});
const previewImage = (e: Event) => {
const currentUrl = props.message?.payload.imageInfoArray[0]?.url;
if (!currentUrl) return;
uni.previewImage({
current: currentUrl,
urls: [currentUrl],
indicator: 'default',
loop: false,
});
}
const calculateImageSize = (width: number, height: number) => {
const maxWidth = 200;
const maxHeight = 200;
if (width > maxWidth) {
height = (maxWidth / width) * height;
width = maxWidth;
}
if (height > maxHeight) {
width = (maxHeight / height) * width;
height = maxHeight;
}
return { width, height };
}
const setImageStyle = (width: number, height: number) => {
imgStyle.value = {
width: `${width}px`,
height: `${height}px`
};
}
const getImageSizeFromProps = () => {
const imageInfo = props.message?.payload.imageInfoArray[0];
if (imageInfo?.width && imageInfo?.height) {
return { width: imageInfo.width, height: imageInfo.height };
}
return null;
}
const detectImageSize = () => {
uni.getImageInfo({
src: props.message?.payload.imageInfoArray[0]?.url,
success: (res) => {
const { width, height } = calculateImageSize(res.width, res.height);
setImageStyle(width, height);
},
fail: () => {
setImageStyle(200, 200);
}
});
}
onMounted(() => {
const sizeFromProps = getImageSizeFromProps();
if (sizeFromProps) {
const { width, height } = calculateImageSize(sizeFromProps.width, sizeFromProps.height);
setImageStyle(width, height);
} else {
detectImageSize();
}
});
</script>
<style lang="scss" scoped>
.image-message {
image {
border-radius: 8px;
object-fit: contain;
}
}
</style>
@@ -0,0 +1,24 @@
.bubble {
background-color: #F0F2F7;
border-radius: 0px 10px 10px 10px;
padding: 15rpx;
position: relative;
&.bubble-me {
border-radius: 10px 0 10px 10px;
background-color: #CCE2FF;
}
.callMessage-icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
.text {
font-size: 14px;
font-weight: 400;
color: #333;
}
}
@@ -0,0 +1,26 @@
<template>
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
<text class="text" :style="{ wordBreak: shouldBreakWord ? 'break-all' : 'normal' }">
{{ message.payload.text }}
</text>
</view>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps({
message: {
type: Object,
},
})
const shouldBreakWord = computed(() => {
return props.message?.payload?.text?.length > 50
})
</script>
<style lang="scss" scoped>
@import './Message.module.scss';
</style>
@@ -0,0 +1,190 @@
<template>
<view class="video-message">
<!-- 视频预览图 -->
<view class="video-preview" @click="handleVideoClick">
<image class="video-poster" :src="message.payload.snapshotUrl" mode="aspectFill" />
<!-- 播放按钮图标 -->
<view class="play-icon">
<image :src="playControlIcon" mode="aspectFit" />
</view>
<!-- 视频时长 -->
<view class="video-duration">
{{ formatDuration(message.payload.videoSecond) }}
</view>
</view>
<!-- 全屏播放器 -->
<view class="fullscreen-container" v-if="isPlaying">
<video id="videoPlayer" :src="message.payload.remoteVideoUrl" :autoplay="true" :controls="true"
:show-fullscreen-btn="false" :show-center-play-btn="false" @ended="handleVideoEnd"
@pause="handleVideoPause" @fullscreenchange="handleFullscreenChange" class="fullscreen-video"
:enable-progress-gesture="true" objectFit="contain" />
<!-- 关闭按钮 -->
<view class="video-controls">
<view class="close-btn" @click="handleCloseVideo">
<view class="close-icon"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import playControlIcon from '../../../assets/chat/play-control.svg';
const props = defineProps({
message: {
type: Object,
required: true
},
});
const isPlaying = ref(false);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
const handleVideoClick = () => {
isPlaying.value = true;
setTimeout(() => {
const videoContext = uni.createVideoContext('videoPlayer', this);
videoContext.requestFullScreen({
direction: 0 // 0表示正常竖屏
});
}, 100);
};
const handleVideoPause = () => {
};
const handleVideoEnd = () => {
isPlaying.value = false;
};
const handleFullscreenChange = (e: any) => {
if (!e.detail.fullScreen) {
isPlaying.value = false;
}
};
const handleCloseVideo = () => {
const videoContext = uni.createVideoContext('videoPlayer', this);
videoContext.pause();
videoContext.exitFullScreen();
isPlaying.value = false;
};
</script>
<style lang="scss" scoped>
.video-message {
position: relative;
width: 200px;
height: 200px;
border-radius: 8px;
overflow: hidden;
background-color: black;
.video-preview {
position: relative;
width: 100%;
height: 100%;
}
.video-poster {
width: 100%;
height: 100%;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
image {
width: 100%;
height: 100%;
}
}
.video-duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 12px;
border-radius: 4px;
}
.fullscreen-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
background-color: black;
}
.fullscreen-video {
width: 100%;
height: 100%;
}
.video-controls {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: 15px;
box-sizing: border-box;
z-index: 1000;
/* 确保关闭按钮在控制条上方 */
}
.close-btn {
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 2px;
background-color: white;
border-radius: 1px;
}
.close-icon::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
</style>
@@ -0,0 +1,325 @@
<template>
<div class="message-container">
<!-- 消息列表滚动区域 -->
<scroll-view ref="scrollViewRef" scroll-y class="message-list" :scroll-into-view="currentMessage"
scroll-with-animation @scrolltolower="handleScrollToBottom" @scrolltoupper="handleScrollToTop">
<!-- 下拉加载提示 -->
<view v-if="isLoadingOlderMessage || showNoMoreTip" class="loading-tip">
<text>{{ isLoadingOlderMessage ? '加载中...' : '没有更多消息了' }}</text>
</view>
<!-- 消息列表 -->
<view v-for="(message, index) in messageList">
<!-- tips 消息 -->
<GroupTipMessage v-if="shouldRenderAsGroupTip(message)" :message="message" />
<!-- 消息时间分割线 -->
<MessageTimestamp v-if="!shouldRenderAsGroupTip(message)" :currTime="message.time"
:prevTime="index > 0 ? messageList[index - 1].time : 0" />
<!-- 非群 tips 消息 -->
<view v-if="isSupportedMessageType(message)" :id="`msg-${message.ID}`" class="message-item"
:class="{ 'message-item-me': message?.flow === 'out' }">
<!-- 消息头像 -->
<Avatar :avatarStyle="avatarStyle" :src="message?.avatar || DefaultAvatarIcon" />
<!-- 消息内容 -->
<view class="message-content">
<!-- 群聊用户名显示 -->
<view v-if="activeConversation?.type === 'GROUP' && message?.flow === 'in'" class="sender-name">
{{ getDisplayName(message) }}
</view>
<!-- 文本消息 -->
<TextMessage v-if="message?.type === MessageType.MSG_TEXT" :message="message" />
<!-- 通话消息 -->
<CustomMessage v-if="message?.type === MessageType.MSG_CUSTOM" :message="message" />
<!-- 图片消息 -->
<ImageMessage v-if="message?.type === MessageType.MSG_IMAGE" :message="message" />
<!-- 视频消息 -->
<VideoMessage v-if="message?.type === MessageType.MSG_VIDEO" :message="message" />
</view>
<!-- 消息状态 -->
<MessageStatus class="message-status" :message="message" />
</view>
</view>
</scroll-view>
<!-- 新消息提示条当有新消息且不在底部时显示 -->
<view v-if="showNewMessageTip" class="new-message-tip" @click="handleNewMessageTipClick">
<image :src="NewMessageTipIcon"></image>
{{ newMessageTipText }}
</view>
</div>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true,
}
}
</script>
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import Avatar from '../Avatar/Avatar.vue'
import TextMessage from './Message/TextMessage.vue';
import CustomMessage from './Message/CustomMessage.vue';
import ImageMessage from './Message/ImageMessage.vue';
import VideoMessage from './Message/VideoMessage.vue';
import GroupTipMessage from './Message/GroupTipMessage.vue';
import MessageStatus from './MessageStatus/MessageStatus.vue';
import MessageTimestamp from './MessageTimeDivider/MessageTimeDivider.vue';
import DefaultAvatarIcon from '../../assets/base/default-avatar.png';
import NewMessageTipIcon from '../../assets/chat/new-message.svg';
import { useMessageListState, useConversationListState } from '../../chat';
import { isCallSignaling } from '../../utils/processCallSignaling';
import { MessageType } from '../../constants/chat'
const { setActiveConversation, activeConversation } = useConversationListState()
const { messageList, loadMoreOlderMessage, hasMoreOlderMessage } = useMessageListState();
const scrollViewRef = ref();
const showNewMessageTip = ref(false);
const newMessageCount = ref(0);
const autoScroll = ref(true);
const isLoadingOlderMessage = ref(false);
const showNoMoreTip = ref(false);
const historyFirstMessageID = ref<string>('');
const currentMessage = ref('');
const avatarStyle = ref({
width: '40px',
height: '40px',
borderRadius: '5px'
})
// 获取显示的用户名
const getDisplayName = (message: any) => {
const nameCard = message?.nameCard || '';
const senderNick = message?.nick || '';
const senderUserID = message?.from || '';
// 如果是群聊,按优先级显示:nameCard > nick > userID
if (activeConversation.value?.type === 'GROUP') {
return nameCard || senderNick || senderUserID;
}
// 如果不是群聊,只显示昵称
return senderNick || senderUserID;
};
const isSupportedMessageType = (message) => {
const type = message?.type
return [
MessageType.MSG_TEXT,
MessageType.MSG_CUSTOM,
MessageType.MSG_IMAGE,
MessageType.MSG_VIDEO
].includes(type) && !shouldRenderAsGroupTip(message)
}
const newMessageTipText = computed(() => {
return `${newMessageCount.value}条新消息`
})
const scrollToBottom = () => {
if (messageList.value?.length) {
const lastMessage = messageList.value[messageList.value.length - 1]
const targetId = `msg-${lastMessage.ID}`
// Clear and re-set to ensure scroll triggers even when value is the same
if (currentMessage.value === targetId) {
currentMessage.value = ''
setTimeout(() => {
currentMessage.value = targetId
}, 50)
} else {
currentMessage.value = targetId
}
}
}
const handleNewMessageTipClick = () => {
showNewMessageTip.value = false
newMessageCount.value = 0
autoScroll.value = true
scrollToBottom()
}
const handleScrollToTop = async (e) => {
if (isLoadingOlderMessage.value) return;
if (!hasMoreOlderMessage.value) {
showNoMoreTip.value = true;
setTimeout(() => {
showNoMoreTip.value = false;
}, 1000);
return;
}
isLoadingOlderMessage.value = true;
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
await loadMoreOlderMessage();
historyFirstMessageID.value = currentFirstMessageID;
isLoadingOlderMessage.value = false;
}
const handleScrollToBottom = () => {
showNewMessageTip.value = false
}
onMounted(() => {
scrollToBottom()
// Listen for keyboard pop-up to auto scroll to bottom
uni.$on('TUIChat:keyboardHeightChange', () => {
scrollToBottom()
})
})
onUnmounted(() => {
setActiveConversation('')
uni.$off('TUIChat:keyboardHeightChange')
})
watch(messageList, (newVal, oldVal) => {
if (!newVal?.length) return
const lastMessage = newVal[newVal.length - 1]
const isMyMessage = lastMessage?.flow === 'out'
if (isMyMessage) {
scrollToBottom()
} else {
if (autoScroll.value) {
// 新消息如果在底部一个屏幕范围内,自动滚动
scrollToBottom()
showNewMessageTip.value = false
newMessageCount.value = 0
} else {
// 否则显示新消息提示
newMessageCount.value += 1
showNewMessageTip.value = true
}
}
}, { deep: true });
const shouldRenderAsGroupTip = (message: any) => {
// 创建群消息
if (message.type === MessageType.MSG_CUSTOM && message.getMessageContent().businessID === 'group_create') {
return true;
}
// 群通话消息
if (
message.type === MessageType.MSG_CUSTOM
&& isCallSignaling(message)
&& message.conversationType === "GROUP"
) {
return true;
}
// 群提示消息
if ( message.type === MessageType.MSG_GRP_TIP ) {
return true
}
return false;
};
</script>
<style lang="scss" scoped>
.message-container {
background-color: #F9FAFC;
display: flex;
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
}
.loading-tip {
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
color: #999;
font-size: 14px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes rotating {
from {
transform: rotate(0deg)
}
to {
transform: rotate(360deg)
}
}
.new-message-tip {
position: absolute;
bottom: 50px;
right: 10px;
background-color: #FFFFFF;
color: #1C66E5;
padding: 8px 16px;
border-radius: 3px;
font-size: 12px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
white-space: nowrap;
image {
width: 12px;
height: 11px;
margin-right: 5px;
}
}
.message-list {
flex: 1;
overflow: auto;
margin: 8px;
overscroll-behavior: contain;
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
}
-webkit-overflow-scrolling: touch;
}
.message-item {
display: flex;
margin: 7px 0;
&.message-item-me {
flex-direction: row-reverse;
}
}
.message-status {
position: relative;
}
.message-content {
margin: 0 20rpx;
max-width: 70%;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
line-height: 1.2;
}
</style>
@@ -0,0 +1,68 @@
<template>
<view v-if="message?.flow === 'out' && message?.status !== 'success'" class="message-status" :class="positionClass">
<view v-if="message?.status === 'unSend'" class="status-loading">
<image :src="IMG_LOADING" />
</view>
<view v-if="message?.status === 'fail'" class="status-fail">
<text>!</text>
</view>
</view>
</template>
<script lang="ts" setup>
import IMG_LOADING from '../../../assets/chat/message-loading.svg';
const { message } = defineProps({
message: {
type: Object,
required: true
}
})
</script>
<style lang="scss" scoped>
.message-status {
position: absolute;
bottom: 14px;
right: -4px;
}
.status-loading {
width: 20px;
height: 20px;
image {
width: 100%;
height: 100%;
animation: rotating 1s linear infinite;
}
}
.status-fail {
width: 16px;
height: 16px;
background-color: #ff4d4f;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text {
color: white;
font-size: 12px;
font-weight: bold;
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,91 @@
<template>
<div
v-if="timestampShowFlag"
class="message-timestamp"
>
{{ timestampShowContent }}
</div>
</template>
<script setup lang="ts">
import { toRefs, ref, watch } from 'vue';
import { calculateTimestamp } from '../../../utils/time';
const props = defineProps({
currTime: {
type: Number,
default: 0,
},
prevTime: {
type: Number,
default: 0,
},
});
const { currTime, prevTime } = toRefs(props);
const timestampShowFlag = ref(false);
const timestampShowContent = ref('');
const handleItemTime = (currTime: number, prevTime: number) => {
timestampShowFlag.value = false;
if (currTime <= 0) return '';
const minDiffToShow = 5 * 60;
// 第一条消息必显示
if (!prevTime || prevTime <= 0) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
// 计算时间差(秒)
const diff = currTime - prevTime;
// 超过5分钟显示
if (diff >= minDiffToShow) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
// 跨天必显示(即使间隔<5分钟)
const currDate = new Date(currTime * 1000);
const prevDate = new Date(prevTime * 1000);
if (currDate.getDate() !== prevDate.getDate() ||
currDate.getMonth() !== prevDate.getMonth() ||
currDate.getFullYear() !== prevDate.getFullYear()) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
return '';
};
watch(
() => [currTime.value, prevTime.value],
(newVal: any, oldVal: any) => {
if (newVal?.toString() === oldVal?.toString()) {
return;
} else {
timestampShowContent.value = handleItemTime(
currTime.value,
prevTime.value,
);
}
},
{
immediate: true,
},
);
</script>
<style lang="scss" scoped>
.message-timestamp {
width: 100%;
margin: 15px 0;
color: #BBBBBB;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
</style>
@@ -0,0 +1,117 @@
// @ts-nocheck
import type { UIKitModalOptions, UIKitModalResult } from './type';
import { TUICallKitServer } from '../../states/TUICallService/index';
const URL_REGEX = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/i;
const FULL_URL_REGEX = /(https?:\/\/[^\s]+)/g;
function isDevEnvironment(): boolean {
const accountInfo = uni.getAccountInfoSync?.();
const envVersion = accountInfo?.miniProgram?.envVersion;
return envVersion === 'develop' || envVersion === 'trial';
}
function extractUrlFromContent(content: string): string | null {
if (!content || typeof content !== 'string') {
return null;
}
if (content.includes('<a ') || content.includes('<a>')) {
const hrefMatch = content.match(/href=["']([^"']+)["']/);
return hrefMatch ? hrefMatch[1] : null;
}
if (URL_REGEX.test(content.trim())) {
return content.trim();
}
const urlMatch = content.match(FULL_URL_REGEX);
return urlMatch ? urlMatch[0] : null;
}
function processContent(content: string): string {
if (!content || typeof content !== 'string') {
return '';
}
let text = content.replace(/<[^>]+>/g, '');
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"');
text = text.replace(/\s+/g, ' ').trim();
const url = extractUrlFromContent(content);
if (url && !text.includes(url)) {
return text ? `${text}\n${url}` : url;
}
return text;
}
function reportModalView(options: UIKitModalOptions): void {
try {
const tim = TUICallKitServer?.getTim?.();
if (tim && typeof tim.callExperimentalAPI === 'function') {
const reportData = {
id: options.id,
title: options.title,
type: options.type,
content: options.content,
platform: 'miniprogram',
};
tim.callExperimentalAPI('reportModalView', JSON.stringify(reportData));
}
} catch (error) {
console.warn('[UIKitModal] reportModalView failed:', error);
}
}
const createUIKitModal = (options: UIKitModalOptions): Promise<UIKitModalResult> => {
return new Promise((resolve) => {
reportModalView(options);
if (!isDevEnvironment()) {
resolve({ action: 'confirm' });
return;
}
const url = extractUrlFromContent(options.content);
const processedContent = processContent(options.content);
const hasLink = !!url;
uni.showModal({
title: options.title || '',
content: processedContent,
cancelText: '取消',
confirmText: hasLink ? '复制链接' : '确认',
success: (res) => {
const action = res.confirm ? 'confirm' : 'cancel';
if (res.confirm) {
if (hasLink && url) {
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' });
},
});
}
options.onConfirm?.();
} else {
options.onCancel?.();
}
resolve({ action, raw: res });
},
fail: () => {
options.onCancel?.();
resolve({ action: 'cancel' });
},
});
});
};
export const UIKitModal = {
openModal: (config: UIKitModalOptions) => createUIKitModal(config),
};
@@ -0,0 +1,2 @@
export { UIKitModal } from './UIKitModal';
export type { UIKitModalOptions, UIKitModalResult, ModalType } from './type';
@@ -0,0 +1,16 @@
// @ts-nocheck
export type ModalType = 'info' | 'warning' | 'error' | 'success';
export interface UIKitModalOptions {
id: number;
title: string;
content: string;
type: ModalType;
onConfirm?: () => void;
onCancel?: () => void;
}
export interface UIKitModalResult {
action: 'confirm' | 'cancel';
raw?: UniApp.ShowModalRes;
}
@@ -0,0 +1,697 @@
<template>
<view class="user-picker">
<!-- 搜索栏 -->
<view v-if="enableSearch" class="search-bar">
<view class="search-input-container">
<input class="search-input" :placeholder="searchPlaceholder" v-model="searchKeyword" @confirm="handleSearch" />
<view class="search-icon" @click="handleSearch">🔍</view>
</view>
</view>
<!-- 用户列表区域 -->
<view class="list-container">
<scroll-view class="user-list" scroll-y :style="listStyle">
<!-- 空状态 -->
<view v-if="allUsers.length === 0" class="empty-state">
<text class="empty-text">{{ emptyText }}</text>
</view>
<!-- 已搜索到的用户列表 -->
<view v-for="user in allUsers" :key="user.userID" class="user-item" @click="toggleUserSelection(user)">
<view v-if="!readOnly" class="checkbox">
<view class="checkbox-icon" :class="{ 'checked': selectedUsers.has(user.userID) }">
<text v-if="selectedUsers.has(user.userID)" class="checkmark"></text>
</view>
</view>
<!-- 用户头像 -->
<view class="user-avatar">
<image :src="user.avatar || defaultAvatarIcon" class="avatar-img" />
</view>
<!-- 用户信息 -->
<view class="user-info">
<text class="user-nick">{{ user.nick || user.userID }}</text>
<text class="user-id">{{ user.userID }}</text>
</view>
<!-- 删除按钮 -->
<view v-if="enableDelete" class="delete-btn" @click.stop="removeUser(user.userID)">
<text>×</text>
</view>
</view>
</scroll-view>
</view>
<!-- 底部操作栏-->
<view v-if="!readOnly" class="bottom-bar">
<view class="selected-count">
已选 {{ selectedUsers.size }}
</view>
<button class="confirm-btn" :class="{ 'disabled': selectedUsers.size === 0 }" @click="handleConfirm"
:disabled="selectedUsers.size === 0">
确定
</button>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted, watch } from 'vue';
import TUIChatEngine from '../../states/chat-uikit-engine-lite';
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
interface User {
userID: string
nick?: string
avatar?: string
}
interface Props {
maxCount?: number
readOnly?: boolean
enableSearch?: boolean
enableDelete?: boolean
dataSource?: User[]
excludeUserIDs?: string[]
mode?: 'select' | 'remove' // 模式:select-选择模式(排除指定用户),remove-删除模式(显示所有用户)
inModal?: boolean // 是否在弹窗中使用
}
const emit = defineEmits<{
confirm: [selectedUserIDs: string[]]
}>()
const props = withDefaults(defineProps<Props>(), {
maxCount: Number.POSITIVE_INFINITY,
readOnly: false,
enableSearch: true,
enableDelete: true,
dataSource: [],
excludeUserIDs: () => [],
mode: 'select',
inModal: false
})
const searchKeyword = ref('')
const allUsers = ref<User[]>([])
const selectedUsers = reactive(new Set<string>())
// 根据模式决定是否应用排除逻辑
const shouldApplyExclude = computed(() => props.mode === 'select')
const listStyle = computed(() => {
if (props.inModal) {
// 在弹窗中:使用 calc 计算可用高度,避免大片空白
return {
height: 'calc(90vh - 160rpx - 120rpx)' // 90vh - 搜索栏(160rpx) - 底部操作栏(120rpx)
}
} else {
// 全屏模式:使用整个视口高度
return {
height: 'calc(100vh - 128rpx - 120rpx)' // 减去搜索栏(128rpx)和底部操作栏(120rpx)的高度
}
}
})
const searchPlaceholder = computed(() => {
if (props.mode === 'remove') {
return '搜索群成员昵称或ID'
}
return props.dataSource.length > 0 ? '搜索群成员昵称或ID' : '输入用户ID进行搜索添加'
})
const emptyText = computed(() => {
if (props.mode === 'remove') {
return '暂无群成员'
}
return props.dataSource.length > 0 ? '暂无群成员' : '暂无用户,请搜索添加'
})
onMounted(() => {
allUsers.value = shouldApplyExclude.value
? props.dataSource?.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
})
// 监听 dataSource 变化
watch(() => props.dataSource, (newDataSource) => {
allUsers.value = shouldApplyExclude.value
? newDataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...newDataSource]
}, { deep: true })
// 监听 excludeUserIDs 变化
watch(() => props.excludeUserIDs, () => {
allUsers.value = shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}, { deep: true })
// 监听 mode 变化
watch(() => props.mode, () => {
allUsers.value = shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}, { deep: true })
const searchUsers = async (keyword: string): Promise<User[]> => {
if (!keyword.trim()) {
// 如果没有关键词,根据模式返回相应的数据源
return shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}
// 如果有dataSource,在dataSource中搜索
if (props.dataSource.length > 0) {
const filteredUsers = props.dataSource.filter(user => {
const matchesKeyword = user.userID.includes(keyword) ||
(user.nick && user.nick.includes(keyword))
// 根据模式决定是否应用排除逻辑
return shouldApplyExclude.value
? !props.excludeUserIDs.includes(user.userID) && matchesKeyword
: matchesKeyword
})
if (filteredUsers.length === 0) {
uni.showToast({
title: '未找到匹配的群成员',
icon: 'none'
})
}
return filteredUsers
} else {
// 如果没有dataSource,进行全局搜索
const currentUserID = TUIChatEngine.getMyUserID();
// 在选择模式下,检查是否搜索自己或排除列表中的用户
if (shouldApplyExclude.value && (keyword === currentUserID || props.excludeUserIDs.includes(keyword))) {
uni.showToast({
title: keyword === currentUserID ? '不能搜索自己' : '用户已在群中',
icon: 'none'
})
return []
}
// 在删除模式下,只检查是否搜索自己
if (!shouldApplyExclude.value && keyword === currentUserID) {
uni.showToast({
title: '不能删除自己',
icon: 'none'
})
return []
}
try {
const res = await TUIChatEngine.TUIUser.getUserProfile({ userIDList: [keyword] })
if (res.data.length > 0) {
const { nick = '', avatar = '' } = res.data[0]
return [
{
userID: keyword,
nick,
avatar: avatar || defaultAvatarIcon
}
]
} else {
uni.showToast({
title: '用户不存在',
icon: 'none'
})
return []
}
} catch (error) {
console.error('搜索用户失败:', error)
uni.showToast({
title: '搜索失败',
icon: 'none'
})
return []
}
}
}
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
try {
const results = await searchUsers(keyword)
if (props.dataSource.length > 0) {
// 有dataSource时,直接显示搜索结果(过滤模式)
allUsers.value = results
} else {
// 无dataSource时,添加模式
if (results.length > 0) {
// 检查是否已经存在该用户
const existingUser = allUsers.value.find(user => user.userID === keyword)
if (!existingUser) {
// 将新用户添加到现有列表中
allUsers.value = [...allUsers.value, ...results]
} else {
uni.showToast({
title: '用户已存在',
icon: 'none'
})
}
}
}
// 搜索完成后清除输入框内容
searchKeyword.value = ''
} catch (error) {
console.error('搜索用户失败:', error)
}
}
const toggleUserSelection = (user: User) => {
if (props.readOnly) return
if (selectedUsers.has(user.userID)) {
selectedUsers.delete(user.userID)
} else {
if (selectedUsers.size >= props.maxCount) {
uni.showToast({
title: `最多只能选择 ${props.maxCount} 个用户`,
icon: 'none'
})
return
}
if (props.maxCount === 1) {
selectedUsers.clear()
}
selectedUsers.add(user.userID)
}
}
const removeUser = (userID: string) => {
allUsers.value = allUsers.value.filter(user => user.userID !== userID)
if (selectedUsers.has(userID)) {
selectedUsers.delete(userID)
}
}
const handleConfirm = async () => {
if (selectedUsers.size === 0) return
const selectedUserIDs = Array.from(selectedUsers)
emit('confirm', selectedUserIDs)
}
defineExpose({
handleConfirm
})
</script>
<style lang="scss" scoped>
.user-picker {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
}
.user-picker[in-modal="true"] {
height: 90vh;
max-height: 800px;
}
.search-bar {
padding: 24rpx;
background-color: #fff;
border-bottom: 1rpx solid #e8e8e8;
flex-shrink: 0;
}
.search-input-container {
position: relative;
display: flex;
align-items: center;
}
.search-input {
flex: 1;
height: 80rpx;
padding: 0 80rpx 0 30rpx;
background-color: #f0f2f7;
border-radius: 40rpx;
font-size: 28rpx;
border: 1rpx solid #e0e0e0;
}
.search-icon {
position: absolute;
right: 30rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
cursor: pointer;
}
.list-container {
flex: 1;
background-color: #fff;
display: flex;
flex-direction: column;
min-height: 0;
}
.user-list {
width: 100%;
background-color: #fff;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 300rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.user-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
position: relative;
transition: background-color 0.2s;
}
.user-item:active {
background-color: #f8f9fa;
}
.checkbox {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.checkbox-icon {
width: 36rpx;
height: 36rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.checked {
border-color: #07c160;
background-color: #07c160;
}
}
.checkmark {
color: #fff;
font-size: 20rpx;
font-weight: bold;
}
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
margin-right: 20rpx;
background-color: #f0f2f5;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
}
.user-nick {
font-size: 32rpx;
color: #1a1a1a;
font-weight: 500;
margin-bottom: 8rpx;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.user-id {
font-size: 24rpx;
color: #8c8c8c;
line-height: 1.2;
}
.delete-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 36rpx;
font-weight: bold;
border-radius: 50%;
transition: all 0.2s;
}
.delete-btn:active {
background-color: #ffebee;
color: #ff4444;
}
.bottom-bar {
height: 120rpx;
background-color: #fff;
border-top: 1rpx solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 30rpx;
flex-shrink: 0;
position: sticky;
bottom: 0;
z-index: 10;
}
.selected-count {
font-size: 28rpx;
color: #666;
flex: 1;
}
.confirm-btn {
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
height: 72rpx;
padding: 0 40rpx;
transition: all 0.2s;
&.disabled {
background-color: #dcdfe6;
color: #c0c4cc;
}
}
.confirm-btn:not(.disabled):active {
background-color: #06a854;
transform: scale(0.98);
}
/* 群聊表单弹出层样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-height: 80vh;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
box-sizing: border-box;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 30rpx 24rpx;
border-bottom: 1rpx solid #e8e8e8;
position: relative;
box-sizing: border-box;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
flex: 1;
text-align: center;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
position: absolute;
right: 30rpx;
}
.form-container {
flex: 1;
max-height: 60vh;
padding: 30rpx;
box-sizing: border-box;
overflow: hidden;
}
.form-item {
margin-bottom: 32rpx;
box-sizing: border-box;
}
.form-label {
display: block;
font-size: 28rpx;
color: #1a1a1a;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-picker {
width: 100%;
height: 80rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
cursor: pointer;
}
.picker-value {
font-size: 28rpx;
color: #1a1a1a;
width: 100vw;
height: 100%;
display: flex;
align-items: center;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
line-height: 1.5;
box-sizing: border-box;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 30rpx 40rpx;
border-top: 1rpx solid #e8e8e8;
box-sizing: border-box;
}
.cancel-btn {
flex: 1;
height: 80rpx;
background-color: #f8f9fa;
color: #666;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-btn {
flex: 1;
height: 80rpx;
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.cancel-btn:active {
background-color: #e8e8e8;
}
.submit-btn:active {
background-color: #06a854;
}
</style>