更新
This commit is contained in:
@@ -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>
|
||||
+31
@@ -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>
|
||||
|
||||
+91
@@ -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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/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>
|
||||
Reference in New Issue
Block a user