分包+主播库

This commit is contained in:
2025-08-22 16:35:32 +08:00
parent f7c04c88d4
commit 5bfb9027b6
13 changed files with 920 additions and 189 deletions

View File

@@ -1,16 +1,8 @@
<template>
<div class="main">
<el-scrollbar class="left"> <!-- 左边栏 -->
<div class="center-line"> <!-- 左边栏按钮 -->
<div v-for="(btn, index) in buttons" :key="index" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button" :class="[{ active: isActive(btn), disabled: isDisabled(btn) }]"
:style="btn.style ? btn.style() : {}" @click="handleBtnClick(btn)" @mouseenter="hoverIndex = index"
@mouseleave="hoverIndex = null">
<img :src="hoverIndex === index ? btn.img.hover : btn.img.normal" alt="">
{{ btn.label }}
</div>
</div>
</div>
<LeftToolbar :buttons="buttons" :active-key="activeKey" :is-locked="isLocked" @click="handleBtnClick" />
<el-button style="position: absolute;left: 20px; bottom: 20px;" @click="showHostDlg = true">执行主播库</el-button>
</el-scrollbar>
<!-- 中间手机区域 -->
<div class="content" @click.self="selectedDevice = 999">
@@ -38,7 +30,7 @@
<!-- <div class="app-button" @click="wsActions.getSize(device.udid, index)">获取屏幕尺寸</div> -->
<div class="app-button" @click="wsActions.test(device.udid, index)">打印ui节点树</div>
<div class="app-button" @click="wsActions.isOneLive(device.udid, index)">判断单人还是双人</div>
<!-- <div class="app-button" @click="wsActions.isOneLive(device.udid, index)">判断单人还是双人</div> -->
<div class="app-button" @click="wsActions.slideDown(device.udid, index)">下滑</div>
<div class="app-button" @click="wsActions.killNow(device.udid, index)">关闭当前应用</div>
<div class="app-button" @click="chooseFile(device.udid, index, 1, wsActions)">安装 APK
@@ -61,6 +53,7 @@
</div>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='""' :title="dialogTitle" :index="selectedDevice"
@confirm="onDialogConfirm" @cancel="stop" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" />
</div>
</template>
@@ -70,7 +63,7 @@ import VideoConverter from "h264-converter";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
getHostList, setHostList, addToHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
setsessionId, getsessionId
} from '@/stores/storage'
@@ -88,11 +81,17 @@ import { prologue, comment } from '@/api/account';
import { createTaskQueue } from '@/composables/useTaskQueue' //创建任务
import { useCanvasPointer } from '@/composables/useCanvasPointer' //canvas 初始化 点击转换
import { attachTrimmerForIndex } from '@/composables/useVideoStream' //修剪器
import HostListManagerDialog from '@/components/HostListManagerDialog.vue'
import { useMonitor } from '@/composables/useMonitor'
import { useTeardown } from '@/composables/useTeardown' //销毁
import LeftToolbar from '@/components/LeftToolbar.vue' //左侧工具栏
import { useStreams } from '@/composables/useStreams'
const router = useRouter();
let wsActions = null;
let userdata = getUser();
// 引入刷新方法
// const reload = inject("reload")
const reloadPage = inject("reload")
let phone = ref({ width: 207, height: 470 });
const openStr = base64ToBinary("ZQBwAAAAAAA8CgLQAtAAAAAAAAAAAAD/AAAAAAAAAAAAAAAA"); //开启视频流的启动命令
@@ -155,8 +154,7 @@ const mouseData = {
let openShowChat = ref(true);
let istranslate = ref(false); //是否是翻译本页
let phoneXYinfo = ref(getphoneXYinfo() == null ? [{}, {}, {}, {}, {}, {}, {}, {}] : getphoneXYinfo());
// 当前悬浮的按钮索引
const hoverIndex = ref(null)
const isMonitorOn = ref(false) // false 表示关闭true 表示开启
// 这四个互斥模式的 key和你的 runType 对应
const EXCLUSIVE_KEYS = ['brushLive', 'like', 'follow', 'listen'];
@@ -168,7 +166,10 @@ const KEY_LABEL = {
follow: '一键关注并打招呼',
listen: '监测消息',
};
const showHostDlg = ref(false)
function onHostSaved(list) {
console.log('保存后的 HostList:', list)
}
// 当前激活的互斥 keyrunType 里只要是这四个之一就视为锁定)
const activeKey = computed(() => EXCLUSIVE_KEYS.includes(runType.value) ? runType.value : '');
@@ -306,7 +307,10 @@ const buttons = [
},
{
label: '登出',
onClick: () => router.push('/'),
onClick: () => {
td.disposeAll('logout')
router.push('/')
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn9.png', import.meta.url).href,
@@ -315,6 +319,27 @@ const buttons = [
}
]
// 建立 monitor
const {
isPausedByIndex,
pauseMonitorByIndex,
resumeMonitorByIndex,
openMonitor,
closeMonitor,
stopAll,
resumeAndKick,
} = useMonitor({
deviceInformation,
runType,
isStop,
isMonitorOn,
isShowMes,
wsActionsRef: () => wsActions, // wsActions 是你 onopen 里创建的
});
// 放在变量都已声明之后(要能拿到 phone、toBuffer、wslist
const { canvasRef, frameMeta, initCanvas, getCanvasCoordinate, sendPointer } =
useCanvasPointer({
@@ -324,33 +349,36 @@ const { canvasRef, frameMeta, initCanvas, getCanvasCoordinate, sendPointer } =
});
const feedState = Array(8).fill(null).map(() => ({
processing: false,
pending: null, // ArrayBuffer 等最新一段
}));
function pushFrame(index, buf) {
const st = feedState[index];
if (st.processing) {
// 覆盖旧的等待帧,保留最新
st.pending = buf;
return;
}
st.processing = true;
try {
//推送帧到video
instanceList[index].converter.appendRawData(new Uint8Array(buf));
} finally {
st.processing = false;
if (st.pending) {
const next = st.pending;
st.pending = null;
// 用微任务衔接,避免递归栈增长
queueMicrotask(() => pushFrame(index, next));
}
}
}
// —— 放在变量区deviceInformation 已经是 ref([]))——
const pausedDevices = new Set(); // 用 UDID 做键
const wsCache = new Map();
const td = useTeardown({
deviceInformation,
wslist,
instanceList,
videoElement,
canvasRef,
playTimer,
isShowMes,
wsCache,
createTaskQueue,
stopAll, // useMonitor 里的
removeDocListeners: () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
})
const { feedState, pushFrame, resetFeedState, waitForVideoEl, refreshStream } = useStreams({
instanceList,
videoElement,
wslist,
openStr,
VideoConverter, // 传入页面已引入的构造器,避免在 composable 重复引入
})
//````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````
// 初始化 手机显示WebSocket 和视频流
const initVideoStream = async (udid, index) => {
@@ -428,15 +456,18 @@ const initVideoStream = async (udid, index) => {
//如果检测到有新消息会收到两条ws回复一条message==1 一条message==成功
} else if (resData.message == 1) {
console.log('有消息')
pauseMonitorByIndex(index); // 新增:暂停该设备的轮询
} else if (resData.message == '点击成功') {
console.log('双击', resData.x, resData.y, index)
console.log('双击', resData.x * iponeCoefficient.value[index].width, resData.y * iponeCoefficient.value[index].height, index)
setTimeout(() => {
clickxy(resData.x * iponeCoefficient.value[index].width, resData.y * iponeCoefficient.value[index].height, index)
clickxy(resData.x * getphoneXYinfo()[index].width, resData.y * getphoneXYinfo()[index].height, index)
setTimeout(() => {
clickxy(resData.x * iponeCoefficient.value[index].width, resData.y * iponeCoefficient.value[index].height, index) //index为9的时候长按
clickxy(resData.x * getphoneXYinfo()[index].width, resData.y * getphoneXYinfo()[index].height, index) //index为9的时候长按
wsActions.clickSysMesage(deviceInformation.value[index].udid, index) //点击消息进入对话框
}, 100)
wsActions.clickSysMesage(deviceInformation.value[index].udid, index) //点击消息进入对话框
}, 1500)
}, 2000)
}
} else if (resData.type == 'clickMesage') {
//点击进入新消息页面以后,获取页面信息
@@ -449,6 +480,15 @@ const initVideoStream = async (udid, index) => {
if (runType.value == 'follow') {
LikesToLikesToLikes(deviceInformation.value[index].udid, index)
}
// 仅监听模式下恢复
if (runType.value === 'listen' || isMonitorOn.value) {
resumeMonitorByIndex(index);
// 轻微延迟后立刻补一次检测
setTimeout(() => {
const udid = deviceInformation.value[index]?.udid;
if (udid) wsActions.getmesNum(udid, index);
}, 1500);
}
}, 1000)
}, 1000)
@@ -517,6 +557,9 @@ const initVideoStream = async (udid, index) => {
// iponeCoefficient.value[index].height = 720 / scaledH;
iponeCoefficient.value[index].width = scaledW / resData.width
iponeCoefficient.value[index].height = scaledH / resData.height
console.log(index)
phoneXYinfo.value[index].width = scaledW / resData.width
phoneXYinfo.value[index].height = scaledH / resData.height
console.log(
`[getSize] raw=${RAW_W}x${RAW_H} -> scaled=${scaledW}x${scaledH} (align↓${ALIGN}) ${iponeCoefficient.value[index].width} ${iponeCoefficient.value[index].height}`
);
@@ -597,9 +640,13 @@ const initVideoStream = async (udid, index) => {
}, 1000);
} else if (resData.type == 'PrivatePushFollow') {
//如果有新消息,回复完私信以后,返回三次,然后继续下一个任务
wsActions.getmesNum(deviceInformation.value[index].udid, index)
// wsActions.getmesNum(deviceInformation.value[index].udid, index)
// LikesToLikesToLikes(deviceInformation.value[index].udid, index)
if (runType.value === 'listen' || isMonitorOn.value) {
resumeAndKick(index); // ← 一步到位:恢复并在 1.5s 后补一次 getmesNum
}
}
}, 1000);
@@ -729,12 +776,17 @@ const initVideoStream = async (udid, index) => {
LikesToLikesToLikes(deviceInformation.value[index].udid, index)
}, 1000)
}, 1000)
} else if (resData.type == 'Privatetex' || resData.type == 'hostVideo' || resData.type == 'search' || resData.type == 'Attention' || resData.type == 'Comment') {
} else if (resData.type == 'PrivatePush' || resData.type == 'Privatetex' || resData.type == 'hostVideo' || resData.type == 'search' || resData.type == 'Attention' || resData.type == 'Comment') {
if (runType.value == 'follow') {
//关注的时候出现无法私信和没有视频的情况 错误重置
resetApp(udid, index)
setTimeout(() => {
wsActions.getmesNum(deviceInformation.value[index].udid, index)
if (isMonitor.value) {
//正常没有消息,发送完私信以后,返回六次,然后继续下一个任务
wsActions.getmesNum(deviceInformation.value[index].udid, index)
} else {
LikesToLikesToLikes(deviceInformation.value[index].udid, index)
}
// LikesToLikesToLikes(deviceInformation.value[index].udid, index)
}, 1000)
}
@@ -903,12 +955,14 @@ onMounted(() => {
text: '初始化中...',
background: 'rgba(0, 0, 0, 0.7)',
})
// reloadPage()
setTimeout(() => {
loading.close()
}, 2000)
//sse接收爬虫发送的消息
connectSSE(`https://datasave.api.yolozs.com/api/sse/connect/${userdata.tenantId}/${userdata.id}`, (data) => {
const es = connectSSE(`https://datasave.api.yolozs.com/api/sse/connect/${userdata.tenantId}/${userdata.id}`, (data) => {
// connectSSE(`http://192.168.1.155:19665/api/sse/connect/${userdata.tenantId}/${userdata.id}`, (data) => {
// 处理服务端推送的数据
console.log('来自服务端:', data)
@@ -933,7 +987,7 @@ onMounted(() => {
type: 'success',
message: '任务开启成功',
})
setHostList(stroageHost.value)
addToHostList(stroageHost.value)
//重启tk
resetTk()
//获取评论
@@ -968,16 +1022,17 @@ onMounted(() => {
})
}
} else {
// stroageHost.value = getHostList()
stroageHost.value = getHostList()
stroageHost.value.push(({ country: data.country, text: data.hostsId, state: false }))
if (runType.value == 'follow') {
setHostList(stroageHost.value)
addToHostList([{ country: data.country, text: data.hostsId, state: false }])
}
}
})
td.setSSE(es)
});
//更新状态
// update(
@@ -1003,6 +1058,7 @@ onUnmounted(() => {
const ObtainDeviceInformation = () => {
// 2. 连接 WebSocket
const ws = new WebSocket("ws://127.0.0.1:8000/?action=multiplex");
td.setMultiplexWS(ws)
ws.binaryType = "arraybuffer";
ws.onopen = () => {
ws.send(eitwo);
@@ -1016,14 +1072,15 @@ const ObtainDeviceInformation = () => {
deviceInformation.value = [];
const filteredList = data.data.list.filter(item => item.state === 'device');
//检测到设备列表时,渲染所有设备
for (const item of filteredList) {
for (let i = 0; i < filteredList.length; i++) {
const item = filteredList[i];
deviceInformation.value.push(item);
await nextTick(); // 等 v-for 渲染出 <video>
initCanvas(item.udid); // 如果它也依赖 DOM同样要在 nextTick 之后
initVideoStream(item.udid, deviceInformation.value.length - 1);
initVideoStream(item.udid, i); // 直接使用循环变量 i
// getSize 建议放到 wslist[index].onopen 里最稳,
// 若保留延时也可以:
setTimeout(() => wsActions?.getSize(item.udid, deviceInformation.value.length - 1), 2000);
setTimeout(() => wsActions?.getSize(item.udid, i), 2000); // 直接使用循环变量 i
}
} else if (data.type == "device") {
if (data.data.device.state === "offline") {
@@ -1234,8 +1291,11 @@ async function drag(udid, index, x1, y1, x2, y2, durationMs = 300, steps = 8) {
// 用 pointer down/up/move 改写后的 clickxy
async function clickxy(x, y, index, type) {
const udid = deviceInformation.value[index]?.udid;
if (!udid) return;
const udid = deviceInformation.value[index].udid;
if (!udid) {
console.error('clickxy: no udid');
return;
};
try {
if (type === 3) {
@@ -1283,16 +1343,9 @@ async function clickxy(x, y, index, type) {
}
}
// 清空喂帧状态(避免旧帧冲突)
function resetFeedState(index) {
const st = feedState[index];
if (!st) return;
st.processing = false;
st.pending = null;
}
const reload = (opts = {}) => {
const { onlySelected = false, hard = false } = opts;
const { onlySelected = false, hard = true } = opts;
const targets = (onlySelected && selectedDevice.value !== 999)
? [selectedDevice.value]
: deviceInformation.value.map((_, i) => i);
@@ -1301,43 +1354,6 @@ const reload = (opts = {}) => {
ElMessage.success(`已刷新${onlySelected ? '当前设备' : '全部设备'}`);
};
/** 重建某台设备的视频解码器,不动 ws、不动 canvas */
function refreshStream(index, hard = false) {
const dev = deviceInformation.value[index];
if (!dev) return;
const udid = dev.udid;
const video = videoElement.value && videoElement.value[udid];
if (!video || !instanceList[index]) return;
// 1) 停止旧的喂帧状态,销毁旧 converter
resetFeedState(index);
try {
const conv = instanceList[index].converter;
if (conv && typeof conv.destroy === 'function') conv.destroy();
} catch (e) { }
instanceList[index].converter = null;
// 2) 可选“硬刷新”:彻底重置 <video>,规避 SourceBuffer 残留
if (hard) {
try { video.pause && video.pause(); } catch (e) { }
try { video.removeAttribute && video.removeAttribute('src'); } catch (e) { }
try { video.load && video.load(); } catch (e) { }
}
// 3) 新建 converter 挂到同一个 <video>
instanceList[index].converter = new VideoConverter(video, 60, 1);
// 4) 让后端立刻推关键帧/重开编码
try { wslist[index] && wslist[index].send(openStr); } catch (e) { }
// 5) 同步尺寸(不影响已有 canvas 坐标换算)
setTimeout(() => {
if (wsActions && typeof wsActions.getSize === 'function') {
wsActions.getSize(udid, index);
}
}, 300);
}
//发送任务前的处理
function sendWsTask(index, data) {
@@ -1450,7 +1466,7 @@ function getVideoStyle(index) {
return {
width: isSelected ? baseWidth * 1.4 + 'px' : baseWidth + 'px',
height: isSelected ? baseHeight * 1.4 + 'px' : baseHeight + 'px',
border: isSelected ? '2px solid blue' : '1px solid blue',
// border: isSelected ? '2px solid blue' : '1px solid blue',
position: isSelected ? 'absolute' : 'relative',
top: isSelected ? '0' : 'unset',
left: isSelected ? '0' : 'unset',
@@ -1463,7 +1479,7 @@ function getVideoStyle(index) {
function stop() {
// actions[index] = [];
cloesMonitor(); //关闭监听
stopAll(); // ← 替代 cloesMonitor + pausedDevices.clear
isStop.value = true; //停止所有任务
isMsgPop.value = false;//关闭爬虫sse任务
@@ -1495,29 +1511,15 @@ function resetTk() {
resetApp(device.udid, index)
})
}
//监听所有手机是否有消息
function openMonitor(type) {
isStop.value = false;
deviceInformation.value.forEach((device, index) => {
wsActions.getmesNum(device.udid, index)
runType.value = 'listen'
})
isShowMes.value = setInterval(() => {
deviceInformation.value.forEach((device, index) => {
wsActions.getmesNum(device.udid, index)
})
}, 10000)
}
//关闭监听
function cloesMonitor() {
isMonitorOn.value = false;//关闭监听
deviceInformation.value.forEach((device, index) => {
runType.value = ''
})
clearInterval(isShowMes.value)
isShowMes.value = ''
isMonitorOn.value = false;
deviceInformation.value.forEach(() => { runType.value = '' });
clearInterval(isShowMes.value);
isShowMes.value = '';
pausedDevices.clear(); // 新增
}
//一键养号
@@ -1591,7 +1593,7 @@ function onDialogConfirm(result, type, index, isMon) {
result.forEach((item, indexA) => {
hostListResult.push({ country: '', text: item, state: false })
})
setHostList(hostListResult)
addToHostList(hostListResult)
//打开评论弹窗
selectedDevice.value = 998;
dialogTitle.value = '评论';
@@ -1631,16 +1633,7 @@ function manualGc() {
window.electronAPI.manualGc()
}
// 等待 video 引用就绪的小工具
async function waitForVideoEl(udid, tries = 20, delay = 16) {
for (let i = 0; i < tries; i++) {
const el = videoElement.value?.[udid];
if (el) return el;
await nextTick(); // 等下一次 DOM 刷新
await new Promise(r => setTimeout(r, delay)); // 再小等一帧
}
return null;
}
</script>
<style scoped lang="less">