需要添加直播接口
This commit is contained in:
571
uni_modules/tuikit-atomic-x/components/AudioEffectPanel.nvue
Normal file
571
uni_modules/tuikit-atomic-x/components/AudioEffectPanel.nvue
Normal file
@@ -0,0 +1,571 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }" @click.stop>
|
||||
<view class="drawer-header">
|
||||
<text class="drawer-title">音效设置</text>
|
||||
<text class="drawer-done" @tap="close">完成</text>
|
||||
</view>
|
||||
|
||||
<!-- 耳返开关 -->
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">耳返</text>
|
||||
<switch :checked="earMonitor" @change="handleEarMonitorChange" class="setting-switch" color="#2B65FB"
|
||||
style="transform:scale(0.7)" />
|
||||
</view>
|
||||
|
||||
<!-- 背景音乐 -->
|
||||
<!-- <view class="setting-item">
|
||||
<text class="setting-label">背景音乐</text>
|
||||
<view class="setting-value" @tap="handleSelectMusic">
|
||||
<text class="setting-text">选择音乐</text>
|
||||
<image class="setting-arrow" src="/static/images/right-arrow.png" mode="aspectFit" />
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 音频设置 -->
|
||||
<view class="volume-settings">
|
||||
<text class="section-title">音频设置</text>
|
||||
|
||||
<view class="slider-item">
|
||||
<text class="slider-label">耳返音量</text>
|
||||
<view class="custom-slider">
|
||||
<!-- 减号按钮 -->
|
||||
<view class="control-btn minus-btn" @tap="decreaseMusicVolume">
|
||||
<text class="btn-text">-</text>
|
||||
</view>
|
||||
|
||||
<!-- 进度条区域 -->
|
||||
<view class="progress-section">
|
||||
<!-- 进度条背景 -->
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: (musicVolume / 100 * 400) + 'rpx' }"></view>
|
||||
</view>
|
||||
<!-- 当前数值显示 -->
|
||||
<text class="current-value">{{ musicVolume }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 加号按钮 -->
|
||||
<view class="control-btn plus-btn" @tap="increaseMusicVolume">
|
||||
<text class="btn-text">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
|
||||
<!-- <view class="slider-item">
|
||||
<text class="slider-label">音乐升降调</text>
|
||||
<text class="slider-value">{{ pitchValue }}</text>
|
||||
<slider
|
||||
:value="pitchValue"
|
||||
@change="handlePitchChange"
|
||||
min="-12"
|
||||
max="12"
|
||||
show-value
|
||||
activeColor="#2B65FB"
|
||||
backgroundColor="rgba(255, 255, 255, 0.1)"
|
||||
block-color="#FFFFFF"
|
||||
block-size="24"
|
||||
/>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<!-- 变声效果 -->
|
||||
<view class="voice-effects">
|
||||
<text class="section-title">变声</text>
|
||||
<view class="effects-grid">
|
||||
<view v-for="(effect, index) in voiceEffects" :key="index" class="effect-item"
|
||||
@tap="handleEffectChange(effect.key)">
|
||||
<view class="effect-icon-container" :class="{ 'effect-active': currentEffect === effect.key }">
|
||||
<image class="effect-icon" :src="effect.icon" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="effect-name">{{ effect.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 混响效果 -->
|
||||
<view class="reverb-effects" style="margin-bottom: 40rpx;">
|
||||
<text class="section-title">混响</text>
|
||||
<view class="effects-grid">
|
||||
<view v-for="(effect, index) in reverbEffects" :key="index" class="effect-item"
|
||||
@tap="handleReverbChange(effect.key)">
|
||||
<view class="effect-icon-container" :class="{ 'effect-active': currentReverb === effect.key }">
|
||||
<image class="effect-icon" :src="effect.icon" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="effect-name">{{ effect.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
watch
|
||||
} from 'vue';
|
||||
import {
|
||||
useAudioEffectState
|
||||
} from '@/uni_modules/tuikit-atomic-x/state/AudioEffectState'
|
||||
|
||||
const {
|
||||
setVoiceEarMonitorEnable,
|
||||
setVoiceEarMonitorVolume,
|
||||
setAudioChangerType,
|
||||
setAudioReverbType,
|
||||
isEarMonitorOpened,
|
||||
earMonitorVolume,
|
||||
audioChangerType,
|
||||
audioReverbType
|
||||
} = useAudioEffectState(uni.$liveID)
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const earMonitor = ref(false);
|
||||
const musicVolume = ref(0);
|
||||
const voiceVolume = ref(0);
|
||||
const pitchValue = ref(0);
|
||||
const safeArea = ref({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 375,
|
||||
height: 750,
|
||||
});
|
||||
const currentEffect = ref('NONE');
|
||||
const currentReverb = ref('NONE');
|
||||
|
||||
const voiceEffects = [{
|
||||
key: 'NONE',
|
||||
name: '原声',
|
||||
icon: '/static/images/no-effect.png'
|
||||
},
|
||||
{
|
||||
key: 'CHILD',
|
||||
name: '熊孩子',
|
||||
icon: '/static/images/voice-wild.png'
|
||||
},
|
||||
{
|
||||
key: 'LITTLE_GIRL',
|
||||
name: '萝莉',
|
||||
icon: '/static/images/voice-loli.png'
|
||||
},
|
||||
{
|
||||
key: 'MAN',
|
||||
name: '大叔',
|
||||
icon: '/static/images/voice-uncle.png'
|
||||
},
|
||||
{
|
||||
key: 'ETHEREAL',
|
||||
name: '空灵',
|
||||
icon: '/static/images/voice-ghost.png'
|
||||
}
|
||||
];
|
||||
|
||||
const reverbEffects = [{
|
||||
key: 'NONE',
|
||||
name: '无效果',
|
||||
icon: '/static/images/no-effect.png'
|
||||
},
|
||||
{
|
||||
key: 'KTV',
|
||||
name: 'Karaoke',
|
||||
icon: '/static/images/reverb-ktv.png'
|
||||
},
|
||||
{
|
||||
key: 'METALLIC',
|
||||
name: '金属声',
|
||||
icon: '/static/images/reverb-metal.png'
|
||||
},
|
||||
{
|
||||
key: 'DEEP',
|
||||
name: '低沉',
|
||||
icon: '/static/images/reverb-bass.png'
|
||||
},
|
||||
{
|
||||
key: 'LOUD',
|
||||
name: '洪亮',
|
||||
icon: '/static/images/reverb-bright.png'
|
||||
}
|
||||
];
|
||||
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
// 同步状态数据到UI
|
||||
const syncStateToUI = () => {
|
||||
try {
|
||||
console.log('开始同步状态数据...');
|
||||
console.log('状态管理器中的值:', {
|
||||
isEarMonitorOpened: isEarMonitorOpened.value,
|
||||
earMonitorVolume: earMonitorVolume.value,
|
||||
audioChangerType: audioChangerType.value,
|
||||
audioReverbType: audioReverbType.value
|
||||
});
|
||||
|
||||
// 同步耳返状态
|
||||
earMonitor.value = isEarMonitorOpened.value;
|
||||
|
||||
// 同步耳返音量
|
||||
musicVolume.value = earMonitorVolume.value;
|
||||
|
||||
|
||||
// 同步变声类型 - 确保值有效
|
||||
if (audioChangerType.value && typeof audioChangerType.value === 'string') {
|
||||
currentEffect.value = audioChangerType.value;
|
||||
console.log('变声类型同步:', audioChangerType.value);
|
||||
} else {
|
||||
console.warn('变声类型无效:', audioChangerType.value);
|
||||
currentEffect.value = 'NONE';
|
||||
}
|
||||
|
||||
// 同步混响类型 - 确保值有效
|
||||
if (audioReverbType.value && typeof audioReverbType.value === 'string') {
|
||||
currentReverb.value = audioReverbType.value;
|
||||
console.log('混响类型同步:', audioReverbType.value);
|
||||
} else {
|
||||
console.warn('混响类型无效:', audioReverbType.value);
|
||||
currentReverb.value = 'NONE';
|
||||
}
|
||||
|
||||
console.log('同步后的本地状态:', {
|
||||
earMonitor: earMonitor.value,
|
||||
musicVolume: musicVolume.value,
|
||||
voiceVolume: voiceVolume.value,
|
||||
currentEffect: currentEffect.value,
|
||||
currentReverb: currentReverb.value
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('同步状态数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('AudioEffectPanel mounted');
|
||||
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
safeArea.value = res.safeArea;
|
||||
}
|
||||
});
|
||||
|
||||
// 延迟同步状态数据,确保状态管理器已初始化
|
||||
setTimeout(() => {
|
||||
syncStateToUI();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// 监听抽屉打开状态,每次打开时同步数据
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal) {
|
||||
console.log('抽屉打开,同步状态数据');
|
||||
// 抽屉打开时同步状态数据
|
||||
setTimeout(() => {
|
||||
syncStateToUI();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
const handleEarMonitorChange = (e) => {
|
||||
earMonitor.value = e.detail.value;
|
||||
setVoiceEarMonitorEnable({
|
||||
enable: earMonitor.value
|
||||
})
|
||||
};
|
||||
|
||||
const handleSelectMusic = () => {
|
||||
// TODO: 实现选择音乐逻辑
|
||||
console.log('选择音乐');
|
||||
};
|
||||
|
||||
const handleMusicVolumeChange = (e) => {
|
||||
musicVolume.value = e.detail.value;
|
||||
setVoiceEarMonitorVolume({
|
||||
volume: musicVolume.value
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
const handlePitchChange = (e) => {
|
||||
pitchValue.value = e.detail.value;
|
||||
};
|
||||
|
||||
const handleEffectChange = (effect) => {
|
||||
console.log('选择效果:', effect, '当前效果:', currentEffect.value);
|
||||
currentEffect.value = effect;
|
||||
setAudioChangerType({
|
||||
changerType: currentEffect.value
|
||||
})
|
||||
};
|
||||
|
||||
const handleReverbChange = (reverb) => {
|
||||
console.log('选择混响:', reverb, '当前混响:', currentReverb.value);
|
||||
currentReverb.value = reverb;
|
||||
setAudioReverbType({
|
||||
reverbType: currentReverb.value
|
||||
})
|
||||
};
|
||||
|
||||
// 音量控制方法
|
||||
const decreaseMusicVolume = () => {
|
||||
const newValue = Math.max(0, musicVolume.value - 10)
|
||||
handleMusicVolumeChange({
|
||||
detail: {
|
||||
value: newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const increaseMusicVolume = () => {
|
||||
const newValue = Math.min(100, musicVolume.value + 10)
|
||||
handleMusicVolumeChange({
|
||||
detail: {
|
||||
value: newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 32rpx;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-done {
|
||||
font-size: 32rpx;
|
||||
color: #2B65FB;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 28rpx;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-text {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.setting-arrow {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
.volume-settings {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.slider-item {
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
font-size: 28rpx;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
/* 自定义控制区域样式 */
|
||||
.custom-slider {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 2rpx solid #2b6ad6;
|
||||
}
|
||||
|
||||
.minus-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(43, 106, 214, 0.1);
|
||||
}
|
||||
|
||||
.plus-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2b6ad6;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.plus-btn .btn-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
flex: 1;
|
||||
margin: 0 20rpx;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 400rpx;
|
||||
height: 8rpx;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 8rpx;
|
||||
background-color: #2b6ad6;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.current-value {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.voice-effects,
|
||||
.reverb-effects {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.effects-grid {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -8rpx;
|
||||
}
|
||||
|
||||
.effect-item {
|
||||
|
||||
margin: 8rpx;
|
||||
/* background-color: rgba(255, 255, 255, 0.1); */
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.effect-active {
|
||||
background-color: rgba(43, 101, 251, 0.2);
|
||||
border-width: 2rpx;
|
||||
border-color: #2B65FB;
|
||||
}
|
||||
|
||||
.effect-icon-container {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 16rpx;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.effect-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.effect-name {
|
||||
font-size: 24rpx;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
83
uni_modules/tuikit-atomic-x/components/BarrageInput.vue
Normal file
83
uni_modules/tuikit-atomic-x/components/BarrageInput.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<input class="input-wrapper" v-model="inputValue" placeholder="说点什么…" confirm-type="send" @confirm="sendMessage">
|
||||
</input>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref, watch
|
||||
} from "vue";
|
||||
import {
|
||||
useBarrageState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/BarrageState";
|
||||
import { useLiveAudienceState } from '@/uni_modules/tuikit-atomic-x/state/LiveAudienceState';
|
||||
const {
|
||||
sendTextMessage,
|
||||
} = useBarrageState(uni?.$liveID);
|
||||
const { audienceList } = useLiveAudienceState(uni?.$liveID);
|
||||
const inputValue = ref("");
|
||||
const isDisableSendMessage = ref(false);
|
||||
const sendMessage = (event : any) => {
|
||||
if (isDisableSendMessage.value) {
|
||||
uni.showToast({ title: '当前直播间内,您已被禁言', icon: 'none' });
|
||||
inputValue.value = '';
|
||||
return;
|
||||
}
|
||||
const value = event.detail.value;
|
||||
console.warn('inputValue = ', inputValue, uni?.$liveID);
|
||||
sendTextMessage({
|
||||
liveID: uni?.$liveID,
|
||||
text: value,
|
||||
success: () => {
|
||||
console.log('sendTextMessage success');
|
||||
},
|
||||
fail: (code, msg) => {
|
||||
console.error(`sendTextMessage failed, code: ${code}, msg: ${msg}`);
|
||||
},
|
||||
})
|
||||
inputValue.value = ""
|
||||
};
|
||||
|
||||
watch(audienceList, (newValue, oldValue) => {
|
||||
(newValue || []).forEach((obj, index) => {
|
||||
if (obj?.userID === uni.$userID) {
|
||||
const oldUserInfo = (oldValue || [])[index] || {};
|
||||
if (obj.isMessageDisabled && !oldUserInfo?.isMessageDisabled) {
|
||||
isDisableSendMessage.value = true;
|
||||
uni.showToast({
|
||||
title: '当前房间内\n您已被禁言',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
position: 'center',
|
||||
});
|
||||
}
|
||||
if (!obj.isMessageDisabled && oldUserInfo?.isMessageDisabled) {
|
||||
isDisableSendMessage.value = false;
|
||||
uni.showToast({
|
||||
title: '当前房间内\n您已被解除禁言',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
position: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { immediate: true, deep: true });
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
background: rgba(15, 16, 20, 0.4);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 72rpx;
|
||||
padding-left: 16rpx;
|
||||
color: #ffffff;
|
||||
width: 260rpx;
|
||||
font-size: 28rpx;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
</style>
|
||||
418
uni_modules/tuikit-atomic-x/components/BarrageList.nvue
Normal file
418
uni_modules/tuikit-atomic-x/components/BarrageList.nvue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<view class="barrage-container">
|
||||
<!-- 聊天消息列表 -->
|
||||
<list class="chat-list" scroll-y :show-scrollbar="false" :style="{ bottom: bottomPx + 'px' }">
|
||||
<cell class="chat-item" v-if="mixMessageList.length > 0" v-for="(message) in mixMessageList"
|
||||
:key="message?.sequence" @tap="onItemTap(message)">
|
||||
<view class="message-content-wrapper">
|
||||
<view class="nickname-content-gift" v-if="message?.gift">
|
||||
<text class="chat-nickname"
|
||||
numberOfLines="1">{{ message?.sender?.userName || message?.sender?.userID }}:</text>
|
||||
<view class="gift-row">
|
||||
<text class="gift-prefix">{{ giftPrefix }}</text>
|
||||
<text class="gift-recipient">{{ giftReceiverName }}</text>
|
||||
<text class="gift-name">{{ message?.textContent || '' }}</text>
|
||||
<image class="gift-icon" :src="message?.gift?.iconURL || ''" alt="" />
|
||||
<text class="gift-count" style="font-size: 26rpx;">x{{ message?.count || '1' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nickname-content-wrapper" v-else>
|
||||
<view class="message-right" v-if="message?.sender?.userID === currentLive?.liveOwner.userID">
|
||||
<text class="message-role">主播</text>
|
||||
</view>
|
||||
<view class="nickname-content-wrapper">
|
||||
<text class="chat-nickname"
|
||||
numberOfLines="1">{{ message?.sender?.userName || message?.sender?.userID }}:</text>
|
||||
<text class="chat-content">{{ message?.textContent || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</cell>
|
||||
<cell ref="ListBottom" style="height: 50rpx;"></cell>
|
||||
</list>
|
||||
|
||||
<!-- GiftToast 提示 -->
|
||||
<view class="toast-container" v-for="toast in visibleToasts" :key="toast.id" :style="getToastStyle(toast)">
|
||||
<view class="toast-content">
|
||||
<!-- 左侧用户信息 -->
|
||||
<view class="user-info">
|
||||
<image class="user-avatar" :src="toast?.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
<view class="user-details">
|
||||
<text class="username">{{ toast?.name || '' }}</text>
|
||||
<text class="action-text">{{ toast?.desc || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧图标 -->
|
||||
<view class="icon-container" v-if="toast.iconURL">
|
||||
<image class="icon" :src="toast?.iconURL || ''" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useBarrageState } from "@/uni_modules/tuikit-atomic-x/state/BarrageState";
|
||||
import { useGiftState } from "@/uni_modules/tuikit-atomic-x/state/GiftState";
|
||||
import { useLiveListState } from "@/uni_modules/tuikit-atomic-x/state/LiveListState";
|
||||
|
||||
const props = defineProps<{
|
||||
mode ?: 'anchor' | 'audience',
|
||||
bottomPx ?: number,
|
||||
liveID ?: string,
|
||||
toast ?: any, // GiftToast 的 toast 属性
|
||||
}>();
|
||||
|
||||
const mode = computed(() => props.mode || 'audience');
|
||||
const bottomPx = computed(() => props.bottomPx ?? 0);
|
||||
|
||||
const liveID = computed(() => props.liveID || uni?.$liveID);
|
||||
|
||||
const { messageList } = useBarrageState(liveID.value);
|
||||
const { addGiftListener, removeGiftListener } = useGiftState(liveID.value);
|
||||
const { currentLive } = useLiveListState(liveID.value);
|
||||
|
||||
const mixMessageList = ref<any[]>(messageList.value || []);
|
||||
|
||||
const dom = uni.requireNativePlugin('dom');
|
||||
const ListBottom = ref('ListBottom');
|
||||
|
||||
const giftPrefix = computed(() => mode.value === 'anchor' ? '送给我' : '送给');
|
||||
const giftReceiverName = computed(() => {
|
||||
if (mode.value === 'anchor') return '';
|
||||
const owner = currentLive?.value?.liveOwner || {};
|
||||
return owner?.userName || owner?.userID || '';
|
||||
});
|
||||
|
||||
// GiftToast 相关状态和逻辑
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
const defaultPosition = {
|
||||
top: 'auto',
|
||||
bottom: 720,
|
||||
left: 32,
|
||||
right: 'auto',
|
||||
};
|
||||
const toastHeight = 105;
|
||||
|
||||
const visibleToasts = ref<any[]>([]);
|
||||
|
||||
const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
const showToast = (toastConfig : Partial<any>) => {
|
||||
const toast = {
|
||||
id: generateId(),
|
||||
duration: 3000,
|
||||
autoHide: true,
|
||||
position: {
|
||||
top: defaultPosition.top,
|
||||
bottom: defaultPosition.bottom + (visibleToasts.value || []).length * toastHeight,
|
||||
left: '32rpx',
|
||||
right: defaultPosition.bottom,
|
||||
},
|
||||
...toastConfig
|
||||
};
|
||||
|
||||
visibleToasts.value.push(toast);
|
||||
|
||||
if (toast.autoHide && toast.duration) {
|
||||
setTimeout(() => {
|
||||
hideToast(toast.id);
|
||||
}, toast.duration);
|
||||
}
|
||||
|
||||
return toast.id;
|
||||
};
|
||||
|
||||
const hideToast = (id : string) => {
|
||||
const index = visibleToasts.value.findIndex(toast => toast.id === id);
|
||||
if (index > -1) {
|
||||
const removedToast = visibleToasts.value.splice(index, 1)[0];
|
||||
emit('toastClosed', removedToast);
|
||||
}
|
||||
};
|
||||
|
||||
const hideAllToasts = () => {
|
||||
visibleToasts.value = [];
|
||||
};
|
||||
|
||||
const getToastStyle = (toast : any) => {
|
||||
const style : any = {
|
||||
position: 'fixed',
|
||||
zIndex: 999
|
||||
};
|
||||
|
||||
if (toast.position) {
|
||||
if (toast.position.top !== undefined) {
|
||||
style.top = typeof toast.position.top === 'number' ? `${toast.position.top}rpx` : toast.position.top;
|
||||
}
|
||||
if (toast.position.bottom !== undefined) {
|
||||
style.bottom = typeof toast.position.bottom === 'number' ? `${toast.position.bottom}rpx` : toast.position.bottom;
|
||||
}
|
||||
if (toast.position.left !== undefined) {
|
||||
style.left = typeof toast.position.left === 'number' ? `${toast.position.left}rpx` : toast.position.left;
|
||||
}
|
||||
if (toast.position.right !== undefined) {
|
||||
style.right = typeof toast.position.right === 'number' ? `${toast.position.right}rpx` : toast.position.right;
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
};
|
||||
|
||||
watch(messageList, (newVal, oldVal) => {
|
||||
if (!newVal) return;
|
||||
const value = newVal.slice((oldVal || []).length, (newVal || []).length);
|
||||
if (value.length > 0) {
|
||||
mixMessageList.value = [...mixMessageList.value, ...value];
|
||||
dom.scrollToElement(ListBottom.value);
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['itemTap', 'toastClosed']);
|
||||
const onItemTap = (message : any) => {
|
||||
emit('itemTap', message);
|
||||
};
|
||||
|
||||
const handleReceiveGift = {
|
||||
callback: (event) => {
|
||||
const res = JSON.parse(event)
|
||||
const value = {
|
||||
...res,
|
||||
textContent: `${res.gift?.name || ''}`,
|
||||
};
|
||||
mixMessageList.value = [...mixMessageList.value, value];
|
||||
dom.scrollToElement(ListBottom.value);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.toast && Object.keys(props.toast).length > 0) {
|
||||
showToast(props.toast);
|
||||
}
|
||||
addGiftListener(uni.$liveID, 'onReceiveGift', handleReceiveGift)
|
||||
});
|
||||
onUnmounted(() => {
|
||||
removeGiftListener(uni.$liveID, 'onReceiveGift', handleReceiveGift)
|
||||
});
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
showToast,
|
||||
hideToast,
|
||||
hideAllToasts
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.barrage-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
position: fixed;
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
bottom: 800rpx;
|
||||
height: 380rpx;
|
||||
width: 500rpx;
|
||||
}
|
||||
|
||||
/* GiftToast 样式 */
|
||||
.toast-container {
|
||||
/* 基础样式由getToastStyle动态设置 */
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(34, 38, 46, 0.4);
|
||||
padding: 20rpx 24rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
height: 100rpx;
|
||||
width: 320rpx;
|
||||
border-radius: 50rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
border-width: 2rpx;
|
||||
border-color: #8B5CF6;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4rpx;
|
||||
max-width: 120rpx;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
max-width: 120rpx;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
border-radius: 32rpx;
|
||||
padding: 6rpx;
|
||||
width: fit-content;
|
||||
max-width: 500rpx;
|
||||
}
|
||||
|
||||
.message-content-wrapper {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
max-width: 500rpx;
|
||||
width: fit-content;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 999rpx;
|
||||
padding: 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.nickname-content-wrapper {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nickname-content-gift {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
max-width: 500rpx;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
background-color: #0468FC;
|
||||
border-radius: 999px;
|
||||
margin-right: 5rpx;
|
||||
color: #fff;
|
||||
padding: 5rpx 15rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.chat-nickname {
|
||||
color: #80BEF6;
|
||||
font-size: 24rpx;
|
||||
line-height: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
padding: 5rpx 0;
|
||||
flex-shrink: 0;
|
||||
max-width: 200rpx;
|
||||
lines: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gift-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
max-width: 300rpx;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 2rpx;
|
||||
}
|
||||
|
||||
.gift-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gift-recipient,
|
||||
.gift-name {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 24rpx;
|
||||
font-weight: 500;
|
||||
z-index: 999;
|
||||
padding: 2rpx 0;
|
||||
max-width: 300rpx;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gift-prefix,
|
||||
.gift-count {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 24rpx;
|
||||
font-weight: 500;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 24rpx;
|
||||
font-weight: 500;
|
||||
z-index: 999;
|
||||
padding: 5rpx 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-indent: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
</style>
|
||||
557
uni_modules/tuikit-atomic-x/components/BeautyPanel.nvue
Normal file
557
uni_modules/tuikit-atomic-x/components/BeautyPanel.nvue
Normal file
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<!-- 美颜面板组件 -->
|
||||
<!-- 使用方式: <BeautyPanel v-model="showBeautyPanel" @adjust-beauty="handleAdjustBeauty" @reset="handleReset" /> -->
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }" @click.stop>
|
||||
<!-- 程度控制区域 -->
|
||||
<view class="slider-section" v-if="currentOptionName !== '关闭'">
|
||||
<view class="control-container">
|
||||
<view class="custom-slider">
|
||||
<!-- 减号按钮 -->
|
||||
<view style="width: 40rpx; height: 40rpx;" @tap="decreaseValue">
|
||||
<text class="btn-text">-</text>
|
||||
</view>
|
||||
|
||||
<!-- 进度条区域 -->
|
||||
<view class="progress-section">
|
||||
<!-- 进度条背景 -->
|
||||
<view class="progress-bar">
|
||||
<view
|
||||
class="progress-fill"
|
||||
:style="{ width: (currentRealValue / 100 * 400) + 'rpx' }"
|
||||
></view>
|
||||
</view>
|
||||
<!-- 当前数值显示 - 定位在进度条右侧 -->
|
||||
<text class="current-value">{{ currentRealValue }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 加号按钮 -->
|
||||
<view style="width: 40rpx; height: 40rpx;" @tap="increaseValue">
|
||||
<text class="btn-text">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能标签页 -->
|
||||
<!-- <view class="feature-tabs">
|
||||
<view
|
||||
v-for="(tab, index) in featureTabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ 'active': activeTabIndex === index }"
|
||||
@tap="switchTab(index)"
|
||||
>
|
||||
<text class="tab-text" :class="{ 'active-text': activeTabIndex === index }">{{ tab.name }}</text>
|
||||
<view v-if="tab.icon" class="tab-icon">
|
||||
<image :src="tab.icon" mode="aspectFit" class="icon-image" />
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 详细选项区域 -->
|
||||
<view class="options-section">
|
||||
<view class="options-grid">
|
||||
<view
|
||||
v-for="(option, index) in currentOptions"
|
||||
:key="index"
|
||||
class="option-item"
|
||||
:class="{ 'selected': selectedOptionIndex === index }"
|
||||
@tap="selectOption(index)"
|
||||
>
|
||||
<view class="option-icon-container">
|
||||
<image :src="option.icon" mode="aspectFit" class="option-icon" />
|
||||
</view>
|
||||
<text class="option-name">{{ option.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useBaseBeautyState } from '@/uni_modules/tuikit-atomic-x/state/BaseBeautyState'
|
||||
|
||||
const {
|
||||
setSmoothLevel,
|
||||
setWhitenessLevel,
|
||||
setRuddyLevel,
|
||||
whitenessLevel,
|
||||
ruddyLevel,
|
||||
smoothLevel,
|
||||
realUiValues,
|
||||
setRealUiValue,
|
||||
getRealUiValue,
|
||||
resetRealUiValues
|
||||
} = useBaseBeautyState(uni.$liveID)
|
||||
|
||||
/**
|
||||
* 美颜面板组件
|
||||
*
|
||||
* Props:
|
||||
* - modelValue: Boolean - 控制面板显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* - update:modelValue - 更新面板显示状态
|
||||
* - adjust-beauty - 调整美颜参数事件
|
||||
* - reset - 重置事件
|
||||
*/
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits(['update:modelValue', 'adjust-beauty', 'reset'])
|
||||
|
||||
// 响应式数据
|
||||
const activeTabIndex = ref(0)
|
||||
const selectedOptionIndex = ref(0) // 默认选中"关闭"
|
||||
|
||||
const featureTabs = ref([
|
||||
{ name: '美颜', icon: null },
|
||||
// { name: '美体', icon: null },
|
||||
// { name: '滤镜', icon: null },
|
||||
// { name: '贴纸', icon: null },
|
||||
// { name: '风格整妆', icon: null },
|
||||
// { name: '重置', icon: '/static/images/edit.png' }
|
||||
])
|
||||
|
||||
const beautyOptions = ref([
|
||||
{ name: '关闭', icon: '/static/images/beauty-close.png' },
|
||||
{ name: '美白', icon: '/static/images/whiteness.png' },
|
||||
{ name: '磨皮', icon: '/static/images/smooth.png' },
|
||||
{ name: '红润', icon: '/static/images/live-ruddy.png' },
|
||||
// { name: '对比度', icon: '/static/images/setting.png' },
|
||||
// { name: '饱和', icon: '/static/images/setting.png' }
|
||||
])
|
||||
|
||||
const bodyOptions = ref([
|
||||
{ name: '瘦脸', icon: '/static/images/beauty.png' },
|
||||
{ name: '大眼', icon: '/static/images/beauty.png' },
|
||||
{ name: '瘦身', icon: '/static/images/beauty.png' },
|
||||
{ name: '长腿', icon: '/static/images/beauty.png' }
|
||||
])
|
||||
|
||||
const filterOptions = ref([
|
||||
{ name: '原图', icon: '/static/images/beauty.png' },
|
||||
{ name: '暖色', icon: '/static/images/beauty.png' },
|
||||
{ name: '冷色', icon: '/static/images/beauty.png' },
|
||||
{ name: '黑白', icon: '/static/images/beauty.png' }
|
||||
])
|
||||
|
||||
const stickerOptions = ref([
|
||||
{ name: '无', icon: '/static/images/close.png' },
|
||||
{ name: '帽子', icon: '/static/images/gift.png' },
|
||||
{ name: '眼镜', icon: '/static/images/gift.png' },
|
||||
{ name: '胡子', icon: '/static/images/gift.png' }
|
||||
])
|
||||
|
||||
const styleOptions = ref([
|
||||
{ name: '自然', icon: '/static/images/beauty.png' },
|
||||
{ name: '清新', icon: '/static/images/beauty.png' },
|
||||
{ name: '复古', icon: '/static/images/beauty.png' },
|
||||
{ name: '时尚', icon: '/static/images/beauty.png' }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const currentOptions = computed(() => {
|
||||
const optionsMap = {
|
||||
0: beautyOptions.value,
|
||||
1: bodyOptions.value,
|
||||
2: filterOptions.value,
|
||||
3: stickerOptions.value,
|
||||
4: styleOptions.value
|
||||
}
|
||||
return optionsMap[activeTabIndex.value] || beautyOptions.value
|
||||
})
|
||||
|
||||
// 获取当前选项名称
|
||||
const currentOptionName = computed(() => {
|
||||
return currentOptions.value[selectedOptionIndex.value]?.name || '关闭'
|
||||
})
|
||||
|
||||
// 获取当前激活的美颜效果名称(排除"关闭"选项)
|
||||
const activeBeautyOption = computed(() => {
|
||||
const options = currentOptions.value
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i]
|
||||
if (option.name !== '关闭') {
|
||||
// 检查这个美颜效果是否有值
|
||||
let hasValue = false
|
||||
switch (option.name) {
|
||||
case '美白':
|
||||
hasValue = getRealUiValue('whiteness') > 0
|
||||
break
|
||||
case '红润':
|
||||
hasValue = getRealUiValue('ruddy') > 0
|
||||
break
|
||||
case '磨皮':
|
||||
hasValue = getRealUiValue('smooth') > 0
|
||||
break
|
||||
}
|
||||
if (hasValue) {
|
||||
return option.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 获取当前选项对应的真实UI显示值(显示用户拖动的真实数据)
|
||||
const currentRealValue = computed(() => {
|
||||
switch (currentOptionName.value) {
|
||||
case '美白':
|
||||
return getRealUiValue('whiteness')
|
||||
case '红润':
|
||||
return getRealUiValue('ruddy')
|
||||
case '磨皮':
|
||||
return getRealUiValue('smooth')
|
||||
case '关闭':
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
// 监听外部状态变化,只在初始化时设置真实的UI值
|
||||
watch([smoothLevel, whitenessLevel, ruddyLevel], () => {
|
||||
// 只在组件初始化时,如果真实UI值未设置过,才从API状态初始化
|
||||
// 这样可以保持用户拖动的真实数据不被覆盖
|
||||
if (getRealUiValue('whiteness') === 0 && whitenessLevel.value > 0) {
|
||||
setRealUiValue('whiteness', Math.round(whitenessLevel.value * 10))
|
||||
}
|
||||
if (getRealUiValue('ruddy') === 0 && ruddyLevel.value > 0) {
|
||||
setRealUiValue('ruddy', Math.round(ruddyLevel.value * 10))
|
||||
}
|
||||
if (getRealUiValue('smooth') === 0 && smoothLevel.value > 0) {
|
||||
setRealUiValue('smooth', Math.round(smoothLevel.value * 10))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听面板显示状态,当面板打开时初始化UI值
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue) {
|
||||
// 面板打开时,只有在真实UI值未设置过的情况下才从API状态初始化
|
||||
// 这样可以保持用户上次拖动的真实数据
|
||||
if (getRealUiValue('whiteness') === 0 && whitenessLevel.value > 0) {
|
||||
setRealUiValue('whiteness', Math.round((whitenessLevel.value || 0) * 10))
|
||||
}
|
||||
if (getRealUiValue('ruddy') === 0 && ruddyLevel.value > 0) {
|
||||
setRealUiValue('ruddy', Math.round((ruddyLevel.value || 0) * 10))
|
||||
}
|
||||
if (getRealUiValue('smooth') === 0 && smoothLevel.value > 0) {
|
||||
setRealUiValue('smooth', Math.round((smoothLevel.value || 0) * 10))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
const close = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少数值
|
||||
*/
|
||||
const decreaseValue = () => {
|
||||
const newValue = Math.max(0, currentRealValue.value - 10)
|
||||
updateBeautyValue(newValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加数值
|
||||
*/
|
||||
const increaseValue = () => {
|
||||
const newValue = Math.min(100, currentRealValue.value + 10)
|
||||
updateBeautyValue(newValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新美颜数值
|
||||
*/
|
||||
const updateBeautyValue = (uiValue) => {
|
||||
const currentOption = currentOptions.value[selectedOptionIndex.value]
|
||||
|
||||
// 保存真实的UI拖动值到全局状态
|
||||
switch (currentOption.name) {
|
||||
case '美白':
|
||||
setRealUiValue('whiteness', uiValue)
|
||||
break
|
||||
case '红润':
|
||||
setRealUiValue('ruddy', uiValue)
|
||||
break
|
||||
case '磨皮':
|
||||
setRealUiValue('smooth', uiValue)
|
||||
break
|
||||
case '关闭':
|
||||
return
|
||||
}
|
||||
|
||||
// 如果UI值超过90,API调用时限制为90
|
||||
const limitedUiValue = uiValue > 90 ? 90 : uiValue
|
||||
const apiValue = limitedUiValue / 10 // 转换为接口值:0-9
|
||||
|
||||
// 调用对应的设置方法,传入转换后的参数值
|
||||
switch (currentOption.name) {
|
||||
case '美白':
|
||||
setWhitenessLevel({ whitenessLevel: apiValue })
|
||||
break
|
||||
case '红润':
|
||||
setRuddyLevel({ ruddyLevel: apiValue })
|
||||
break
|
||||
case '磨皮':
|
||||
setSmoothLevel({ smoothLevel: apiValue })
|
||||
break
|
||||
}
|
||||
|
||||
emit('adjust-beauty', {
|
||||
type: featureTabs.value[activeTabIndex.value].name,
|
||||
option: currentOption.name,
|
||||
value: uiValue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换标签页
|
||||
*/
|
||||
const switchTab = (index) => {
|
||||
activeTabIndex.value = index
|
||||
selectedOptionIndex.value = 0
|
||||
|
||||
// 如果是重置标签,触发重置事件
|
||||
if (index === 5) {
|
||||
emit('reset')
|
||||
// 重置所有美颜参数
|
||||
setSmoothLevel({ smoothLevel: 0 })
|
||||
setWhitenessLevel({ whitenessLevel: 0 })
|
||||
setRuddyLevel({ ruddyLevel: 0 })
|
||||
selectedOptionIndex.value = 0
|
||||
activeTabIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择选项
|
||||
*/
|
||||
const selectOption = (index) => {
|
||||
const currentOption = currentOptions.value[index]
|
||||
|
||||
// 如果选择"关闭",重置当前选中的美颜效果
|
||||
if (currentOption.name === '关闭') {
|
||||
// 获取当前选中的美颜效果(在更新selectedOptionIndex之前)
|
||||
const currentSelectedOption = currentOptions.value[selectedOptionIndex.value]
|
||||
|
||||
// 根据当前选中的美颜效果来重置对应的参数
|
||||
switch (currentSelectedOption?.name) {
|
||||
case '美白':
|
||||
setWhitenessLevel({ whitenessLevel: 0 })
|
||||
setRealUiValue('whiteness', 0)
|
||||
break
|
||||
case '红润':
|
||||
setRuddyLevel({ ruddyLevel: 0 })
|
||||
setRealUiValue('ruddy', 0)
|
||||
break
|
||||
case '磨皮':
|
||||
setSmoothLevel({ smoothLevel: 0 })
|
||||
setRealUiValue('smooth', 0)
|
||||
break
|
||||
default:
|
||||
// 如果当前选中的不是具体的美颜选项,则重置所有
|
||||
setSmoothLevel({ smoothLevel: 0 })
|
||||
setWhitenessLevel({ whitenessLevel: 0 })
|
||||
setRuddyLevel({ ruddyLevel: 0 })
|
||||
resetRealUiValues()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
selectedOptionIndex.value = index
|
||||
|
||||
emit('adjust-beauty', {
|
||||
type: featureTabs.value[activeTabIndex.value].name,
|
||||
option: currentOption.name,
|
||||
value: currentRealValue.value // 使用UI显示值(0-100)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 滑块区域 */
|
||||
.slider-section {
|
||||
padding: 40rpx 48rpx 20rpx;
|
||||
background-color: rgba(34, 38, 46, 1);
|
||||
}
|
||||
|
||||
/* 自定义控制区域样式 */
|
||||
.control-container {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 2rpx solid #2b6ad6;
|
||||
}
|
||||
|
||||
.minus-btn {
|
||||
display: flex;
|
||||
justify-self: center;
|
||||
align-items: center;
|
||||
background-color: rgba(43, 106, 214, 0.1);
|
||||
}
|
||||
|
||||
.plus-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
background-color: #2b6ad6;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.plus-btn .btn-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
flex: 1;
|
||||
margin: 0 20rpx;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 400rpx;
|
||||
height: 8rpx;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 8rpx;
|
||||
background-color: #2b6ad6;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.current-value {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 详细选项区域 */
|
||||
.options-section {
|
||||
flex: 1;
|
||||
background-color: rgba(34, 38, 46, 1);
|
||||
padding: 20rpx 48rpx;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
width: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
align-items: center;
|
||||
padding: 16rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
border-color: #2b6ad6;
|
||||
background-color: #2b6ad6;
|
||||
}
|
||||
|
||||
.option-icon-container {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
margin-bottom: 12rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<view class="audience-header">
|
||||
<view class="tab-container">
|
||||
<!-- <view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'invite' ? 'active' : 'inactive'"
|
||||
@tap="activeTab = 'invite'"
|
||||
>
|
||||
<text :class="activeTab === 'invite' ? 'active-text' : 'inactive-text'">邀请连麦</text>
|
||||
<view class="active-line-container" v-if="activeTab === 'invite'">
|
||||
<view class="active-line"></view>
|
||||
</view>
|
||||
</view> -->
|
||||
<view class="tab-item" :class="activeTab === 'requests' ? 'active' : 'inactive'"
|
||||
@tap="activeTab = 'requests'">
|
||||
<text :class="activeTab === 'requests' ? 'active-text' : 'inactive-text'">连麦申请</text>
|
||||
<!-- <view class="active-line-container" v-if="activeTab === 'requests'">
|
||||
<view class="active-line"></view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="audience-content" scroll-y @scroll="handleScroll" :scroll-top="scrollTop">
|
||||
<!-- 连麦申请列表 -->
|
||||
<view v-if="activeTab === 'requests'">
|
||||
<view v-if="(applicants || []).length > 0" class="audience-grid">
|
||||
<view v-for="audience in applicants || []" :key="audience?.userID" class="audience-item"
|
||||
:class="{ 'is-admin': audience?.userRole === 'administrator' }">
|
||||
<view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar" :src="audience?.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name" :numberOfLines="1">{{ audience?.userName || audience?.userID}}</text>
|
||||
</view>
|
||||
<view class="request-actions">
|
||||
<view class="action-btn accept" @tap="handleRequest(audience, 'accept')">
|
||||
<text class="btn-text">同意</text>
|
||||
</view>
|
||||
<view class="action-btn reject" @tap="handleRequest(audience, 'reject')">
|
||||
<text class="btn-text">拒绝</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar" :src="audience.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name" :numberOfLines="1">{{ audience.userName }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="start-link"
|
||||
:class="audience?.isMessageDisabled ? 'start' : 'waiting'"
|
||||
@tap="startLink(audience)"
|
||||
>
|
||||
<text class="link-text">{{ audience?.isMessageDisabled ? '发起连线' : '邀请中(30)s' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>-->
|
||||
<view class="audience-item-bottom-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-text">暂无观众申请连麦</text>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请连麦列表 -->
|
||||
<!-- <view v-if="activeTab === 'invite'">
|
||||
<view v-if="(invitableGuests || []).length > 0" class="audience-grid">
|
||||
<view v-for="request in invitableGuests || []" :key="request.userID" class="audience-item">
|
||||
<view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar"
|
||||
src="https://sdk-web-1252463788.cos.ap-hongkong.myqcloud.com/trtc/live/assets/Icon/defaultAvatar.png"
|
||||
mode="aspectFill" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name" :numberOfLines="1">{{ request.userName || request.userID}}</text>
|
||||
</view>
|
||||
<view class="request-actions">
|
||||
<view class="action-btn accept" @tap="handleRequest(request, 'accept')">
|
||||
<text class="btn-text">同意</text>
|
||||
</view>
|
||||
<view class="action-btn reject" @tap="handleRequest(request, 'reject')">
|
||||
<text class="btn-text">拒绝</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="audience-item-bottom-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-text">暂无邀请连麦</text>
|
||||
</view> -->
|
||||
<!-- </view> -->
|
||||
|
||||
<view v-if="isLoading" class="loading-state">
|
||||
<image src="/static/images/more.png" mode="aspectFit" class="loading-image" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- <view class="drawer-content">
|
||||
<view class="divider-line-container">
|
||||
<view class="divider-line"></view>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
computed
|
||||
} from 'vue'
|
||||
|
||||
import {
|
||||
useCoGuestState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
|
||||
const {
|
||||
applicants,
|
||||
invitableGuests,
|
||||
acceptApplication,
|
||||
cancelApplication,
|
||||
rejectApplication
|
||||
} = useCoGuestState(uni?.$liveID);
|
||||
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: 'invite',
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = ref(false);
|
||||
const currentCursor = ref(0);
|
||||
const scrollTop = ref(0);
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (this.currentCursor.value === 0) return;
|
||||
|
||||
const {
|
||||
scrollHeight,
|
||||
scrollTop
|
||||
} = e.detail;
|
||||
scrollTop.value = scrollTop;
|
||||
|
||||
if (scrollHeight - scrollTop.value < 100) {
|
||||
// this.loadAudiences(this.currentCursor);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequest = (audience, action) => {
|
||||
console.warn(`${action} request from ${JSON.stringify(audience)}`);
|
||||
|
||||
if (action === 'accept') {
|
||||
acceptApplication({
|
||||
liveID: uni?.$liveID,
|
||||
userID: audience?.userID,
|
||||
success: () => {
|
||||
console.log('acceptApplication success.');
|
||||
},
|
||||
fail: (errCode, errMsg) => {
|
||||
console.error(`acceptApplication fail errCode: ${errCode}, errMsg: ${errMsg}`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'reject') {
|
||||
rejectApplication({
|
||||
liveID: uni?.$liveID,
|
||||
userID: audience?.userID,
|
||||
success: () => {
|
||||
console.log('rejectApplication success.');
|
||||
},
|
||||
fail: (errCode, errMsg) => {
|
||||
console.error(`rejectCoGuestRequest fail errCode: ${errCode}, errMsg: ${errMsg}`);
|
||||
},
|
||||
});
|
||||
uni.$localGuestStatus = 'IDLE'
|
||||
}
|
||||
};
|
||||
|
||||
const startLink = (audience) => {
|
||||
console.warn('发起连线');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
height: 1000rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.audience-header {
|
||||
padding: 40rpx 0;
|
||||
background-color: rgba(34, 38, 46, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 20rpx 40rpx;
|
||||
margin: 0 20rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.active-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.active-line-container {
|
||||
margin-top: 4rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 128rpx;
|
||||
}
|
||||
|
||||
.active-line {
|
||||
border-bottom: 4rpx solid rgba(255, 255, 255, 0.9);
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
.inactive-text {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.audience-content {
|
||||
flex: 1;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.audience-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audience-item {
|
||||
padding: 24rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.audience-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-avatar-container {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.audience-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.audience-item-right {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-name {
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
max-width: 200rpx;
|
||||
lines: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.start-link {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 100rpx;
|
||||
background-color: rgba(43, 106, 214, 1);
|
||||
}
|
||||
|
||||
.start-link.waiting {
|
||||
background-color: rgba(58, 60, 66, 1);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.request-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 100rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn.accept {
|
||||
background-color: rgba(43, 106, 214, 1);
|
||||
}
|
||||
|
||||
.action-btn.reject {
|
||||
background-color: rgba(58, 60, 66, 1);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.audience-item-bottom-line {
|
||||
position: absolute;
|
||||
border-bottom-width: 1rpx;
|
||||
border-bottom-color: rgba(79, 88, 107, 0.3);
|
||||
width: 550rpx;
|
||||
height: 2rpx;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
padding: 64rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.loading-image {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.divider-line-container {
|
||||
height: 68rpx;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
width: 268rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 200rpx;
|
||||
background-color: #ffffff;
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<view class="drawer-header">
|
||||
<text style="color: rgba(213, 224, 242, 1); font-size: 32rpx;">选择连麦方式</text>
|
||||
<text
|
||||
style="font-size: 24rpx; font-weight: 400; color: rgba(124, 133, 166, 1); margin-top: 20rpx;">选择连麦方式,主播同意后接通</text>
|
||||
</view>
|
||||
|
||||
<view class="drawer-content">
|
||||
<view class="drawer-actions">
|
||||
<view style="height: 2rpx; color: rgba(79, 88, 107, 0.3); width: 750rpx; background: #fff; opacity: 0.2;">
|
||||
</view>
|
||||
<view style="display: flex; flex-direction: row; align-items: center; padding: 30rpx;"
|
||||
@click="handleSendCoGuest('video')">
|
||||
<image src="/static/images/mode.png" style="width: 36rpx; height: 36rpx;"></image>
|
||||
<text
|
||||
style="font-size: 32rpx; font-weight: 400; color: rgba(213, 224, 242, 1); padding-left: 10rpx;">申请视频连麦</text>
|
||||
</view>
|
||||
<view style="height: 2rpx; color: rgba(79, 88, 107, 0.3); width: 750rpx; background: #fff; opacity: 0.2;">
|
||||
</view>
|
||||
<view style="display: flex; flex-direction: row; align-items: center; padding: 30rpx;"
|
||||
@click="handleSendCoGuest('mic')">
|
||||
<image src="/static/images/live-comic.png" style="width: 36rpx; height: 36rpx;"></image>
|
||||
<text
|
||||
style="font-size: 32rpx; font-weight: 400; color: rgba(213, 224, 242, 1); padding-left: 10rpx;">申请语音连麦</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <view class="divider-line-container">
|
||||
<view class="divider-line"></view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, ref, onUnmounted } from 'vue';
|
||||
|
||||
import { useDeviceState } from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
|
||||
import { useCoGuestState } from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
|
||||
import { useLoginState } from "@/uni_modules/tuikit-atomic-x/state/LoginState";
|
||||
const { loginUserInfo } = useLoginState()
|
||||
const currentCoGuestType = ref('')
|
||||
const {
|
||||
// 响应式状态 - 麦克风相关
|
||||
microphoneStatus, microphoneStatusReason, microphoneLastError, hasPublishAudioPermission, captureVolume,
|
||||
// 响应式状态 - 摄像头相关
|
||||
cameraStatus, cameraStatusReason, cameraLastError,
|
||||
// 响应式状态 - 其他设备相关
|
||||
currentAudioRoute, isScreenSharing, isFrontCamera, screenStatus, screenStatusReason,
|
||||
// 操作方法 - 麦克风相关,callback在params中
|
||||
openLocalMicrophone, closeLocalMicrophone, muteLocalAudio, unmuteLocalAudio, setAudioRoute,
|
||||
// 操作方法 - 摄像头相关,callback在params中
|
||||
openLocalCamera, closeLocalCamera, switchCamera, switchMirror, updateVideoQuality,
|
||||
// 操作方法 - 屏幕共享相关,callback在params中
|
||||
startScreenShare, stopScreenShare,
|
||||
} = useDeviceState(uni?.$liveID);
|
||||
const {
|
||||
// 响应式状态
|
||||
invitees, applicants,
|
||||
applyForSeat,
|
||||
addCoGuestHostListener, removeCoGuestHostListener,
|
||||
addCoGuestGuestListener, removeCoGuestGuestListener
|
||||
} = useCoGuestState(uni?.$liveID);
|
||||
|
||||
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
liveID: {
|
||||
type: String,
|
||||
},
|
||||
userID: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
seatIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
addCoGuestGuestListener(uni.$liveID, 'onGuestApplicationResponded', handleGuestApplicationResponded)
|
||||
addCoGuestGuestListener(uni.$liveID, 'onGuestApplicationNoResponse', handleGuestApplicationNoResponse)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
currentCoGuestType.value = ''
|
||||
removeCoGuestGuestListener(uni.$liveID, 'onGuestApplicationResponded', handleGuestApplicationResponded)
|
||||
removeCoGuestGuestListener(uni.$liveID, 'onGuestApplicationNoResponse', handleGuestApplicationNoResponse)
|
||||
})
|
||||
|
||||
|
||||
const handleGuestApplicationResponded = {
|
||||
callback: (event) => {
|
||||
const res = JSON.parse(event)
|
||||
if (res.isAccept) {
|
||||
uni.$localGuestStatus = 'CONNECTED'
|
||||
if (currentCoGuestType.value === 'video') {
|
||||
openLocalCamera({
|
||||
isFront: true,
|
||||
success: () => {
|
||||
console.log('openLocalCamera success.');
|
||||
},
|
||||
fail: (errCode, errMsg) => {
|
||||
console.error(`openLocalCamera fail errCode: ${errCode}, errMsg: ${errMsg}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
openLocalMicrophone({
|
||||
success: () => {
|
||||
console.log('openLocalMicrophone success.');
|
||||
},
|
||||
fail: (errCode, errMsg) => {
|
||||
console.error(`openLocalMicrophone fail errCode: ${errCode}, errMsg: ${errMsg}`);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
uni.$localGuestStatus = 'IDLE'
|
||||
uni.showToast({
|
||||
title: '上麦申请被拒绝',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleGuestApplicationNoResponse = {
|
||||
callback: (event) => {
|
||||
const res = JSON.parse(event)
|
||||
if (res.reason === 'TIMEOUT') {
|
||||
uni.$localGuestStatus = 'IDLE'
|
||||
uni.showToast({
|
||||
title: '上麦申请超时',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const handleSendCoGuest = (type : string) => {
|
||||
console.log(`goGuest localStatus: ${uni.$localGuestStatus}`);
|
||||
currentCoGuestType.value = type
|
||||
if (uni.$localGuestStatus === 'USER_APPLYING') {
|
||||
console.log(`cancel userID: ${props?.userID}`);
|
||||
uni.showToast({
|
||||
title: '你已提交了连麦申请 \n请勿重复申请',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
position: 'center',
|
||||
});
|
||||
close();
|
||||
return
|
||||
}
|
||||
if (uni.$localGuestStatus === 'IDLE') {
|
||||
uni.showToast({
|
||||
title: '你提交了连麦申请 \n请等待主播同意',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
position: 'center',
|
||||
});
|
||||
applyForSeat({
|
||||
liveID: props.liveID,
|
||||
seatIndex: props.seatIndex, // 申请上麦传 -1, 随机分配麦位, xinlxin 反馈
|
||||
timeout: 30,
|
||||
});
|
||||
uni.$localGuestStatus = 'USER_APPLYING'
|
||||
close();
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
height: 500rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 48rpx;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drawer-header-title {
|
||||
font-size: 32rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* justify-content: space-around; */
|
||||
/* justify-content: flex-start; */
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 40rpx;
|
||||
flex: 1;
|
||||
height: 150rpx;
|
||||
}
|
||||
|
||||
.action-btn-image-container {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
margin-bottom: 12rpx;
|
||||
border-radius: 25rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn-image {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
}
|
||||
|
||||
.action-btn-content {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.divider-line-container {
|
||||
height: 68rpx;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
width: 268rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 200rpx;
|
||||
background-color: #ffffff;
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
}
|
||||
|
||||
.camera-mic-setting {
|
||||
flex: 1;
|
||||
background-color: #1f1024;
|
||||
}
|
||||
</style>
|
||||
581
uni_modules/tuikit-atomic-x/components/CoHostPanel.nvue
Normal file
581
uni_modules/tuikit-atomic-x/components/CoHostPanel.nvue
Normal file
@@ -0,0 +1,581 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }" @click.stop>
|
||||
<view class="audience-header">
|
||||
<view class="tab-container">
|
||||
<view class="tab-item">
|
||||
<text class="active-text">发起连线</text>
|
||||
<view class="end-right" @tap.stop="handleExitCoHost" v-if="connected?.length > 0">
|
||||
<image class="end-connect" src="/static/images/logout.png"></image>
|
||||
<text style="color: #E6594C; padding-left: 10rpx;">断开</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="filteredConnected?.length > 0"
|
||||
style="display: flex; width: 800rpx; padding-left: 80rpx; margin-bottom: 40rpx;">
|
||||
<text class="title-text">连线中</text>
|
||||
<view v-for="connect in filteredConnected" :key="connect.userID" class="audience-item">
|
||||
<view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar" mode="aspectFill" :src="connect.avatarURL || defaultAvatarURL" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name" :numberOfLines="1">{{ connect.userName || connect.userID}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="liveList?.length > 0" class="audience-grid">
|
||||
|
||||
</view>
|
||||
<view
|
||||
style="display: flex; width: 800rpx; flex-direction: row; padding-left: 80rpx; padding-right: 80rpx;margin-bottom: 40rpx; justify-content:space-between; "
|
||||
@click.stop>
|
||||
<text class="title-text">推荐列表</text>
|
||||
<text class="title-text" @click="handleRefresh">刷新</text>
|
||||
</view>
|
||||
|
||||
<list class="audience-content" @loadmore="loadMore" :show-scrollbar="false">
|
||||
|
||||
<cell v-for="host in currentInviteHosts" :key="host?.liveOwner?.userID">
|
||||
<view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar" mode="aspectFill" :src="host?.liveOwner?.avatarURL || defaultAvatarURL" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name"
|
||||
:numberOfLines="1">{{ host?.liveOwner?.userName || host?.liveOwner?.userID}}</text>
|
||||
</view>
|
||||
<view :class=" isHostInviting(host) ? 'start-link-waiting' : 'start-link' " @tap="onStartLinkTap(host)">
|
||||
<text class="start-link-text">{{ isHostInviting(host) ? '邀请中' : '邀请连线' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="audience-item-bottom-line"></view>
|
||||
</cell>
|
||||
|
||||
|
||||
</list>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
computed,
|
||||
watch,
|
||||
nextTick
|
||||
} from 'vue'
|
||||
import {
|
||||
useCoHostState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoHostState"
|
||||
import {
|
||||
useLiveListState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveListState";
|
||||
import {
|
||||
useLoginState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LoginState";
|
||||
import { useCoGuestState } from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
|
||||
const { applicants, rejectApplication } = useCoGuestState(uni?.$liveID);
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const refreshing = ref('hide')
|
||||
const isLoading = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const activeTab = ref('pk')
|
||||
const showDrawer = ref(false)
|
||||
const selectedAudience = ref(null)
|
||||
const screenWidth = ref(350)
|
||||
const {
|
||||
candidates,
|
||||
connected,
|
||||
requestHostConnection,
|
||||
invitees,
|
||||
exitHostConnection,
|
||||
} = useCoHostState(uni?.$liveID)
|
||||
const {
|
||||
// 响应式状态
|
||||
liveListCursor,
|
||||
currentLive,
|
||||
// 操作方法 - callback在params中
|
||||
fetchLiveList,
|
||||
liveList
|
||||
} = useLiveListState();
|
||||
const {
|
||||
loginUserInfo
|
||||
} = useLoginState()
|
||||
const currentCursor = ref('')
|
||||
const currentUserID = ref(loginUserInfo.value?.userID);
|
||||
const filteredConnected = computed(() => {
|
||||
const list = connected?.value || [];
|
||||
const selfId = uni?.$userID ?? currentUserID.value;
|
||||
return list.filter(item => item?.userID !== selfId);
|
||||
})
|
||||
function filterInviteHosts(list) {
|
||||
const hosts = Array.isArray(list) ? list : [];
|
||||
const connectedLiveIds = new Set((connected?.value || []).map(item => item?.liveID));
|
||||
return hosts.filter(item =>
|
||||
item?.liveOwner?.userID !== currentLive?.value?.liveOwner?.userID &&
|
||||
!connectedLiveIds.has(item?.liveID)
|
||||
);
|
||||
}
|
||||
|
||||
const currentInviteHosts = ref(filterInviteHosts(liveList?.value))
|
||||
const isRefreshEnd = ref(false)
|
||||
|
||||
watch(() => loginUserInfo.value?.userID, (newUserId, oldUserId) => {
|
||||
console.log('用户ID变化:', {
|
||||
newUserId
|
||||
});
|
||||
if (newUserId) {
|
||||
// 如果当前标题是默认值或者为空,则更新为新的用户名
|
||||
currentUserID.value = newUserId;
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
});
|
||||
|
||||
watch(liveList, (newHostsList, olderHostList) => {
|
||||
if (newHostsList) {
|
||||
currentInviteHosts.value = filterInviteHosts(newHostsList)
|
||||
}
|
||||
nextTick()
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
},
|
||||
)
|
||||
|
||||
watch(currentLive, () => {
|
||||
currentInviteHosts.value = filterInviteHosts(liveList?.value)
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
})
|
||||
|
||||
watch(applicants, (newVal, oldVal) => {
|
||||
if (newVal && invitees?.value.length > 0) {
|
||||
newVal.forEach(applicant => {
|
||||
rejectApplication({
|
||||
liveID: uni?.$liveID,
|
||||
userID: applicant.userID,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
},)
|
||||
|
||||
watch(liveListCursor, (newCursor, oldCursor) => {
|
||||
isRefreshEnd.value = newCursor === ''
|
||||
})
|
||||
|
||||
// 监听连线状态变化,重新过滤推荐列表
|
||||
watch(connected, (newConnected, oldConnected) => {
|
||||
currentInviteHosts.value = filterInviteHosts(liveList?.value);
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
})
|
||||
|
||||
const handleRefresh = () => {
|
||||
const params = {
|
||||
cursor: '', // 首次拉起传空(不能是null),然后根据回调数据的cursor确认是否拉完
|
||||
count: 20, // 分页拉取的个数
|
||||
success: () => {
|
||||
fetchLiveListRecursively(liveListCursor.value); // 最多尝试3次
|
||||
}
|
||||
};
|
||||
fetchLiveList(params);
|
||||
}
|
||||
|
||||
const fetchLiveListRecursively = (cursor) => {
|
||||
const params = {
|
||||
cursor: cursor,
|
||||
count: 20,
|
||||
success: () => {
|
||||
if (liveListCursor.value) {
|
||||
fetchLiveListRecursively(liveListCursor.value);
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '刷新完成'
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error(`fetchLiveListRecursively failed, err: ${JSON.stringify(err)}`);
|
||||
}
|
||||
};
|
||||
fetchLiveList(params);
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!liveListCursor.value) {
|
||||
uni.showToast({
|
||||
title: "没有更多了",
|
||||
icon: "none"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const params = {
|
||||
cursor: liveListCursor.value,
|
||||
count: 20,
|
||||
};
|
||||
fetchLiveList(params);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 判断当前 host 是否处于邀请中(依据 invitees 列表里的 liveID)
|
||||
function isHostInviting(host) {
|
||||
const inviteesList = invitees?.value;
|
||||
const targetLiveID = host?.liveID;
|
||||
if (!targetLiveID) return false;
|
||||
return inviteesList.some((item) => item?.liveID === targetLiveID);
|
||||
}
|
||||
|
||||
// 点击邀请,若已在邀请中则不重复发起
|
||||
function onStartLinkTap(host) {
|
||||
if (isHostInviting(host)) {
|
||||
return;
|
||||
}
|
||||
startLink(host);
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function startLink(host) {
|
||||
if (applicants.value.length > 0) {
|
||||
uni.showToast({
|
||||
title: '有人申请连麦,无法发起连线',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
requestHostConnection({
|
||||
liveID: uni?.$liveID,
|
||||
targetHostLiveID: host.liveID,
|
||||
layoutTemplate: 'HOST_DYNAMIC_GRID',
|
||||
timeout: 30,
|
||||
extensionInfo: "",
|
||||
success: (res) => {
|
||||
console.log(res)
|
||||
},
|
||||
fail: (errorCode) => {
|
||||
console.log(errorCode)
|
||||
if (errorCode === 5) {
|
||||
uni.showToast({
|
||||
icon: 'none',
|
||||
title: '主播连线中,无法发起连线'
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
icon: 'none',
|
||||
title: '连线失败'
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
},)
|
||||
}
|
||||
|
||||
|
||||
const handleExitCoHost = () => {
|
||||
uni.showModal({
|
||||
content: '确定要退出连线吗?',
|
||||
confirmText: '退出连线',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
exitHostConnection({
|
||||
liveID: uni?.$liveID,
|
||||
success: () => {
|
||||
close()
|
||||
},
|
||||
fail: (error) => {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.refresh-icon-container {
|
||||
margin-left: 16rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-image {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.refresh-icon.refreshing {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 1000rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.audience-header {
|
||||
padding: 20rpx 0;
|
||||
background-color: rgba(34, 38, 46, 1);
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 20rpx 40rpx;
|
||||
margin: 0 20rpx;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
width: 750rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.end-right {
|
||||
position: absolute;
|
||||
right: 80rpx;
|
||||
left: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.end-connect {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.active-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.active-line-container {
|
||||
margin-top: 4rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 128rpx;
|
||||
}
|
||||
|
||||
.active-line {
|
||||
border-bottom: 4rpx solid rgba(255, 255, 255, 0.9);
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
.inactive-text {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.audience-return-arrow-container {
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
left: 48rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-return-arrow {
|
||||
height: 28rpx;
|
||||
width: 16rpx;
|
||||
}
|
||||
|
||||
.audience-content {
|
||||
flex: 1;
|
||||
width: 750rpx;
|
||||
padding: 0 48rpx;
|
||||
background-color: rgba(34, 38, 46, 1);
|
||||
}
|
||||
|
||||
.audience-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audience-item {
|
||||
border-radius: 16rpx;
|
||||
height: 100rpx;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
margin-bottom: 32rpx;
|
||||
padding-top: 10rpx;
|
||||
}
|
||||
|
||||
.audience-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.audience-avatar-container {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
margin-right: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audience-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.audience-item-right {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-detail {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.audience-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
width: 300rpx;
|
||||
lines: 1;
|
||||
}
|
||||
|
||||
.start-link {
|
||||
width: 120rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 30rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(28, 102, 229, 1);
|
||||
}
|
||||
|
||||
.start-link-waiting {
|
||||
width: 120rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 30rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-width: 2rpx;
|
||||
border-style: solid;
|
||||
border-color: rgba(28, 102, 229, 1);
|
||||
}
|
||||
|
||||
.start-link-text {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.audience-item-bottom-line {
|
||||
position: absolute;
|
||||
border-bottom-width: 2rpx;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: rgba(79, 88, 107, 0.3);
|
||||
width: 550rpx;
|
||||
height: 2rpx;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
padding: 64rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 0 48rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
</style>
|
||||
320
uni_modules/tuikit-atomic-x/components/GiftPicker.nvue
Normal file
320
uni_modules/tuikit-atomic-x/components/GiftPicker.nvue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<view class="gift-header">
|
||||
<view class="header-content">
|
||||
<text class="gift-title">礼物</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<swiper class="gift-content" :current="currentPage" @change="handleSwiperChange" style="height: 710rpx;">
|
||||
<swiper-item v-for="(page, pageIndex) in giftPages" :key="pageIndex" class="gift-page">
|
||||
<view class="gift-container">
|
||||
<view class="gift-item" v-for="(giftInfo, index) in page" :key="giftInfo.giftID"
|
||||
:class="{ 'selected': selectedGiftIndex === (pageIndex * itemsPerPage + index) }"
|
||||
@tap="selectGift(pageIndex * itemsPerPage + index)">
|
||||
<view class="gift-image-container">
|
||||
<image class="gift-image" :src="giftInfo.iconURL" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="gift-action" v-if="selectedGiftIndex === (pageIndex * itemsPerPage + index)">
|
||||
<view class="send-btn selected-btn" @tap.stop="handleSendGift(pageIndex * itemsPerPage + index)">
|
||||
<text class="send-text">赠送</text>
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
class="gift-name">{{ selectedGiftIndex === (pageIndex * itemsPerPage + index) ? '' : (giftInfo.name || '') }}</text>
|
||||
<text class="gift-price">{{ giftInfo.coins }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import {
|
||||
downloadAndSaveToPath
|
||||
} from '@/uni_modules/tuikit-atomic-x/components/GiftPlayer/giftService';
|
||||
import {
|
||||
useGiftState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/GiftState";
|
||||
const {
|
||||
usableGifts,
|
||||
latestGift,
|
||||
sendGift,
|
||||
refreshUsableGifts
|
||||
} = useGiftState(uni?.$liveID);
|
||||
|
||||
export default {
|
||||
name: 'GiftPanel',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
onGiftSelect: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollTop: 0,
|
||||
selectedGiftIndex: 0,
|
||||
giftLists: usableGifts,
|
||||
currentPage: 0,
|
||||
itemsPerPage: 8,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
handleSwiperChange(e) {
|
||||
this.currentPage = e.detail.current;
|
||||
},
|
||||
selectGift(index) {
|
||||
this.selectedGiftIndex = index;
|
||||
},
|
||||
handleSendGift(index) {
|
||||
const gift = (this.flattenedGifts || [])[index];
|
||||
if (this.selectedGiftIndex !== index) return;
|
||||
if (this.onGiftSelect) {
|
||||
this.onGiftSelect(gift);
|
||||
}
|
||||
this.selectedGiftIndex = -1;
|
||||
},
|
||||
handleRecharge() {
|
||||
this.$emit('recharge');
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 兼容新的分类结构与旧的扁平结构
|
||||
flattenedGifts() {
|
||||
const list = this.giftLists || [];
|
||||
if (!Array.isArray(list)) return [];
|
||||
// 新结构:[{ categoryID, name, giftList: [...] }, ...]
|
||||
if (list.length > 0 && list[0] && Array.isArray(list[0].giftList)) {
|
||||
const merged = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const category = list[i];
|
||||
const gifts = Array.isArray(category.giftList) ? category.giftList : [];
|
||||
for (let j = 0; j < gifts.length; j++) {
|
||||
merged.push(gifts[j]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
// 旧结构:直接为礼物数组
|
||||
return list;
|
||||
},
|
||||
giftPages() {
|
||||
const pages = [];
|
||||
const list = this.flattenedGifts;
|
||||
for (let i = 0; i < list.length; i += this.itemsPerPage) {
|
||||
pages.push(list.slice(i, i + this.itemsPerPage));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async giftLists(newVal) {
|
||||
const flatten = () => {
|
||||
if (!Array.isArray(newVal)) return [];
|
||||
if (newVal.length > 0 && newVal[0] && Array.isArray(newVal[0].giftList)) {
|
||||
// 分类结构
|
||||
const out = [];
|
||||
for (let i = 0; i < newVal.length; i++) {
|
||||
const gifts = Array.isArray(newVal[i].giftList) ? newVal[i].giftList : [];
|
||||
for (let j = 0; j < gifts.length; j++) out.push(gifts[j]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return newVal;
|
||||
};
|
||||
const flatList = flatten();
|
||||
if (!flatList || flatList.length === 0) return;
|
||||
for (let i = 0; i < flatList.length; i++) {
|
||||
const giftData = flatList[i];
|
||||
if (giftData && giftData.resourceURL) {
|
||||
const giftKey = `${(giftData.name || '').split(' ').join('')}-${giftData.giftID}`;
|
||||
if (plus && plus.storage && plus.storage.getAllKeys && plus.storage.getAllKeys().includes(giftKey)) continue;
|
||||
const svgaGiftSourceUrl = plus && plus.storage ? plus.storage.getItem(giftKey) : null;
|
||||
if (!svgaGiftSourceUrl) {
|
||||
const filePath = await downloadAndSaveToPath(`${giftData.resourceURL}`);
|
||||
if (plus && plus.storage) plus.storage.setItem(giftKey, filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!uni?.$liveID) {
|
||||
return;
|
||||
}
|
||||
refreshUsableGifts({
|
||||
liveID: uni.$liveID
|
||||
})
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
height: 710rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.gift-header {
|
||||
padding: 40rpx 48rpx;
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gift-title {
|
||||
font-size: 36rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gift-content {
|
||||
flex: 1;
|
||||
height: 710rpx;
|
||||
}
|
||||
|
||||
.gift-page {
|
||||
flex: 1;
|
||||
height: 710rpx;
|
||||
}
|
||||
|
||||
.gift-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 20rpx;
|
||||
flex: 1;
|
||||
height: 710rpx;
|
||||
}
|
||||
|
||||
.gift-item {
|
||||
width: 168rpx;
|
||||
/* 4列布局 */
|
||||
margin-bottom: 24rpx;
|
||||
align-items: center;
|
||||
border-radius: 20rpx;
|
||||
padding: 10rpx 6rpx 6rpx 6rpx;
|
||||
border: 2rpx solid transparent;
|
||||
background-color: transparent;
|
||||
box-sizing: border-box;
|
||||
height: 230rpx;
|
||||
}
|
||||
|
||||
.gift-item.selected {
|
||||
border-color: #2B6AD6;
|
||||
background-color: rgba(43, 106, 214, 0.12);
|
||||
height: 230rpx;
|
||||
}
|
||||
|
||||
.gift-image-container {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
margin-bottom: 12rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gift-image {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
}
|
||||
|
||||
.gift-action {
|
||||
height: 56rpx;
|
||||
/* 固定高度避免布局抖动 */
|
||||
margin-bottom: 8rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 100rpx;
|
||||
background-color: rgba(58, 60, 66, 1);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-btn.selected-btn {
|
||||
background-color: #2b6ad6;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* 名称与价格样式 */
|
||||
.gift-name {
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
text-align: center;
|
||||
max-width: 150rpx;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gift-price {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<svga-player ref="playerRef" :url="computedUrl" style="position: absolute; z-index: -1;" @onFinished="handleFinished"
|
||||
:style="modelValue ? {
|
||||
top: 100 + 'px',
|
||||
left: (safeArea?.left || 0) + 'px',
|
||||
width: (safeArea?.width || 0) + 'px',
|
||||
height: (safeArea?.height || 0) + 'px',
|
||||
} : {
|
||||
width: 0 + 'rpx',
|
||||
height: 0 + 'rpx',
|
||||
}"></svga-player>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, reactive } from 'vue';
|
||||
import { downloadAndSaveToPath } from '@/uni_modules/tuikit-atomic-x/components/GiftPlayer/giftService';
|
||||
|
||||
interface SafeAreaLike {
|
||||
top ?: number;
|
||||
left ?: number;
|
||||
width ?: number;
|
||||
height ?: number;
|
||||
}
|
||||
interface GiftModel {
|
||||
resourceURL : string;
|
||||
name : string;
|
||||
giftID : string | number;
|
||||
isFromSelf : boolean;
|
||||
timestamp : number;
|
||||
cacheKey ?: string;
|
||||
}
|
||||
const props = defineProps<{
|
||||
modelValue : boolean;
|
||||
url ?: string;
|
||||
safeArea ?: SafeAreaLike;
|
||||
autoHideMs ?: number;
|
||||
maxQueueSize ?: number;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e : 'update:modelValue', value : boolean) : void
|
||||
(e : 'finished') : void
|
||||
}>();
|
||||
const playerRef = ref<any>(null);
|
||||
const internalUrl = ref<string>('');
|
||||
const computedUrl = computed(() => internalUrl.value || props.url || '');
|
||||
const MAX_CACHE_SIZE = props.maxQueueSize !== undefined ? props.maxQueueSize : 50;
|
||||
const ABSOLUTE_MAX_SIZE = 1000;
|
||||
const effectiveMaxSize = MAX_CACHE_SIZE === 0 ? ABSOLUTE_MAX_SIZE : Math.min(MAX_CACHE_SIZE, ABSOLUTE_MAX_SIZE);
|
||||
const giftPrepareList = reactive<GiftModel[]>([]);
|
||||
const isPlaying = ref<boolean>(false);
|
||||
const currentPlayingGift = ref<GiftModel | null>(null);
|
||||
|
||||
async function resetPlayer() {
|
||||
if (playerRef.value) {
|
||||
try {
|
||||
playerRef.value.stopPlay && playerRef.value.stopPlay();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
internalUrl.value = '';
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
if (playerRef.value) {
|
||||
try {
|
||||
playerRef.value.stopPlay && playerRef.value.stopPlay();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSvgaLocalPath(resourceURL : string, cacheKey : string) : Promise<string> {
|
||||
let cachedPath = plus.storage.getItem(cacheKey);
|
||||
if (!cachedPath) {
|
||||
try {
|
||||
const filePath = await downloadAndSaveToPath(resourceURL);
|
||||
if (!filePath || filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
throw new Error(`Invalid local path: ${filePath}`);
|
||||
}
|
||||
plus.storage.setItem(cacheKey, filePath as string);
|
||||
return filePath as string;
|
||||
} catch (_) {
|
||||
return resourceURL;
|
||||
}
|
||||
} else {
|
||||
if (cachedPath.startsWith('http://') || cachedPath.startsWith('https://')) {
|
||||
plus.storage.removeItem(cacheKey);
|
||||
try {
|
||||
const filePath = await downloadAndSaveToPath(resourceURL);
|
||||
if (filePath && !filePath.startsWith('http')) {
|
||||
plus.storage.setItem(cacheKey, filePath as string);
|
||||
return filePath as string;
|
||||
} else {
|
||||
throw new Error(`Invalid local path: ${filePath}`);
|
||||
}
|
||||
} catch (_) {
|
||||
return resourceURL;
|
||||
}
|
||||
}
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function addGiftToQueue(giftData : { resourceURL ?: string; name ?: string; giftID ?: string | number }, isFromSelf = false) {
|
||||
if (!giftData || !giftData.resourceURL) return;
|
||||
const giftDataCopy = {
|
||||
resourceURL: String(giftData.resourceURL || ''),
|
||||
name: String(giftData.name || ''),
|
||||
giftID: giftData.giftID || 0,
|
||||
};
|
||||
const buildCacheKey = (resourceURL : string, name : string, giftID : string | number) => {
|
||||
const giftIdStr = String(giftID || '');
|
||||
let urlHash = '';
|
||||
try {
|
||||
const urlObj = new URL(resourceURL);
|
||||
const fileName = (urlObj.pathname.split('/').pop() || '').replace(/\.(svga|SVGA)$/, '');
|
||||
urlHash = fileName;
|
||||
} catch (_) {
|
||||
urlHash = resourceURL.substring(resourceURL.lastIndexOf('/') + 1).replace(/\.(svga|SVGA)$/i, '');
|
||||
}
|
||||
const fallbackName = (name || '').replace(/\s+/g, '').substring(0, 10);
|
||||
return `${giftIdStr}-${(urlHash || fallbackName)}`;
|
||||
};
|
||||
const giftModel : GiftModel = {
|
||||
resourceURL: giftDataCopy.resourceURL,
|
||||
name: giftDataCopy.name,
|
||||
giftID: giftDataCopy.giftID,
|
||||
isFromSelf,
|
||||
timestamp: Date.now(),
|
||||
cacheKey: buildCacheKey(giftDataCopy.resourceURL, giftDataCopy.name, giftDataCopy.giftID),
|
||||
};
|
||||
if (isFromSelf) {
|
||||
let hasNonSelfGift = false;
|
||||
for (let i = 0; i < giftPrepareList.length; i++) {
|
||||
if (!giftPrepareList[i].isFromSelf) {
|
||||
hasNonSelfGift = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonSelfGift) {
|
||||
let insertIndex = 0;
|
||||
for (let i = giftPrepareList.length - 1; i >= 0; i--) {
|
||||
if (giftPrepareList[i].isFromSelf) {
|
||||
insertIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
giftPrepareList.splice(insertIndex, 0, giftModel);
|
||||
} else {
|
||||
giftPrepareList.push(giftModel);
|
||||
}
|
||||
} else {
|
||||
giftPrepareList.push(giftModel);
|
||||
}
|
||||
while (giftPrepareList.length > effectiveMaxSize) {
|
||||
let removed = false;
|
||||
for (let i = 0; i < giftPrepareList.length; i++) {
|
||||
if (!giftPrepareList[i].isFromSelf) {
|
||||
giftPrepareList.splice(i, 1);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!removed && giftPrepareList.length > effectiveMaxSize) {
|
||||
giftPrepareList.shift();
|
||||
}
|
||||
}
|
||||
if (giftPrepareList.length === 1 && !isPlaying.value) {
|
||||
preparePlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePlay() {
|
||||
if (giftPrepareList.length === 0) {
|
||||
isPlaying.value = false;
|
||||
return;
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
return;
|
||||
}
|
||||
await resetPlayer();
|
||||
if (isPlaying.value) {
|
||||
return;
|
||||
}
|
||||
isPlaying.value = true;
|
||||
const queueGift = giftPrepareList[0];
|
||||
const gift : GiftModel = {
|
||||
resourceURL: String(queueGift.resourceURL || ''),
|
||||
name: String(queueGift.name || ''),
|
||||
giftID: queueGift.giftID || 0,
|
||||
isFromSelf: queueGift.isFromSelf,
|
||||
timestamp: queueGift.timestamp,
|
||||
cacheKey: String(queueGift.cacheKey || ''),
|
||||
};
|
||||
currentPlayingGift.value = gift;
|
||||
if (!gift || !gift.resourceURL) {
|
||||
onPlayFinished();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const giftResourceURL = String(gift.resourceURL || '');
|
||||
const giftCacheKey = String(gift.cacheKey || '');
|
||||
const giftID = gift.giftID || 0;
|
||||
let svgaGiftSourceUrl = await resolveSvgaLocalPath(giftResourceURL, giftCacheKey);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
let newUrl = svgaGiftSourceUrl as string;
|
||||
if (internalUrl.value === newUrl) {
|
||||
internalUrl.value = '';
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
if (newUrl.startsWith('http://') || newUrl.startsWith('https://')) {
|
||||
try {
|
||||
const filePath = await downloadAndSaveToPath(giftResourceURL);
|
||||
if (filePath && !filePath.startsWith('http')) {
|
||||
plus.storage.setItem(giftCacheKey, filePath);
|
||||
newUrl = filePath;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
internalUrl.value = newUrl;
|
||||
emit('update:modelValue', true);
|
||||
setTimeout(() => {
|
||||
const currentGift = currentPlayingGift.value;
|
||||
if (props.modelValue && computedUrl.value && playerRef.value && isPlaying.value &&
|
||||
currentGift && currentGift.giftID === giftID && currentGift.cacheKey === giftCacheKey) {
|
||||
tryStart();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const retryGift = currentPlayingGift.value;
|
||||
if (props.modelValue && computedUrl.value && playerRef.value && isPlaying.value &&
|
||||
retryGift && retryGift.giftID === giftID && retryGift.cacheKey === giftCacheKey) {
|
||||
tryStart();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 200);
|
||||
} catch (e) {
|
||||
onPlayFinished();
|
||||
}
|
||||
}
|
||||
|
||||
function onPlayFinished() {
|
||||
isHandlingFinished = false;
|
||||
if (playerRef.value) {
|
||||
try {
|
||||
playerRef.value.stopPlay && playerRef.value.stopPlay();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
isPlaying.value = false;
|
||||
if (giftPrepareList.length > 0) {
|
||||
giftPrepareList.shift();
|
||||
}
|
||||
currentPlayingGift.value = null;
|
||||
internalUrl.value = '';
|
||||
if (giftPrepareList.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (playerRef.value) {
|
||||
try {
|
||||
playerRef.value.stopPlay && playerRef.value.stopPlay();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!isPlaying.value) {
|
||||
preparePlay();
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
emit('finished');
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
}
|
||||
|
||||
const tryStart = () => {
|
||||
if (props.modelValue && computedUrl.value && playerRef.value) {
|
||||
const urlToPlay = computedUrl.value;
|
||||
if (urlToPlay.startsWith('http://') || urlToPlay.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
playerRef.value.startPlay(urlToPlay);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedUrl.value, (newUrl) => {
|
||||
if (!isPlaying.value && newUrl === computedUrl.value) {
|
||||
tryStart();
|
||||
}
|
||||
});
|
||||
let finishedTimer : NodeJS.Timer | null = null;
|
||||
let isHandlingFinished = false;
|
||||
|
||||
const handleFinished = () => {
|
||||
if (isHandlingFinished) {
|
||||
return;
|
||||
}
|
||||
if (finishedTimer) {
|
||||
clearTimeout(finishedTimer);
|
||||
}
|
||||
isHandlingFinished = true;
|
||||
finishedTimer = setTimeout(() => {
|
||||
onPlayFinished();
|
||||
isHandlingFinished = false;
|
||||
finishedTimer = null;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function playGift(
|
||||
giftData : { resourceURL ?: string; name ?: string; giftID ?: string | number },
|
||||
isFromSelf = false
|
||||
) {
|
||||
addGiftToQueue(giftData, isFromSelf);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
playGift,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ref } from 'vue'
|
||||
import { useGiftState } from '@/uni_modules/tuikit-atomic-x/state/GiftState'
|
||||
|
||||
type GiftData = {
|
||||
giftID?: string
|
||||
name?: string
|
||||
iconURL?: string
|
||||
resourceURL?: string
|
||||
coins?: number
|
||||
[k: string]: any
|
||||
}
|
||||
|
||||
export function giftService(params: {
|
||||
roomId: string
|
||||
giftPlayerRef: any
|
||||
giftToastRef?: any
|
||||
autoHideMs?: number
|
||||
}) {
|
||||
const { sendGift } = useGiftState(uni?.$liveID)
|
||||
const isGiftPlaying = ref(false)
|
||||
|
||||
const showGift = async (giftData: GiftData, options?: { onlyDisplay?: boolean; isFromSelf?: boolean }) => {
|
||||
if (!giftData) return
|
||||
const onlyDisplay = !!options?.onlyDisplay
|
||||
const isFromSelf = !!options?.isFromSelf // 是否为自送礼物(用户自己送的)
|
||||
|
||||
// SVGA 类型
|
||||
if (giftData.resourceURL) {
|
||||
isGiftPlaying.value = true
|
||||
// 传递 isFromSelf 参数,用于队列优先级管理
|
||||
params.giftPlayerRef?.value?.playGift(giftData, isFromSelf)
|
||||
} else if (params.giftToastRef?.value) {
|
||||
// 普通礼物提示
|
||||
params.giftToastRef?.value?.showToast({
|
||||
...giftData,
|
||||
duration: 1500,
|
||||
})
|
||||
}
|
||||
|
||||
if (!onlyDisplay) {
|
||||
sendGift({
|
||||
liveID: params.roomId,
|
||||
giftID: giftData.giftID,
|
||||
count: 1,
|
||||
success: () => { },
|
||||
fail: () => { },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onGiftFinished = () => {
|
||||
isGiftPlaying.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
showGift,
|
||||
onGiftFinished,
|
||||
isGiftPlaying,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件并保存到自定义路径
|
||||
* @param {string} url - 文件网络地址
|
||||
* @return {Promise<string>} 返回文件本地绝对路径
|
||||
*/
|
||||
export function downloadAndSaveToPath(url: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
url: url,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error('下载失败'))
|
||||
return
|
||||
}
|
||||
let imageFilePath = ''
|
||||
uni.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
success: (res) => {
|
||||
imageFilePath = res.savedFilePath
|
||||
|
||||
// 转换为本地文件系统 URL
|
||||
if (plus && plus.io && plus.io.convertLocalFileSystemURL) {
|
||||
imageFilePath = plus.io.convertLocalFileSystemURL(imageFilePath)
|
||||
}
|
||||
|
||||
resolve(imageFilePath)
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error('保存文件失败'))
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
671
uni_modules/tuikit-atomic-x/components/Like.nvue
Normal file
671
uni_modules/tuikit-atomic-x/components/Like.nvue
Normal file
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<view class="like-container">
|
||||
<!-- 点赞按钮 -->
|
||||
<view class="action-btn-wrapper" @click="handleLikeClick" @touchstart="handleTouchStart" v-if="role !== 'anchor' ">
|
||||
<image class="action-btn" src="/static/images/live-like.png" />
|
||||
</view>
|
||||
|
||||
<!-- 点赞动画容器 - 平台适配 -->
|
||||
<view class="like-animations-container" :class="{ 'ios-container': isIOS, 'android-container': !isIOS }" :style="{
|
||||
width: likeAnimations.length > 0 ? '400rpx' : '0',
|
||||
height: likeAnimations.length > 0 ? '600rpx' : '0'
|
||||
}">
|
||||
<view v-for="(like, index) in likeAnimations" :key="like.id" class="like-animation"
|
||||
:class="{ 'ios-animation': isIOS, 'android-animation': !isIOS }" :style="getAnimationStyle(like)">
|
||||
<image class="heart-icon" :src="like.imageSrc" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useLikeState } from "@/uni_modules/tuikit-atomic-x/state/LikeState";
|
||||
|
||||
// 配置选项
|
||||
const props = defineProps({
|
||||
// 动画方案选择:'platform' - 平台适配方案, 'simple' - 统一兼容方案
|
||||
animationMode: {
|
||||
type: String,
|
||||
default: 'platform',
|
||||
validator: (value) => ['platform', 'simple'].includes(value)
|
||||
},
|
||||
// 角色:anchor 主播 | audience 观众,用于设置默认行为或样式
|
||||
role: {
|
||||
type: String,
|
||||
default: 'audience',
|
||||
validator: (value) => ['anchor', 'audience'].includes(value)
|
||||
},
|
||||
// 容器位置
|
||||
position: {
|
||||
type: String,
|
||||
default: 'bottom-right',
|
||||
validator: (v) => ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(v)
|
||||
},
|
||||
// 同屏最大漂浮数量
|
||||
maxConcurrent: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
// 是否启用触觉反馈
|
||||
enableHaptics: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { sendLike, addLikeListener, totalLikeCount, removeLikeListener } = useLikeState(uni.$liveID)
|
||||
|
||||
// 平台检测
|
||||
const systemInfo = ref({});
|
||||
const isIOS = computed(() => systemInfo.value.platform === 'ios');
|
||||
|
||||
// 初始化系统信息
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
systemInfo.value = res;
|
||||
console.log('系统信息:', res);
|
||||
}
|
||||
});
|
||||
|
||||
// 点赞动画相关状态
|
||||
const likeAnimations = ref([]);
|
||||
let likeAnimationId = 0;
|
||||
const currentLikeCount = ref(0);
|
||||
let lastClickTime = 0; // 记录上次点击时间
|
||||
const CLICK_INTERVAL = 100; // 点击间隔时间(毫秒)
|
||||
|
||||
// 批量点赞策略相关状态
|
||||
const pendingLikeCount = ref(0); // 待发送的点赞数量
|
||||
const batchTimer = ref(null); // 批量发送定时器
|
||||
const BATCH_DELAY = 6000; // 6秒批量发送延迟
|
||||
const lastSendTime = ref(0); // 记录上次发送时间
|
||||
const isFirstClick = ref(true); // 是否第一次点击
|
||||
|
||||
// 存储每次接收到的总点赞数
|
||||
const lastTotalLikesReceived = ref(0); // 上次接收到的总点赞数
|
||||
|
||||
// 心形图片数组
|
||||
const heartImages = [
|
||||
'/static/images/gift_heart0.png',
|
||||
'/static/images/gift_heart1.png',
|
||||
'/static/images/gift_heart2.png',
|
||||
'/static/images/gift_heart3.png',
|
||||
'/static/images/gift_heart4.png',
|
||||
'/static/images/gift_heart5.png',
|
||||
'/static/images/gift_heart6.png',
|
||||
'/static/images/gift_heart7.png',
|
||||
'/static/images/gift_heart8.png'
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
addLikeListener(uni.$liveID, 'onReceiveLikesMessage', handleReceiveLikesMessage)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeLikeListener(uni.$liveID, 'onReceiveLikesMessage', handleReceiveLikesMessage)
|
||||
|
||||
// 清理定时器
|
||||
if (batchTimer.value) {
|
||||
clearTimeout(batchTimer.value);
|
||||
batchTimer.value = null;
|
||||
}
|
||||
|
||||
if (pendingLikeCount.value > 0) {
|
||||
console.log('组件卸载,发送待发送的点赞(不显示动画)');
|
||||
sendBatchLikes();
|
||||
}
|
||||
})
|
||||
|
||||
// 随机选择心形图片
|
||||
const getRandomHeartImage = () => {
|
||||
const randomIndex = Math.floor(Math.random() * heartImages.length);
|
||||
return heartImages[randomIndex];
|
||||
};
|
||||
|
||||
// 获取动画样式 - 平台适配
|
||||
const getAnimationStyle = (like) => {
|
||||
if (isIOS.value) {
|
||||
// iOS端使用更简单的定位方式
|
||||
return {
|
||||
left: like.left + 'rpx',
|
||||
top: like.top + 'rpx',
|
||||
transform: like.transform,
|
||||
opacity: like.opacity
|
||||
};
|
||||
} else {
|
||||
// 安卓端使用原有方式
|
||||
return {
|
||||
left: like.left + 'rpx',
|
||||
bottom: like.bottom + 'rpx',
|
||||
transform: like.transform,
|
||||
opacity: like.opacity
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const createLikeAnimation = (count : number) => {
|
||||
console.log('=== createLikeAnimation 开始 ===');
|
||||
console.log('传入的count参数:', count);
|
||||
console.log('当前动画数组长度:', likeAnimations.value.length);
|
||||
console.log('当前动画数组:', JSON.stringify(likeAnimations.value));
|
||||
|
||||
// 固定创建3个动画,忽略传入的count参数
|
||||
const fixedCount = 3;
|
||||
console.log('创建点赞动画,固定数量:', fixedCount, '平台:', isIOS.value ? 'iOS' : 'Android', '模式:', props.animationMode);
|
||||
|
||||
// 根据配置选择动画方案
|
||||
if (props.animationMode === 'simple') {
|
||||
console.log('使用简单动画方案');
|
||||
createSimpleAnimation(fixedCount);
|
||||
console.log('=== createLikeAnimation 结束(简单方案) ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建固定3个动画
|
||||
const actualCount = Math.min(fixedCount, props.maxConcurrent); // 限制最大数量
|
||||
console.log('实际创建动画数量:', actualCount);
|
||||
|
||||
for (let i = 0; i < actualCount; i++) {
|
||||
const newLike = {
|
||||
id: ++likeAnimationId,
|
||||
show: true,
|
||||
imageSrc: getRandomHeartImage(),
|
||||
left: Math.random() * 120 + 40, // 随机水平位置
|
||||
bottom: isIOS.value ? 0 : 60, // iOS端使用top定位,Android端使用bottom定位
|
||||
top: isIOS.value ? 400 : 0, // iOS端从顶部开始
|
||||
transform: 'scale(0.8)',
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
console.log('添加动画元素:', newLike);
|
||||
|
||||
// 控制最大并发,如果超过限制则移除最旧的
|
||||
if (likeAnimations.value.length >= props.maxConcurrent) {
|
||||
likeAnimations.value.shift();
|
||||
}
|
||||
likeAnimations.value.push(newLike);
|
||||
|
||||
// 平台适配的动画逻辑,添加延迟避免同时出现
|
||||
const delay = i * 100; // 每个动画间隔100ms
|
||||
setTimeout(() => {
|
||||
if (isIOS.value) {
|
||||
console.log('使用iOS动画方案');
|
||||
createIOSAnimation(newLike);
|
||||
} else {
|
||||
console.log('使用Android动画方案');
|
||||
createAndroidAnimation(newLike);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
console.log('添加后动画数组长度:', likeAnimations.value.length);
|
||||
console.log('=== createLikeAnimation 结束 ===');
|
||||
};
|
||||
|
||||
// iOS端动画实现
|
||||
const createIOSAnimation = (newLike) => {
|
||||
// iOS端使用更简单的动画,避免复杂的transition
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(1.2)';
|
||||
like.top = like.top - 40;
|
||||
console.log('iOS动画阶段1:', like.id);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(1)';
|
||||
like.top = like.top - 40;
|
||||
console.log('iOS动画阶段2:', like.id);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.9)';
|
||||
like.top = like.top - 40;
|
||||
like.opacity = 0.9;
|
||||
console.log('iOS动画阶段3:', like.id);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.7)';
|
||||
like.top = like.top - 40;
|
||||
like.opacity = 0.7;
|
||||
console.log('iOS动画阶段4:', like.id);
|
||||
}
|
||||
}, 450);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.5)';
|
||||
like.top = like.top - 40;
|
||||
like.opacity = 0.3;
|
||||
console.log('iOS动画阶段5:', like.id);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
// 动画结束后移除
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
likeAnimations.value.splice(index, 1);
|
||||
console.log('移除iOS动画元素:', newLike.id);
|
||||
console.log('移除后动画数组长度:', likeAnimations.value.length);
|
||||
}
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
// 安卓端动画实现
|
||||
const createAndroidAnimation = (newLike) => {
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(1.2)';
|
||||
like.bottom = like.bottom + 40;
|
||||
console.log('Android动画阶段1:', like.id);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(1)';
|
||||
like.bottom = like.bottom + 40;
|
||||
console.log('Android动画阶段2:', like.id);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.9)';
|
||||
like.bottom = like.bottom + 40;
|
||||
like.opacity = 0.9;
|
||||
console.log('Android动画阶段3:', like.id);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.7)';
|
||||
like.bottom = like.bottom + 40;
|
||||
like.opacity = 0.7;
|
||||
console.log('Android动画阶段4:', like.id);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.transform = 'scale(0.5)';
|
||||
like.bottom = like.bottom + 40;
|
||||
like.opacity = 0.3;
|
||||
console.log('Android动画阶段5:', like.id);
|
||||
}
|
||||
}, 800);
|
||||
|
||||
// 动画结束后移除
|
||||
setTimeout(() => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
likeAnimations.value.splice(index, 1);
|
||||
console.log('移除Android动画元素:', newLike.id);
|
||||
console.log('移除后动画数组长度:', likeAnimations.value.length);
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 批量发送点赞
|
||||
const sendBatchLikes = () => {
|
||||
if (pendingLikeCount.value > 0) {
|
||||
console.log('=== 批量发送点赞 ===');
|
||||
console.log('发送数量:', pendingLikeCount.value);
|
||||
|
||||
sendLike({
|
||||
liveID: uni.$liveID,
|
||||
count: pendingLikeCount.value,
|
||||
success: () => {
|
||||
console.log('批量sendLike success, count:', pendingLikeCount.value);
|
||||
// 更新发送时间
|
||||
lastSendTime.value = Date.now();
|
||||
isFirstClick.value = false;
|
||||
},
|
||||
fail: (code, msg) => {
|
||||
console.error(`批量sendLike failed, code: ${code}, msg: ${msg}`);
|
||||
},
|
||||
});
|
||||
|
||||
// 清空待发送数量
|
||||
pendingLikeCount.value = 0;
|
||||
}
|
||||
|
||||
// 清除定时器
|
||||
if (batchTimer.value) {
|
||||
clearTimeout(batchTimer.value);
|
||||
batchTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理点赞点击事件
|
||||
const handleLikeClick = () => {
|
||||
console.log('=== 点赞点击事件开始 ===');
|
||||
console.log('当前时间:', new Date().toISOString());
|
||||
console.log('当前动画数量:', likeAnimations.value.length);
|
||||
console.log('待发送点赞数量:', pendingLikeCount.value);
|
||||
|
||||
// 添加点击间隔控制,确保每次点击只创建一个动画
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastClickTime < CLICK_INTERVAL) {
|
||||
console.log('点击间隔太短,跳过本次点击');
|
||||
console.log('=== 点赞点击事件结束(跳过) ===');
|
||||
return;
|
||||
}
|
||||
lastClickTime = currentTime;
|
||||
|
||||
// 添加平台检测和调试信息
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
console.log('当前平台:', systemInfo.platform);
|
||||
console.log('点击事件触发时间:', new Date().toISOString());
|
||||
console.log('当前动画数量:', likeAnimations.value.length);
|
||||
|
||||
// 添加触觉反馈(仅安卓端)
|
||||
if (props.enableHaptics && systemInfo.platform === 'android') {
|
||||
uni.vibrateShort({
|
||||
type: 'light'
|
||||
});
|
||||
}
|
||||
|
||||
// 智能发送策略:判断是否需要立即发送
|
||||
const timeSinceLastSend = currentTime - lastSendTime.value;
|
||||
const shouldSendImmediately = isFirstClick.value || timeSinceLastSend >= BATCH_DELAY;
|
||||
|
||||
console.log('智能发送判断:', {
|
||||
isFirstClick: isFirstClick.value,
|
||||
timeSinceLastSend: timeSinceLastSend,
|
||||
shouldSendImmediately: shouldSendImmediately
|
||||
});
|
||||
|
||||
// 累积点赞数量
|
||||
pendingLikeCount.value += 1;
|
||||
|
||||
if (shouldSendImmediately) {
|
||||
// 第一次点击或距离上次发送超过6秒,立即发送
|
||||
console.log('立即发送点赞');
|
||||
sendBatchLikes();
|
||||
|
||||
// 立即发送时显示动画
|
||||
console.log('立即发送,显示动画');
|
||||
createLikeAnimation(3);
|
||||
} else {
|
||||
// 距离上次发送不足6秒,累积并设置定时器
|
||||
console.log('累积点赞,设置延迟发送');
|
||||
|
||||
// 6秒内点击不显示动画,只累积数量
|
||||
console.log('6秒内点击,不显示动画,只累积数量');
|
||||
|
||||
// 清除之前的定时器
|
||||
if (batchTimer.value) {
|
||||
clearTimeout(batchTimer.value);
|
||||
}
|
||||
|
||||
// 计算剩余等待时间
|
||||
const remainingTime = BATCH_DELAY - timeSinceLastSend;
|
||||
console.log('剩余等待时间:', remainingTime, 'ms');
|
||||
|
||||
// 设置剩余时间的定时器
|
||||
batchTimer.value = setTimeout(() => {
|
||||
sendBatchLikes();
|
||||
// 批量发送时显示动画
|
||||
console.log('批量发送,显示动画');
|
||||
createLikeAnimation(3);
|
||||
}, remainingTime);
|
||||
}
|
||||
|
||||
console.log('=== 点赞点击事件结束 ===');
|
||||
};
|
||||
|
||||
// 对外暴露触发方法
|
||||
function triggerLike(count : number = 1) {
|
||||
createLikeAnimation(count);
|
||||
}
|
||||
|
||||
defineExpose({ triggerLike });
|
||||
|
||||
// 处理触摸事件(安卓端备用方案)
|
||||
const handleTouchStart = (event) => {
|
||||
console.log('touch start event:', event);
|
||||
|
||||
// 防止重复触发
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// 触摸事件,直接调用点赞逻辑(间隔控制已在handleLikeClick中处理)
|
||||
if (event && event.touches && event.touches.length > 0) {
|
||||
handleLikeClick();
|
||||
}
|
||||
};
|
||||
|
||||
// 方案二:统一兼容方案 - 使用更简单的动画实现
|
||||
const createSimpleAnimation = (count : number) => {
|
||||
console.log('=== createSimpleAnimation 开始 ===');
|
||||
console.log('传入的count参数:', count);
|
||||
console.log('当前动画数组长度:', likeAnimations.value.length);
|
||||
|
||||
// 固定创建3个动画,忽略传入的count参数
|
||||
const fixedCount = 3;
|
||||
console.log('使用简单动画方案,固定数量:', fixedCount);
|
||||
|
||||
// 创建固定3个动画
|
||||
const actualCount = Math.min(fixedCount, props.maxConcurrent);
|
||||
console.log('实际创建简单动画数量:', actualCount);
|
||||
|
||||
for (let i = 0; i < actualCount; i++) {
|
||||
const newLike = {
|
||||
id: ++likeAnimationId,
|
||||
show: true,
|
||||
imageSrc: getRandomHeartImage(),
|
||||
left: Math.random() * 120 + 40,
|
||||
bottom: 60,
|
||||
transform: 'scale(1)',
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
console.log('添加简单动画元素:', newLike);
|
||||
|
||||
// 控制最大并发
|
||||
if (likeAnimations.value.length >= props.maxConcurrent) {
|
||||
likeAnimations.value.shift();
|
||||
}
|
||||
likeAnimations.value.push(newLike);
|
||||
|
||||
// 简化的动画逻辑,适用于两个平台,添加延迟避免同时出现
|
||||
const delay = i * 100; // 每个动画间隔100ms
|
||||
setTimeout(() => {
|
||||
const animate = () => {
|
||||
const index = likeAnimations.value.findIndex(item => item.id === newLike.id);
|
||||
if (index > -1) {
|
||||
const like = likeAnimations.value[index];
|
||||
like.bottom = like.bottom + 20;
|
||||
like.opacity = like.opacity - 0.1;
|
||||
|
||||
if (like.opacity > 0) {
|
||||
setTimeout(animate, 100);
|
||||
} else {
|
||||
likeAnimations.value.splice(index, 1);
|
||||
console.log('简单动画结束,移除元素:', newLike.id);
|
||||
console.log('移除后动画数组长度:', likeAnimations.value.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
animate();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
console.log('添加后动画数组长度:', likeAnimations.value.length);
|
||||
console.log('=== createSimpleAnimation 结束 ===');
|
||||
};
|
||||
|
||||
const handleReceiveLikesMessage = {
|
||||
callback: (event) => {
|
||||
const res = JSON.parse(event)
|
||||
if (res.sender.userID !== uni.$userID) {
|
||||
createLikeAnimation(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(totalLikeCount, (newVal, oldVal) => {
|
||||
if (oldVal) {
|
||||
currentLikeCount.value = newVal - oldVal
|
||||
}
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
})
|
||||
// 位置样式计算(用于简化模板内联 style 的拼接)
|
||||
const containerInlineStyle = computed(() => {
|
||||
const base = {
|
||||
width: likeAnimations.value.length > 0 ? '400rpx' : '0',
|
||||
height: likeAnimations.value.length > 0 ? '600rpx' : '0',
|
||||
} as any;
|
||||
switch (props.position) {
|
||||
case 'bottom-left':
|
||||
base.left = '40rpx';
|
||||
base.bottom = '40rpx';
|
||||
break;
|
||||
case 'top-right':
|
||||
base.right = '40rpx';
|
||||
base.top = '40rpx';
|
||||
break;
|
||||
case 'top-left':
|
||||
base.left = '40rpx';
|
||||
base.top = '40rpx';
|
||||
break;
|
||||
default:
|
||||
base.right = '40rpx';
|
||||
base.bottom = '40rpx';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.like-container {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-btn-wrapper {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
/* 增加点击区域 */
|
||||
padding: 8rpx;
|
||||
/* 确保在安卓端可以正常点击 */
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
pointer-events: none;
|
||||
/* 确保图片正确显示 */
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
.like-animations-container {
|
||||
position: fixed;
|
||||
bottom: 40rpx;
|
||||
right: 40rpx;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* iOS端特殊样式 */
|
||||
.ios-container {
|
||||
position: absolute;
|
||||
bottom: 40rpx;
|
||||
right: 40rpx;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Android端特殊样式 */
|
||||
.android-container {
|
||||
position: fixed;
|
||||
bottom: 40rpx;
|
||||
right: 40rpx;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.like-animation {
|
||||
position: absolute;
|
||||
transition: all 0.1s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* iOS端动画样式 */
|
||||
.ios-animation {
|
||||
position: absolute;
|
||||
transition: none;
|
||||
/* iOS端不使用transition,避免兼容性问题 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Android端动画样式 */
|
||||
.android-animation {
|
||||
position: absolute;
|
||||
transition: all 0.1s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
|
||||
<view class="drawer-header">
|
||||
<view class="user-info">
|
||||
<image class="user-avatar" :src="userInfo?.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
<view class="user-details">
|
||||
<view class="name-badge-row">
|
||||
<text class="user-name">{{ userInfo?.userName || userInfo?.userID || '' }}</text>
|
||||
<!-- <view class="badge">
|
||||
<image class="badge-icon" src="/static/images/heart.png" mode="aspectFit" />
|
||||
<text class="badge-text">65</text>
|
||||
</view> -->
|
||||
</view>
|
||||
<text class="user-id">ID: {{ userInfo?.userID || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <view class="follow-button" @tap="followUser">
|
||||
<text class="follow-text">Follow</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="drawer-content">
|
||||
<view class="drawer-actions">
|
||||
<!-- <view class="action-btn" @tap="muteSpeak">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" v-if="userInfo?.isMessageDisabled" src="/static/images/unmute-speak.png"
|
||||
mode="aspectFit" />
|
||||
<image class="action-btn-image" v-else src="/static/images/mute-speak.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content" v-if="userInfo?.isMessageDisabled">解除禁言</text>
|
||||
<text class="action-btn-content" v-else>禁言</text>
|
||||
</view> -->
|
||||
<view class="action-btn" @tap="kickOut">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" src="/static/images/kick-out-room.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content">踢出房间</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <view class="divider-line-container">
|
||||
<view class="divider-line"></view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import {
|
||||
useLiveListState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveListState";
|
||||
import {
|
||||
useLiveAudienceState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveAudienceState";
|
||||
const {
|
||||
currentLive
|
||||
} = useLiveListState();
|
||||
const {
|
||||
setAdministrator,
|
||||
revokeAdministrator,
|
||||
kickUserOutOfRoom,
|
||||
disableSendMessage
|
||||
} = useLiveAudienceState(uni?.$liveID);
|
||||
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
},
|
||||
liveID: {
|
||||
type: String,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
const muteSpeak = () => {
|
||||
console.log(
|
||||
`mute or unMute speak, liveID: ${props.liveID}, isMessageDisabled: ${props?.userInfo?.isMessageDisabled}`);
|
||||
const params = {
|
||||
liveID: uni?.$liveID,
|
||||
userID: props?.userInfo?.userID,
|
||||
isDisable: !props?.userInfo?.isMessageDisabled,
|
||||
};
|
||||
|
||||
if (props?.userInfo?.isMessageDisabled) {
|
||||
disableSendMessage(params);
|
||||
} else {
|
||||
disableSendMessage(params);
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const kickOut = () => {
|
||||
console.log('kick out from room', props?.userInfo?.userID);
|
||||
|
||||
uni.showModal({
|
||||
content: `确认踢出${props?.userInfo?.userName || props?.userInfo?.userID}吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
kickUserOutOfRoom({
|
||||
liveID: uni?.$liveID,
|
||||
userID: props?.userInfo?.userID,
|
||||
success: () => {
|
||||
close()
|
||||
console.log(`kickUserOutOfRoom success`);
|
||||
},
|
||||
fail: (errCode, errMsg) => {
|
||||
console.log(`kickUserOutOfRoom fail errCode: ${errCode}, errMsg: ${errMsg}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const followUser = () => {
|
||||
console.warn('== 关注用户 ', userInfo?.userName);
|
||||
// 这里可以添加关注用户的逻辑
|
||||
uni.showToast({
|
||||
title: '关注成功',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
height: 400rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 40rpx 48rpx;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
border-width: 2rpx;
|
||||
border-color: #ffffff;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name-badge-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #8B5CF6;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.follow-button {
|
||||
background-color: #007AFF;
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 32rpx;
|
||||
/* height: 64rpx; */
|
||||
}
|
||||
|
||||
.follow-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
height: 400rpx;
|
||||
justify-content: flex-start;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-left: 10rpx;
|
||||
height: 160rpx;
|
||||
width: 120rpx
|
||||
}
|
||||
|
||||
.action-btn-image-container {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 20rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn-image {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
}
|
||||
|
||||
.action-btn-content {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.divider-line-container {
|
||||
height: 68rpx;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
width: 268rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 200rpx;
|
||||
background-color: #ffffff;
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<view class="live-audience-list">
|
||||
<view class="audience-header">
|
||||
<text class="audience-title">在线观众</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="audience-content" scroll-y @scroll="handleScroll" :scroll-top="scrollTop">
|
||||
<view v-if="audienceList.length > 0" class="audience-grid">
|
||||
<view v-for="audience in audienceList" :key="audience.userID" class="audience-item">
|
||||
<view class="audience-info">
|
||||
<view class="audience-avatar-container">
|
||||
<image class="audience-avatar" :src="audience.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="audience-item-right">
|
||||
<view class="audience-detail">
|
||||
<text class="audience-name" :numberOfLines="1">{{ audience.userName || audience.userID }}</text>
|
||||
<view v-if="audience.tag" class="audience-tag">
|
||||
<text class="tag-text">{{ audience.tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="audience-more" v-if="loginUserInfo?.userID === currentLive.liveOwner.userID"
|
||||
@tap="audienceOperator(audience)">
|
||||
<text class="more-text">···</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="audienceList.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无观众</text>
|
||||
<view></view>
|
||||
</view>
|
||||
<view v-if="isLoading" class="loading-state">
|
||||
<image src="/static/images/loading.png" mode="aspectFit" class="loading-image" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AudienceActionPanel v-model="isShowAudienceActionPanel" :userInfo="selectedAudience" :liveID="liveID">
|
||||
</AudienceActionPanel>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted
|
||||
} from 'vue';
|
||||
import AudienceActionPanel from '@/uni_modules/tuikit-atomic-x/components/LiveAudienceList/AudienceActionPanel.nvue';
|
||||
|
||||
import {
|
||||
useLiveAudienceState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveAudienceState";
|
||||
import {
|
||||
useLoginState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LoginState";
|
||||
import {
|
||||
useLiveListState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveListState";
|
||||
const {
|
||||
currentLive
|
||||
} = useLiveListState();
|
||||
const {
|
||||
loginUserInfo
|
||||
} = useLoginState();
|
||||
|
||||
const {
|
||||
audienceList,
|
||||
audienceListCursor
|
||||
} = useLiveAudienceState(uni?.$liveID);
|
||||
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
const isLoading = ref(false);
|
||||
const currentCursor = ref(0);
|
||||
const scrollTop = ref(0);
|
||||
const isShowAudienceActionPanel = ref(false);
|
||||
const selectedAudience = ref(null);
|
||||
const safeArea = ref({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 375,
|
||||
height: 750,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentViewerCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
liveID: {
|
||||
type: String,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
// 初始化加载
|
||||
onMounted(() => {
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
safeArea.value = res.safeArea;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (currentCursor.value === 0) return;
|
||||
|
||||
const {
|
||||
scrollHeight,
|
||||
scrollTop: currentScrollTop
|
||||
} = e.detail;
|
||||
scrollTop.value = currentScrollTop;
|
||||
|
||||
if (scrollHeight - currentScrollTop < 100) {
|
||||
// loadAudiences(currentCursor.value);
|
||||
}
|
||||
};
|
||||
|
||||
const audienceOperator = (audience) => {
|
||||
selectedAudience.value = audience;
|
||||
isShowAudienceActionPanel.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1F2024;
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
height: 1000rpx;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.live-audience-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audience-header {
|
||||
padding: 32rpx;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 64rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.audience-title {
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 400;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
line-height: 100rpx;
|
||||
}
|
||||
|
||||
.audience-content {
|
||||
flex: 1;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.audience-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audience-item {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.audience-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-avatar-container {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.audience-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.audience-item-right {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-detail {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-name {
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
margin-right: 12rpx;
|
||||
max-width: 300rpx;
|
||||
lines: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audience-tag {
|
||||
background-color: #007AFF;
|
||||
border-radius: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.audience-more {
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.more-text {
|
||||
font-size: 40rpx;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
padding: 32rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.loading-image {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
375
uni_modules/tuikit-atomic-x/components/LiveList.nvue
Normal file
375
uni_modules/tuikit-atomic-x/components/LiveList.nvue
Normal file
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<view class="live-list">
|
||||
<view class="live-list-quick-join">
|
||||
<input v-model="inputLiveId" class="quick-join-input" placeholder="输入房间名称、主播名称或房间ID" confirm-type="done"
|
||||
@confirm="handleJoinLiveById" maxlength="64" />
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 无匹配结果提示 -->
|
||||
<view v-if="hasNoResults" class="empty-container">
|
||||
<text class="empty-text">未找到匹配的直播</text>
|
||||
</view>
|
||||
|
||||
<!-- 直播列表内容 -->
|
||||
<list v-else class="live-list-content" @loadmore="loadMore" :show-scrollbar="false">
|
||||
<cell v-for="(row, rowIndex) in groupedLiveList" :key="`row-${rowIndex}-${row[0]?.liveID || 'empty'}`">
|
||||
<view class="live-row">
|
||||
<view v-for="(live, index) in row" :key="live.liveID" class="live-card" @tap="handleJoinLive(live)">
|
||||
<view class="live-cover">
|
||||
<image :src="live.coverURL || defaultCoverURL" :mode="'aspectFill'" class="cover-image"
|
||||
@error="handleCoverError(live)" />
|
||||
<view class="live-status">
|
||||
<view class="live-bar-container">
|
||||
<view class="bar bar-1"></view>
|
||||
<view class="bar bar-2"></view>
|
||||
<view class="bar bar-3"></view>
|
||||
</view>
|
||||
<text class="viewer-count">{{ formatViewerCount(live.totalViewerCount) }}人看过</text>
|
||||
</view>
|
||||
<!-- 直播信息覆盖在封面图底部 -->
|
||||
<view class="live-info-overlay">
|
||||
<text class="live-title" :lines="1">{{ live.liveName }}</text>
|
||||
<view class="live-owner">
|
||||
<image :src="live?.liveOwner?.avatarURL || defaultAvatarURL" class="owner-avatar" mode="aspectFill" />
|
||||
<text class="owner-name"
|
||||
:numberOfLines="1">{{ live?.liveOwner?.userName || live?.liveOwner?.userID}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</cell>
|
||||
</list>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
computed
|
||||
} from 'vue';
|
||||
import {
|
||||
onLoad
|
||||
} from '@dcloudio/uni-app';
|
||||
|
||||
import {
|
||||
useLiveListState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveListState";
|
||||
const {
|
||||
liveList,
|
||||
liveListCursor,
|
||||
joinLive,
|
||||
fetchLiveList
|
||||
} = useLiveListState();
|
||||
|
||||
// 数据状态
|
||||
const inputLiveId = ref(''); // 'live_'
|
||||
const isLoadingJoin = ref(false);
|
||||
// 默认图片
|
||||
const defaultCoverURL =
|
||||
'https://liteav-test-1252463788.cos.ap-guangzhou.myqcloud.com/voice_room/voice_room_cover1.png';
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
const params = {
|
||||
cursor: "", // 首次拉起传空(不能是null),然后根据回调数据的cursor确认是否拉完
|
||||
count: 20, // 分页拉取的个数
|
||||
};
|
||||
fetchLiveList(params);
|
||||
})
|
||||
|
||||
// 无匹配结果标记(用于 UI 展示)
|
||||
const hasNoResults = computed(() => {
|
||||
const k = (inputLiveId.value || '').trim();
|
||||
return !!k && ((filteredLiveList.value || []).length === 0);
|
||||
});
|
||||
|
||||
// 进入直播间
|
||||
const handleJoinLive = async (live) => {
|
||||
try {
|
||||
uni.$liveID = live.liveID;
|
||||
|
||||
uni.redirectTo({
|
||||
url: `/pages/audience/index?liveID=${live.liveID}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('进入直播间失败:', error);
|
||||
uni.showToast({
|
||||
title: '进入直播间失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleJoinLiveById = () => {
|
||||
if (!inputLiveId.value.trim()) {
|
||||
uni.showToast({
|
||||
title: '请输入 liveID',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.$liveID = inputLiveId.value.trim();
|
||||
|
||||
isLoadingJoin.value = true;
|
||||
|
||||
joinLive({
|
||||
liveID: inputLiveId.value.trim(),
|
||||
success: () => {
|
||||
isLoadingJoin.value = false;
|
||||
uni.redirectTo({
|
||||
url: `/pages/audience/index?liveID=${inputLiveId.value.trim()}`,
|
||||
});
|
||||
},
|
||||
fail: (error) => {
|
||||
isLoadingJoin.value = false;
|
||||
console.error('进房失败 = ', JSON.stringify(error))
|
||||
|
||||
uni.showToast({
|
||||
title: "进入失败",
|
||||
icon: "none",
|
||||
content: error?.message || "房间不存在或已关闭",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCoverError = (live) => {
|
||||
live.coverURL = defaultCoverURL;
|
||||
}
|
||||
|
||||
// 根据输入框的 liveID 关键字过滤直播列表(前端过滤,不请求后端)
|
||||
const filteredLiveList = computed(() => {
|
||||
const keyword = (inputLiveId.value || '').trim().toLowerCase();
|
||||
if (!keyword) return liveList.value;
|
||||
return (liveList.value || []).filter(item => {
|
||||
const id = (item?.liveID || '').toLowerCase();
|
||||
const name = (item?.liveName || '').toLowerCase();
|
||||
const ownerName = (item?.liveOwner?.userName || item?.liveOwner?.userID || '').toLowerCase();
|
||||
return id.includes(keyword) || name.includes(keyword) || ownerName.includes(keyword);
|
||||
});
|
||||
});
|
||||
|
||||
// 将直播列表分组,每行两个元素(对过滤后的结果进行分组)
|
||||
const groupedLiveList = computed(() => {
|
||||
const source = filteredLiveList.value || [];
|
||||
const groups = [];
|
||||
for (let i = 0; i < source.length; i += 2) {
|
||||
const row = source.slice(i, i + 2);
|
||||
groups.push(row);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// 格式化观看人数
|
||||
const formatViewerCount = (count) => {
|
||||
if (count >= 10000) {
|
||||
return `${(count / 10000).toFixed(1)}万`;
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
if (!liveListCursor.value) {
|
||||
uni.showToast({
|
||||
title: "没有更多了",
|
||||
icon: "none"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const params = {
|
||||
cursor: liveListCursor.value,
|
||||
count: 20,
|
||||
};
|
||||
fetchLiveList(params);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.live-list {
|
||||
width: 750rpx;
|
||||
background-color: #FFFFFF;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.live-list-header {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.live-list-quick-join {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0 32rpx 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.quick-join-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
border: 1rpx solid #e0e0e0;
|
||||
border-radius: 999rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.live-list-content {
|
||||
width: 750rpx;
|
||||
}
|
||||
|
||||
.live-row {
|
||||
flex-direction: row;
|
||||
padding: 0 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.live-card {
|
||||
width: 334rpx;
|
||||
height: 500rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.live-cover {
|
||||
position: relative;
|
||||
width: 334rpx;
|
||||
height: 500rpx;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 334rpx;
|
||||
height: 400rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.live-status {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
left: 20rpx;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.live-bar-container {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 4rpx;
|
||||
background-color: #5AD69E;
|
||||
}
|
||||
|
||||
.bar-1 {
|
||||
height: 10rpx;
|
||||
margin-right: 2rpx;
|
||||
}
|
||||
|
||||
.bar-2 {
|
||||
height: 16rpx;
|
||||
margin-right: 2rpx;
|
||||
}
|
||||
|
||||
.bar-3 {
|
||||
height: 6rpx;
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* 新增:直播信息覆盖样式 */
|
||||
.live-info-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
padding: 16rpx 20rpx;
|
||||
width: 334rpx;
|
||||
background-color: rgba(240, 242, 247, 1);
|
||||
border-bottom-right-radius: 16rpx;
|
||||
border-bottom-left-radius: 16rpx;
|
||||
/* 半透明黑色背景 */
|
||||
}
|
||||
|
||||
.live-info-overlay .live-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin-bottom: 8rpx;
|
||||
lines: 1;
|
||||
max-width: 400rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live-info-overlay .live-owner {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.live-info-overlay .owner-avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.live-info-overlay .owner-name {
|
||||
font-size: 24rpx;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
lines: 1;
|
||||
max-width: 300rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container,
|
||||
.loading-more {
|
||||
padding: 32rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
margin-top: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 32rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
</style>
|
||||
296
uni_modules/tuikit-atomic-x/components/LiveStatusInfoCard.nvue
Normal file
296
uni_modules/tuikit-atomic-x/components/LiveStatusInfoCard.nvue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<view v-if="modelValue" class="bottom-drawer-container">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<!-- 标题区域 -->
|
||||
<view class="panel-header">
|
||||
<text class="panel-title">网络信息</text>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="panel-content">
|
||||
<!-- 视频状态 -->
|
||||
<!-- <view class="row">
|
||||
<image class="icon" src="/static/images/network-good.png" />
|
||||
<view class="row-content">
|
||||
<text class="row-title">视频状态正常</text>
|
||||
<text class="row-desc">开播画面流畅 | 清晰度:{{ videoQuality }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 音频状态 -->
|
||||
<!-- <view class="row">
|
||||
<image class="icon" src="/static/images/beauty.png" />
|
||||
<view class="row-content">
|
||||
<text class="row-title">音频状态正常</text>
|
||||
<text class="row-desc">适当音量保障观看体验 | 音质模式:{{ audioMode }}</text>
|
||||
</view>
|
||||
<slider class="audio-slider" :value="audioVolume" min="0" max="100" disabled />
|
||||
<text class="audio-value">{{ audioVolume }}</text>
|
||||
</view>
|
||||
<!-- 设备温度 -->
|
||||
<!-- <view class="row">
|
||||
<image class="icon" src="/static/images/temperature.png" />
|
||||
<view class="row-content">
|
||||
<text class="row-title">设备温度正常</text>
|
||||
<text class="row-desc">建议适时检查,保障观看体验</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<!-- 网络状态 -->
|
||||
<!-- <view class="row">
|
||||
<image class="icon" src="/static/images/network.png" />
|
||||
<view class="row-content">
|
||||
<text class="row-title">Wi-Fi/移动网络正常</text>
|
||||
<text class="row-desc">建议不要频繁切换网络</text>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 网络指标区域 -->
|
||||
<view class="metrics-section">
|
||||
<view class="metrics-grid">
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.delay > 100, green: networkInfo.delay <= 100 }">{{ networkInfo.delay }}ms</text>
|
||||
<text class="metric-label">往返延时</text>
|
||||
</view>
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.downLoss > 0.1, green: networkInfo.downLoss <= 0.1 }">{{ networkInfo.downLoss }}%</text>
|
||||
<text class="metric-label">下行丢包率</text>
|
||||
</view>
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.upLoss > 0.1, green: networkInfo.upLoss <= 0.1 }">{{ networkInfo.upLoss }}%</text>
|
||||
<text class="metric-label">上行丢包率</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits } from 'vue';
|
||||
import { useDeviceState } from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
|
||||
|
||||
const { networkInfo } = useDeviceState()
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
overflow: hidden;
|
||||
/* 添加这行 */
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.panel-header {
|
||||
padding: 40rpx 0 20rpx;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 0 48rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* 网络指标区域 */
|
||||
.metrics-section {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32rpx;
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.metric-value.red {
|
||||
color: rgba(230, 89, 76, 1);
|
||||
/* 红色 */
|
||||
}
|
||||
|
||||
.metric-value.green {
|
||||
color: rgba(56, 166, 115, 1);
|
||||
/* 绿色 */
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 保留原有样式,以备将来使用 */
|
||||
.live-status-info-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding-top: 20rpx
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
margin-right: 40rpx
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-desc {
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.audio-slider {
|
||||
flex: 1;
|
||||
margin: 0 8rpx;
|
||||
}
|
||||
|
||||
.audio-value {
|
||||
font-size: 24rpx;
|
||||
color: #222;
|
||||
width: 40rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.network-info {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.network-grid {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.value.red {
|
||||
color: rgba(230, 89, 76, 1);
|
||||
/* 红色 */
|
||||
}
|
||||
|
||||
.value.green {
|
||||
color: rgba(56, 166, 115, 1);
|
||||
/* 绿色 */
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }" @click.stop>
|
||||
<view class="drawer-header">
|
||||
<view class="user-info-section">
|
||||
<view class="avatar-container">
|
||||
<image class="user-avatar" :src="userInfo?.avatar || defaultAvatarURL" mode="aspectFill"></image>
|
||||
</view>
|
||||
<view class="user-details">
|
||||
<text class="username">{{ userInfo?.userName || '' }}</text>
|
||||
<text class="user-id">ID: {{ userInfo?.userID || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="drawer-content">
|
||||
<view class="drawer-actions">
|
||||
<view class="action-btn" @tap.stop="microphoneOperation">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" v-if="targetMicStatus !== 'OFF'" src="/static/images/mute-mic.png"
|
||||
mode="aspectFit" />
|
||||
<image class="action-btn-image" v-else src="/static/images/unmute-mic.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content" v-if="targetMicStatus !== 'OFF'">{{ micOffText }}</text>
|
||||
<text class="action-btn-content" v-else>{{ micOnText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="action-btn" @tap="cameraOperation">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" v-if="targetCameraStatus !== 'OFF'" src="/static/images/end-camera.png"
|
||||
mode="aspectFit" />
|
||||
<image class="action-btn-image" v-else src="/static/images/start-camera.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content" v-if="targetCameraStatus !== 'OFF'">{{ cameraOffText }}</text>
|
||||
<text class="action-btn-content" v-else>{{ cameraOnText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="action-btn" @tap="flipCamera" v-if="showFlip">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" src="/static/images/flip.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content">翻转</text>
|
||||
</view>
|
||||
|
||||
<view class="action-btn" @tap="handleHangUp" v-if="showHangup">
|
||||
<view class="action-btn-image-container">
|
||||
<image class="action-btn-image" src="/static/images/hangup.png" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-btn-content">{{ hangupText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch
|
||||
} from 'vue';
|
||||
import {
|
||||
useDeviceState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
|
||||
import {
|
||||
useCoGuestState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
|
||||
import {
|
||||
useCoHostState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoHostState";
|
||||
import {
|
||||
useLiveSeatState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveSeatState";
|
||||
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 点击对象信息:{ userID, userName, seatIndex, avatar, ... }
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
liveID: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 展示模式:主播或观众
|
||||
isAnchorMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否当前登录者本人(主播/观众自我操作用)
|
||||
isSelf: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const close = () => emit('update:modelValue', false);
|
||||
// 设备与连麦状态
|
||||
const {
|
||||
microphoneStatus,
|
||||
cameraStatus,
|
||||
isFrontCamera,
|
||||
openLocalCamera,
|
||||
closeLocalCamera,
|
||||
openLocalMicrophone,
|
||||
closeLocalMicrophone,
|
||||
switchCamera,
|
||||
} = useDeviceState(uni?.$liveID);
|
||||
const {
|
||||
exitHostConnection,
|
||||
coHostStatus
|
||||
} = useCoHostState(uni?.$liveID);
|
||||
const {
|
||||
disconnect
|
||||
} = useCoGuestState(uni?.$liveID);
|
||||
const {
|
||||
seatList,
|
||||
muteMicrophone,
|
||||
unmuteMicrophone,
|
||||
openRemoteCamera,
|
||||
closeRemoteCamera,
|
||||
openRemoteMicrophone,
|
||||
closeRemoteMicrophone,
|
||||
kickUserOutOfSeat
|
||||
} = useLiveSeatState(uni?.$liveID);
|
||||
|
||||
// 控件显隐与文案
|
||||
const showMic = computed(() => props.isSelf || (!props.isSelf && !props.isAnchorMode));
|
||||
const showCamera = computed(() => props.isSelf || (!props.isSelf && !props.isAnchorMode));
|
||||
const showFlip = computed(() => props.isSelf && targetCameraStatus.value === 'ON');
|
||||
const showHangup = computed(() => !props.isSelf || (props.isSelf && !props.isAnchorMode));
|
||||
|
||||
const micOffText = computed(() => props.isAnchorMode && props.isSelf ? '静音' : '关闭音频');
|
||||
const micOnText = computed(() => props.isAnchorMode && props.isSelf ? '解除静音' : '打开音频');
|
||||
const cameraOffText = computed(() => props.isAnchorMode && props.isSelf ? '关闭视频' : '关闭视频');
|
||||
const cameraOnText = computed(() => props.isAnchorMode && props.isSelf ? '打开视频' : '打开视频');
|
||||
const hangupText = computed(() => props.isAnchorMode ? '移下麦' : '断开连麦');
|
||||
|
||||
const targetSeat = computed(() => {
|
||||
const list = seatList.value || [];
|
||||
const userID = props?.userInfo?.userID;
|
||||
if (!userID || !Array.isArray(list)) return null;
|
||||
return list.find(item => item?.userInfo?.userID === userID) || null;
|
||||
});
|
||||
|
||||
const targetMicStatus = computed(() => {
|
||||
return targetSeat.value?.userInfo?.microphoneStatus || 'OFF';
|
||||
});
|
||||
|
||||
const targetCameraStatus = computed(() => {
|
||||
return targetSeat.value?.userInfo?.cameraStatus || 'OFF';
|
||||
});
|
||||
|
||||
// seatList 变化自动关闭(当目标用户离席)
|
||||
watch(seatList, (newVal, oldVal) => {
|
||||
if (!oldVal || !newVal || newVal.length === oldVal.length) return;
|
||||
if (newVal.length < oldVal.length) {
|
||||
const missing = oldVal.find(oldItem => !newVal.some(newItem => newItem.userInfo.userID === oldItem.userInfo
|
||||
.userID));
|
||||
if (missing && missing.userInfo?.userID === props?.userInfo?.userID) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true
|
||||
});
|
||||
|
||||
// 操作区
|
||||
const microphoneOperation = () => {
|
||||
if (!props.isSelf) {
|
||||
if (targetMicStatus.value === 'OFF') {
|
||||
openRemoteMicrophone({
|
||||
liveID: uni.$liveID,
|
||||
userID: props.userInfo.userID,
|
||||
policy: 'UNLOCK_ONLY'
|
||||
});
|
||||
uni.showToast({
|
||||
title: `您已将${props.userInfo.userName || props.userInfo.userID} 解除静音`,
|
||||
icon: 'none'
|
||||
})
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (targetMicStatus.value === 'ON') {
|
||||
closeRemoteMicrophone({
|
||||
liveID: uni.$liveID,
|
||||
userID: props.userInfo.userID,
|
||||
});
|
||||
uni.showToast({
|
||||
title: `您已将${props.userInfo.userName || props.userInfo.userID} 静音`,
|
||||
icon: 'none'
|
||||
})
|
||||
close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (targetMicStatus.value === 'OFF') {
|
||||
unmuteMicrophone({
|
||||
liveID: uni.$liveID,
|
||||
fail: (errorCode) => {
|
||||
if (errorCode === -2360) {
|
||||
uni.showToast({
|
||||
title: '你已被静音',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (targetMicStatus.value === 'ON') {
|
||||
muteMicrophone({
|
||||
liveID: uni.$liveID
|
||||
});
|
||||
close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const cameraOperation = () => {
|
||||
if (!props.isSelf) {
|
||||
if (targetCameraStatus.value === 'OFF') {
|
||||
openRemoteCamera({
|
||||
liveID: uni.$liveID,
|
||||
userID: props.userInfo.userID,
|
||||
policy: 'UNLOCK_ONLY'
|
||||
});
|
||||
uni.showToast({
|
||||
title: `您已将${props.userInfo.userName || props.userInfo.userID} 解除禁画`,
|
||||
icon: 'none'
|
||||
})
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (targetCameraStatus.value === 'ON') {
|
||||
closeRemoteCamera({
|
||||
liveID: uni.$liveID,
|
||||
userID: props.userInfo.userID,
|
||||
});
|
||||
uni.showToast({
|
||||
title: `您已将${props.userInfo.userName || props.userInfo.userID} 禁画`,
|
||||
icon: 'none'
|
||||
})
|
||||
close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (targetCameraStatus.value === 'OFF') {
|
||||
openLocalCamera({
|
||||
isFront: isFrontCamera.value,
|
||||
fail: (errorCode) => {
|
||||
if (errorCode === -2370) {
|
||||
uni.showToast({
|
||||
title: '你已被禁画',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (targetCameraStatus.value === 'ON') {
|
||||
closeLocalCamera();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const flipCamera = () => {
|
||||
if (cameraStatus.value !== 'ON' || !props.isSelf) return;
|
||||
switchCamera({
|
||||
isFront: !isFrontCamera.value
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
const handleHangUp = () => {
|
||||
if (props.isAnchorMode) {
|
||||
// 主播模式:当自己处于主播连线时,提示断开与主播的连线;否则断开与观众的连麦
|
||||
if (coHostStatus.value === 'CONNECTED') {
|
||||
uni.showModal({
|
||||
content: '确定要断开与其他主播的连线吗?',
|
||||
confirmText: '断开',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
exitHostConnection({
|
||||
liveID: uni?.$liveID
|
||||
});
|
||||
}
|
||||
close();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
uni.showModal({
|
||||
content: '确定要断开与其他观众的连麦吗?',
|
||||
confirmText: '断开',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
kickUserOutOfSeat({
|
||||
liveID: uni?.$liveID,
|
||||
userID: props.userInfo.userID,
|
||||
success: () => {},
|
||||
fail: () => {},
|
||||
});
|
||||
}
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 观众模式:断开当前连麦
|
||||
uni.showModal({
|
||||
content: '确定要断开当前连麦吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
disconnect({
|
||||
liveID: uni?.$liveID,
|
||||
success: () => {},
|
||||
fail: () => {},
|
||||
});
|
||||
close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(31, 32, 36, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
height: 400rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 40rpx 48rpx;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 32rpx;
|
||||
color: #C5CCDB;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 28rpx;
|
||||
color: #7C85A6;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
height: 400rpx;
|
||||
justify-content: flex-start;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 160rpx;
|
||||
margin-left: 10rpx;
|
||||
width: 120rpx
|
||||
}
|
||||
|
||||
.action-btn-image-container {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 20rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn-image {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
}
|
||||
|
||||
.action-btn-content {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<view class="live-stream-view-container">
|
||||
<view class="live-stream-view-content" :class="{ 'audience-mode': !isAnchor }" @touchend.stop="" :style="{
|
||||
top: isAnchor ? systemInfo.safeArea.top + 'px' : '0px',
|
||||
left: isAnchor ? '0px' : systemInfo.safeArea.left + 'px',
|
||||
width: isAnchor ? safeArea.width + 'px' : systemInfo.safeArea.width + 'px',
|
||||
height: isAnchor ? systemInfo.safeArea?.height + 'px' : systemInfo.windowHeight + 'px',
|
||||
}">
|
||||
<!-- 主播模式:显示默认头像 -->
|
||||
<view v-if="isAnchor && cameraStatus === 'OFF' && seatList.length === 1 && isLiving" class="default-avatar"
|
||||
:style="{ top: safeArea.height * 1/2 + 'px' }">
|
||||
<image style="width: 96rpx; height: 96rpx; border-radius: 99px;"
|
||||
:src="loginUserInfo.avatarURL || defaultAvatarURL" />
|
||||
</view>
|
||||
|
||||
<!-- 主视频流 -->
|
||||
<live-core-view class="live-stream-view-background" :liveID="liveID"
|
||||
:viewType=" isAnchor ? 'PUSH_VIEW' : 'PLAY_VIEW'" @tap="streamViewClick(loginUserInfo)" :style="{
|
||||
top: '0px',
|
||||
width: safeArea.width + 'px',
|
||||
height: streamViewHeight + 'px',
|
||||
}">
|
||||
</live-core-view>
|
||||
|
||||
<!-- 单人模式(观众视角特有) -->
|
||||
<view v-if="!isAnchor && seatList?.length === 1" @touchend.stop="" class="grid-content-cell"
|
||||
v-for="(participant, colIndex) in seatList" :key="`${colIndex}`" @tap="streamViewClick(participant)" :style="{
|
||||
left: participant?.region?.x * scale.scaleX + 'px',
|
||||
top: (calculateTopValue(participant) - 2) + 'px',
|
||||
'background-color': participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
|
||||
}">
|
||||
<view style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
|
||||
:style="{
|
||||
width: participant?.region?.w * scale.scaleX + 'px',
|
||||
height: (streamViewHeight + 10) + 'px',
|
||||
}">
|
||||
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
|
||||
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
|
||||
mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 多人模式:非网格布局 -->
|
||||
<view v-if="seatList?.length > 1 && (templateLayout !== 801 && templateLayout !== 800)" @touchend.stop=""
|
||||
class="grid-content-cell" v-for="(participant, colIndex) in seatList" :key="`${colIndex}`"
|
||||
@tap="streamViewClick(participant)" style="border-radius: 0rpx;" :style="{
|
||||
left: participant?.region?.x * scale.scaleX + 'px',
|
||||
top: calculateTopValue(participant) + (isAnchor ? 0 : -2) + 'px',
|
||||
'background-color': !isAnchor && participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
|
||||
}">
|
||||
<view style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
|
||||
:style="{
|
||||
width: participant?.region?.w * scale.scaleX + 'px',
|
||||
height: isAnchor ? (participant?.region?.h * scale.scaleY + 'px') : (participant?.region?.h * scale.scaleY + 2) + 'px',
|
||||
}">
|
||||
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
|
||||
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
|
||||
mode="aspectFill" />
|
||||
</view>
|
||||
|
||||
<view class="participant-info-container">
|
||||
<image v-if="participant?.userInfo?.microphoneStatus === 'OFF'" class="mic-icon"
|
||||
src="/static/images/unmute-mic.png" mode="aspectFit" />
|
||||
<text class="participant-name" :style="{ 'max-width': participant?.region.w * scale.scaleX * 0.85 + 'px' }">
|
||||
{{ participant?.userInfo?.userName || participant?.userInfo?.userID }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 多人模式:网格布局 -->
|
||||
<view v-if="seatList?.length > 1 && (templateLayout === 801 || templateLayout === 800)" @touchend.stop=""
|
||||
class="grid-content-cell" v-for="(participant, colIndex) in seatList"
|
||||
:key="`${isAnchor ? colIndex : participant.index}`" @tap="streamViewClick(participant)"
|
||||
style="border-radius: 0rpx;" :style="{
|
||||
left: participant?.region?.x * scale.scaleX + 'px',
|
||||
top: calculateTopValue(participant) + (isAnchor ? 0 : -2) + 'px',
|
||||
'background-color': !isAnchor && participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
|
||||
}">
|
||||
<view v-if="participant?.userInfo.userID"
|
||||
style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
|
||||
:style="{
|
||||
width: participant?.region?.w * scale.scaleX + 'px',
|
||||
height: isAnchor ? (participant?.region?.h * scale.scaleY + 'px') : (participant?.region?.h * scale.scaleY + 2) + 'px',
|
||||
}">
|
||||
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
|
||||
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
|
||||
mode="aspectFill" />
|
||||
</view>
|
||||
|
||||
<view class="participant-info-container" :class="{ 'audience-mode': !isAnchor }">
|
||||
<image v-if="participant?.userInfo?.microphoneStatus === 'OFF'" class="mic-icon"
|
||||
src="/static/images/unmute-mic.png" mode="aspectFit" />
|
||||
<text class="participant-name" :style="{ 'max-width': participant?.region.w * scale.scaleX * 0.85 + 'px' }">
|
||||
{{ participant?.userInfo?.userName || participant?.userInfo?.userID }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="participant-wait-container" v-else :style="{
|
||||
width: participant?.region?.w * scale.scaleX + 'px',
|
||||
height: participant?.region?.h * scale.scaleY + 'px',
|
||||
'border-bottom': '1rpx solid #000',
|
||||
'background-color': '#1f2024',
|
||||
'border-left': isAnchor && templateLayout === 800 ? '1rpx solid #000' : '',
|
||||
'border-right': !isAnchor && templateLayout === 800 ? '1rpx solid #000' : '',
|
||||
}">
|
||||
<text class="participant-wait-content">{{ isAnchor ? colIndex : participant?.index }}</text>
|
||||
<text class="participant-wait-content">等待连麦</text>
|
||||
</view>
|
||||
</view>
|
||||
<LiveStreamActionPanel v-if="enableClickPanel" v-model="isShowClickPanel" :liveID="liveID || ''"
|
||||
:userInfo="clickUserInfo" :isAnchorMode="isAnchor"
|
||||
:isSelf="(isAnchor ? (clickUserInfo?.userID === loginUserInfo?.userID) : (clickUserInfo?.userID === currentLoginUserId))" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
computed,
|
||||
watch
|
||||
} from 'vue';
|
||||
import {
|
||||
useLiveSeatState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LiveSeatState";
|
||||
import {
|
||||
useLoginState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/LoginState";
|
||||
import {
|
||||
useCoHostState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoHostState";
|
||||
import {
|
||||
useDeviceState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
|
||||
import {
|
||||
useCoGuestState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
|
||||
import LiveStreamActionPanel from '@/uni_modules/tuikit-atomic-x/components/LiveStreamView/LiveStreamActionPanel.nvue';
|
||||
|
||||
const props = defineProps({
|
||||
liveID: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
onStreamViewClick: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
isAnchor: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
templateLayout: {
|
||||
type: Number,
|
||||
},
|
||||
isLiving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentLoginUserId: {
|
||||
type: String,
|
||||
}
|
||||
,
|
||||
enableClickPanel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['streamViewClick']);
|
||||
|
||||
// 状态管理
|
||||
const {
|
||||
loginUserInfo
|
||||
} = useLoginState();
|
||||
const {
|
||||
connected,
|
||||
coHostStatus
|
||||
} = useCoHostState(uni?.$liveID);
|
||||
const {
|
||||
cameraStatus
|
||||
} = useDeviceState();
|
||||
const {
|
||||
connected: audienceConnected
|
||||
} = useCoGuestState(uni?.$liveID)
|
||||
// 使用 rpx
|
||||
const standardWidth = 750;
|
||||
let standardHeight = standardWidth * 16 / 9;
|
||||
const safeArea = ref({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: standardWidth,
|
||||
height: standardHeight,
|
||||
});
|
||||
const systemInfo = ref({});
|
||||
|
||||
const deviceWidthRatio = ref(1);
|
||||
const deviceHeightRatio = ref(1);
|
||||
const bottomPanelHeight = ref(80);
|
||||
const pixelWidthRatio = ref(1);
|
||||
|
||||
const {
|
||||
seatList,
|
||||
canvas,
|
||||
lockSeat,
|
||||
} = props?.liveID && useLiveSeatState(props?.liveID);
|
||||
const platform = ref('android');
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
|
||||
const scale = ref({
|
||||
scaleX: 1,
|
||||
scaleY: 1
|
||||
});
|
||||
const streamViewHeight = ref(1);
|
||||
const isShowClickPanel = ref(false);
|
||||
const clickUserInfo = ref<any>({});
|
||||
|
||||
function calculateScale(originalWidth : number, originalHeight : number, displayWidth : number, displayHeight : number) {
|
||||
// 等比缩放(以宽度或高度中较小的比例为准)
|
||||
const scaleX = displayWidth / originalWidth;
|
||||
const scaleY = displayHeight / originalHeight;
|
||||
|
||||
return { scaleX, scaleY }; // 确保不变形
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
onMounted(() => {
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
systemInfo.value = res;
|
||||
|
||||
safeArea.value = res.safeArea;
|
||||
deviceWidthRatio.value = standardWidth / res.windowWidth;
|
||||
platform.value = res.platform;
|
||||
|
||||
if (platform.value === 'ios') {
|
||||
bottomPanelHeight.value = 60;
|
||||
}
|
||||
|
||||
if (props.isAnchor) {
|
||||
streamViewHeight.value = systemInfo.value.safeArea.height - bottomPanelHeight.value;
|
||||
} else {
|
||||
streamViewHeight.value = systemInfo.value.windowHeight;
|
||||
}
|
||||
|
||||
console.warn(`LiveStreamView: isAnchor=${props.isAnchor}, safeArea: ${JSON.stringify(res.safeArea)},
|
||||
deviceWidthRatio: ${deviceWidthRatio.value}, deviceHeightRatio: ${deviceHeightRatio.value},
|
||||
windowWidth: ${res.windowWidth}, windowHeight: ${res.windowHeight},
|
||||
streamViewHeight: ${streamViewHeight.value}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 下发的视频像素值
|
||||
watch(canvas, (newValue) => {
|
||||
if (newValue?.w) {
|
||||
pixelWidthRatio.value = (safeArea.value.width / newValue?.w) * deviceWidthRatio.value;
|
||||
console.warn(`LiveStreamView: canvas 变动: ${pixelWidthRatio.value}`)
|
||||
}
|
||||
|
||||
if (newValue?.w && newValue?.h) {
|
||||
scale.value = calculateScale(
|
||||
newValue.w,
|
||||
newValue.h,
|
||||
systemInfo.value.safeArea.width,
|
||||
systemInfo.value.safeArea.width * newValue.h / newValue.w,
|
||||
);
|
||||
|
||||
console.warn(
|
||||
`LiveStreamView: scale value: ${JSON.stringify(scale.value)}, streamViewHeight: ${streamViewHeight.value}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const streamViewClick = (options) => {
|
||||
console.log(`LiveStreamView: click options: ${JSON.stringify(options)}, isAnchor: ${props.isAnchor}`);
|
||||
if (!props.isLiving) return
|
||||
// 非主播模式且没有参与者信息(背景点击),直接忽略
|
||||
if (!props.isAnchor && !options?.userInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.isAnchor) {
|
||||
// 主播模式:区分点击背景(loginUserInfo)和点击参与者(带 userInfo)
|
||||
if (options?.userInfo) {
|
||||
if (audienceConnected.value.length > 1 && connected?.value.length === 0) {
|
||||
const userInfo = { ...options.userInfo, seatIndex: options.index };
|
||||
if (!userInfo?.userID) return;
|
||||
if (props.enableClickPanel) {
|
||||
clickUserInfo.value = userInfo;
|
||||
isShowClickPanel.value = true;
|
||||
}
|
||||
}
|
||||
if (connected?.value.length > 0) {
|
||||
if (options?.userInfo.userID === uni.$userID) {
|
||||
const userInfo = { ...options.userInfo, seatIndex: options.index };
|
||||
if (!userInfo?.userID) return;
|
||||
if (props.enableClickPanel) {
|
||||
clickUserInfo.value = userInfo;
|
||||
isShowClickPanel.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 背景点击:直接把当前登录用户信息抛给上层,保持原有行为
|
||||
// props.onStreamViewClick?.(options);
|
||||
// emit('streamViewClick', options);
|
||||
if (props.enableClickPanel) {
|
||||
clickUserInfo.value = { ...(loginUserInfo.value || {}), userID: loginUserInfo.value?.userID } as any;
|
||||
isShowClickPanel.value = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 面板限制规则仅影响面板,不影响事件下发给页面
|
||||
const isClickSelf = options?.userInfo?.userID === props?.currentLoginUserId;
|
||||
const isTemplateGrid = props.templateLayout === 800 || props.templateLayout === 801;
|
||||
const isCoGuesting = uni.$localGuestStatus !== 'DISCONNECTED';
|
||||
const payload = { ...options.userInfo, seatIndex: options.index };
|
||||
|
||||
if (props.enableClickPanel && isClickSelf) {
|
||||
clickUserInfo.value = payload as any;
|
||||
isShowClickPanel.value = true;
|
||||
return
|
||||
}
|
||||
props.onStreamViewClick?.(payload);
|
||||
emit('streamViewClick', payload);
|
||||
}
|
||||
};
|
||||
|
||||
// 返回 px 对应的计算值
|
||||
const calculateTopValue = (participant) => {
|
||||
let topValue = 1;
|
||||
console.log(
|
||||
`LiveStreamView: templateLayout: ${props.templateLayout}, platform: ${platform.value}, isAnchor: ${props.isAnchor}`
|
||||
);
|
||||
if (!participant) return topValue;
|
||||
|
||||
if (props.templateLayout !== 800 && props.templateLayout !== 801) {
|
||||
topValue = participant.region.y * scale.value.scaleY;
|
||||
}
|
||||
if (props.templateLayout === 800) {
|
||||
topValue = participant.region.y * scale.value.scaleY;
|
||||
}
|
||||
if (props.templateLayout === 801) {
|
||||
topValue = participant.region.y * scale.value.scaleY;
|
||||
}
|
||||
|
||||
console.log(`LiveStreamView: topValue: ${topValue}`);
|
||||
|
||||
return topValue;
|
||||
};
|
||||
|
||||
watch(seatList, (newValue) => {
|
||||
if ((newValue || []).length > 1 && canvas.value?.w && canvas.value?.h) {
|
||||
if (props.isAnchor) {
|
||||
streamViewHeight.value = systemInfo.value.safeArea.height - bottomPanelHeight.value;
|
||||
} else {
|
||||
streamViewHeight.value = systemInfo.value.windowHeight;
|
||||
}
|
||||
}
|
||||
if (!props.isAnchor && (newValue || []).length === 1 && canvas.value?.w && canvas.value?.h &&
|
||||
audienceConnected.value.length === 1) {
|
||||
streamViewHeight.value = systemInfo.value.windowHeight;
|
||||
}
|
||||
|
||||
console.log(`LiveStreamView: seatList change streamViewHeight: ${streamViewHeight.value}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 观众模式特有的状态监听
|
||||
if (!props.isAnchor) {
|
||||
watch(audienceConnected.value, (newVal, oldVal) => {
|
||||
console.log(
|
||||
`LiveStreamView: audience localStatus change, newVal: ${newVal}, oldVal: ${oldVal}, templateLayout: ${props.templateLayout}`
|
||||
);
|
||||
|
||||
if (newVal.length === 1) {
|
||||
streamViewHeight.value = systemInfo.value.windowHeight;
|
||||
console.log(`LiveStreamView: audience localStatus change streamViewHeight: ${streamViewHeight.value}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
streamViewClick,
|
||||
calculateTopValue,
|
||||
scale,
|
||||
streamViewHeight
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.live-stream-view-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: rgba(15, 16, 20, 0.5);
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-stream-view-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.live-stream-view-content.audience-mode {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.live-stream-view-background {
|
||||
position: relative;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-image-cell {
|
||||
position: absolute;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.participant-video {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 80rpx;
|
||||
}
|
||||
|
||||
.grid-content-cell {
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participant-info-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: rgba(34, 38, 46, 0.4);
|
||||
border-radius: 38rpx;
|
||||
height: 36rpx;
|
||||
padding-left: 6rpx;
|
||||
padding-right: 12rpx;
|
||||
left: 6rpx;
|
||||
bottom: 6rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.participant-info-container.audience-mode {
|
||||
/* 观众模式下的特殊样式 */
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-left: 4rpx;
|
||||
background-color: rgba(34, 38, 46, 0);
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
margin-left: 2rpx;
|
||||
text-align: center;
|
||||
min-width: 80rpx;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participant-wait-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.participant-wait-content {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.default-avatar {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<view class="avatar-container">
|
||||
<image class="avatar" :src="userInfo?.avatarURL || defaultAvatarURL" mode="aspectFill" />
|
||||
</view>
|
||||
|
||||
<view class="user-info-container" v-if="isShowAnchor">
|
||||
<text class="user-name">{{ userInfo?.userName || userInfo?.userID || '' }}</text>
|
||||
<text class="user-roomid">直播房间ID:{{ userInfo?.liveID || userInfo?.roomId }}</text>
|
||||
</view>
|
||||
<view class="user-info-container" v-if="!isShowAnchor">
|
||||
<text class="user-name">{{ userInfo?.userName || userInfo?.userID || '' }}</text>
|
||||
<text class="user-roomid">UserId:{{ userInfo?.userID }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
userInfo: { type: Object, default: () => ({}) },
|
||||
isShowAnchor: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const close = () => { emit('update:modelValue', false); };
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container { position: fixed; bottom: 0; left: 0; right: 0; top: 0; z-index: 1000; }
|
||||
.drawer-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(15, 16, 20, 0.8); }
|
||||
.bottom-drawer { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(34, 38, 46, 1); transition-property: transform; transition-duration: 0.3s; transition-timing-function: ease; flex-direction: column; align-items: center; height: 400rpx; padding: 20rpx 0; border-top-left-radius: 32rpx; border-top-right-radius: 32rpx; overflow: hidden; }
|
||||
.drawer-open { transform: translateY(0); }
|
||||
.avatar-container { width: 200rpx; height: 120rpx; justify-content: center; align-items: center; position: absolute; }
|
||||
.avatar { width: 112rpx; height: 112rpx; border-radius: 56rpx; }
|
||||
.user-info-container { flex: 1; padding-top: 120rpx; align-items: center; }
|
||||
.user-name { font-size: 32rpx; color: rgba(255, 255, 255, 0.9); }
|
||||
.user-roomid { font-size: 24rpx; color: rgba(255, 255, 255, 0.55); margin: 20rpx 0; }
|
||||
.divider-line-container { height: 68rpx; justify-content: center; position: relative; align-items: center; }
|
||||
.divider-line { width: 268rpx; height: 10rpx; border-radius: 200rpx; background-color: rgba(255, 255, 255, 1); position: absolute; bottom: 16rpx; }
|
||||
</style>
|
||||
|
||||
282
uni_modules/tuikit-atomic-x/components/NetworkQualityPanel.nvue
Normal file
282
uni_modules/tuikit-atomic-x/components/NetworkQualityPanel.nvue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<!-- 仪表盘面板组件 -->
|
||||
<!-- 使用方式: <NetworkQualityPanel v-model="showDashboardPanel" /> -->
|
||||
<view class="bottom-drawer-container" v-if="modelValue">
|
||||
<view class="drawer-overlay" @tap="close"></view>
|
||||
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
|
||||
<!-- 标题区域 -->
|
||||
<view class="panel-header">
|
||||
<text class="panel-title">仪表盘</text>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="panel-content">
|
||||
<!-- 网络指标区域 -->
|
||||
<view class="metrics-section">
|
||||
<view class="metrics-grid">
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.delay > 100, green: networkInfo.delay <= 100 }">{{ networkInfo.delay }}ms</text>
|
||||
<text class="metric-label">往返延时</text>
|
||||
</view>
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.downLoss > 0.1, green: networkInfo.downLoss <= 0.1 }">{{ networkInfo.downLoss }}%</text>
|
||||
<text class="metric-label">下行丢包率</text>
|
||||
</view>
|
||||
<view class="metric-item">
|
||||
<text class="metric-value"
|
||||
:class="{ red: networkInfo.upLoss > 0.1, green: networkInfo.upLoss <= 0.1 }">{{ networkInfo.upLoss }}%</text>
|
||||
<text class="metric-label">上行丢包率</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 视频和音频信息区域 -->
|
||||
<!-- <view class="info-section"> -->
|
||||
<!-- 视频信息 -->
|
||||
<!-- <view class="info-card">
|
||||
<text class="info-title">视频信息</text>
|
||||
<view class="info-list">
|
||||
<view class="info-item">
|
||||
<text class="info-label">分辨率</text>
|
||||
<text class="info-value">{{ videoInfo.resolution }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">码率</text>
|
||||
<text class="info-value">{{ videoInfo.bitrate }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">帧率</text>
|
||||
<text class="info-value">{{ videoInfo.frameRate }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 音频信息 -->
|
||||
<!-- <view class="info-card">
|
||||
<text class="info-title">音频信息</text>
|
||||
<view class="info-list">
|
||||
<view class="info-item">
|
||||
<text class="info-label">采样率</text>
|
||||
<text class="info-value">{{ audioInfo.sampleRate }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">码率</text>
|
||||
<text class="info-value">{{ audioInfo.bitrate }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
reactive
|
||||
} from 'vue'
|
||||
import {
|
||||
useDeviceState
|
||||
} from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
|
||||
|
||||
const {
|
||||
networkInfo
|
||||
} = useDeviceState()
|
||||
|
||||
/**
|
||||
* 仪表盘面板组件
|
||||
*
|
||||
* Props:
|
||||
* - modelValue: Boolean - 控制面板显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* - update:modelValue - 更新面板显示状态
|
||||
*/
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 响应式数据
|
||||
const networkMetrics = reactive({
|
||||
roundTripDelay: 45,
|
||||
downlinkPacketLoss: 0,
|
||||
uplinkPacketLoss: 2
|
||||
})
|
||||
|
||||
const videoInfo = reactive({
|
||||
resolution: '1080*1920',
|
||||
bitrate: '6015 kbps',
|
||||
frameRate: '31 FPS'
|
||||
})
|
||||
|
||||
const audioInfo = reactive({
|
||||
sampleRate: '48000 HZ',
|
||||
bitrate: '6015 kbps'
|
||||
})
|
||||
|
||||
// 方法
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
const close = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-drawer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
transform: translateY(100%);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.panel-header {
|
||||
padding: 40rpx 0 20rpx;
|
||||
background: rgba(34, 38, 46, 1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 0 48rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* 网络指标区域 */
|
||||
.metrics-section {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: rgba(43, 44, 48, 1);
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32rpx;
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.metric-value.red {
|
||||
color: rgba(230, 89, 76, 1);
|
||||
/* 红色 */
|
||||
}
|
||||
|
||||
.metric-value.green {
|
||||
color: rgba(56, 166, 115, 1);
|
||||
/* 绿色 */
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 信息区域 */
|
||||
.info-section {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
flex: 1;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin: 0 8rpx;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 30rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user