需要添加直播接口

This commit is contained in:
cbb
2026-01-12 17:52:15 +08:00
parent 83fec2617c
commit 13af9eb303
281 changed files with 313157 additions and 104 deletions

View 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>

View 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>

View 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>

View 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值超过90API调用时限制为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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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)
},
})
})
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>