分包+主播库

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

@@ -0,0 +1,381 @@
<template>
<el-dialog v-model="show" width="70vw" :close-on-click-modal="false" :destroy-on-close="true" @open="onOpen">
<template #header>
<div class="dlg-title">
<span>主播管理</span>
<span class="muted">已选 {{ selectedCount }} / {{ hosts.length }}</span>
</div>
</template>
<!-- 工具栏 -->
<div class="toolbar">
<el-button size="small" @click="selectAll">全选</el-button>
<el-button size="small" @click="selectNone">全不选</el-button>
<el-button size="small" @click="invertSelect">反选</el-button>
<el-button size="small" type="danger" :disabled="!selectedCount" @click="deleteSelected">删除选中</el-button>
<!-- 新增一键删除已处理 -->
<el-button size="small" type="warning" :disabled="!processedCount" @click="deleteProcessed">
删除已处理
</el-button>
<el-button size="small" type="primary" @click="saveAll">保存</el-button>
<el-tooltip placement="bottom" effect="dark">
<template #content>
在空白区域按下左键拖拽进行框选<br />
</template>
<el-icon class="hint">i</el-icon>
</el-tooltip>
</div>
<!-- 列表区域 -->
<div ref="gridRef" class="grid" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp"
@mouseleave="onMouseUp" @scroll="recalcRectsSoon">
<div v-for="it in hosts" :key="it.text" class="item-card" :class="{ selected: isSelected(it.text) }"
:ref="el => setCardRef(it.text, el)" @click.stop="toggleSelect(it.text)">
<div class="row top">
<span class="id" :title="it.text">{{ it.text }}</span>
<button class="x" title="删除此项" @click.stop="deleteOne(it.text)">×</button>
</div>
<div class="row meta">
<span class="country" :title="it.country">{{ it.country || '—' }}</span>
<span class="state" :class="{ done: !!it.state }">{{ it.state ? '已处理' : '未处理' }}</span>
</div>
</div>
<!-- 框选矩形 -->
<div v-if="selecting" class="selection-rect" :style="selectionStyle"></div>
</div>
<template #footer>
<div class="foot">
<el-button @click="show = false">关闭</el-button>
<el-button type="primary" @click="saveAll">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getHostList, setHostList } from '@/stores/storage'
// v-model:visible 接口
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'save'])
const show = computed({
get: () => props.visible,
set: (v) => emit('update:visible', v)
})
// 数据
const hosts = ref([]) // {country, text, state}
const selected = reactive(new Set()) // 选中的 text 集合
// 卡片 DOM 引用与位置缓存
const gridRef = ref(null)
const cardRefs = reactive({}) // text -> el
const rectCache = reactive({}) // text -> DOMRect
let rectRecalcTimer = null
const processedCount = computed(() => hosts.value.filter(it => !!it?.state).length)
function setCardRef(key, el) {
if (el) {
cardRefs[key] = el
} else {
delete cardRefs[key]
delete rectCache[key]
}
}
function getStoredHostList() {
const v = getHostList()
return Array.isArray(v) ? v : []
}
async function onOpen() {
hosts.value = getStoredHostList()
selected.clear()
await nextTick()
recalcRects()
}
function saveAll() {
setHostList(hosts.value)
ElMessage.success('已保存')
emit('save', hosts.value)
}
// 选择相关
const selectedCount = computed(() => selected.size)
function isSelected(id) { return selected.has(id) }
function toggleSelect(id) {
if (selected.has(id)) selected.delete(id)
else selected.add(id)
}
function selectAll() { selected.clear(); hosts.value.forEach(it => selected.add(it.text)) }
function selectNone() { selected.clear() }
function invertSelect() {
const next = new Set()
hosts.value.forEach(it => { if (!selected.has(it.text)) next.add(it.text) })
selected.clear(); next.forEach(id => selected.add(id))
}
function deleteSelected() {
if (!selected.size) return
ElMessageBox.confirm(`确认删除选中的 ${selected.size} 项吗?`, '提示', { type: 'warning' })
.then(() => {
const keep = []
for (const it of hosts.value) if (!selected.has(it.text)) keep.push(it)
hosts.value = keep
selected.clear()
setHostList(keep)
recalcRectsSoon()
ElMessage.success('已删除选中')
})
.catch(() => { })
}
function deleteOne(id) {
const idx = hosts.value.findIndex(it => it.text === id)
if (idx !== -1) {
hosts.value.splice(idx, 1)
selected.delete(id)
// setHostList(hosts.value)
recalcRectsSoon()
}
}
// —— 框选逻辑 ——
const selecting = ref(false)
const anchor = ref({ x: 0, y: 0 })
const cursor = ref({ x: 0, y: 0 })
const baseSelection = ref(new Set()) // 框选开始时的已有选择(支持累加)
const selectionStyle = computed(() => {
const root = gridRef.value
if (!root) return {}
// 使用容器坐标系定位矩形
const box = root.getBoundingClientRect()
const x1 = Math.min(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
const y1 = Math.min(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
const x2 = Math.max(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
const y2 = Math.max(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
return { left: x1 + 'px', top: y1 + 'px', width: (x2 - x1) + 'px', height: (y2 - y1) + 'px' }
})
function onMouseDown(e) {
if (e.button !== 0) return
const root = gridRef.value
if (!root) return
// 只在空白处拖拽,或按着 Alt 任意处拖拽
const onItem = e.target && e.target.closest && e.target.closest('.item-card')
if (onItem && !e.altKey) return
// 记录锚点client 坐标,方便和 DOMRect 比较)
anchor.value = { x: e.clientX, y: e.clientY }
cursor.value = { x: e.clientX, y: e.clientY }
selecting.value = true
// 是否保留原选择
baseSelection.value = (e.ctrlKey || e.metaKey) ? new Set(Array.from(selected)) : new Set()
// 防止选中文本
e.preventDefault()
}
function onMouseMove(e) {
if (!selecting.value) return
cursor.value = { x: e.clientX, y: e.clientY }
updateSelectionByRect()
}
function onMouseUp() {
if (!selecting.value) return
selecting.value = false
}
function updateSelectionByRect() {
const x1 = Math.min(anchor.value.x, cursor.value.x)
const y1 = Math.min(anchor.value.y, cursor.value.y)
const x2 = Math.max(anchor.value.x, cursor.value.x)
const y2 = Math.max(anchor.value.y, cursor.value.y)
// 实时选择集合
const current = new Set(baseSelection.value)
for (const it of hosts.value) {
const r = rectCache[it.text]
if (!r) continue
const hit = !(r.left > x2 || r.right < x1 || r.top > y2 || r.bottom < y1)
if (hit) current.add(it.text)
}
// 覆盖 selected
selected.clear(); current.forEach(id => selected.add(id))
}
function recalcRects() {
for (const [key, el] of Object.entries(cardRefs)) {
try { rectCache[key] = el.getBoundingClientRect() } catch { }
}
}
function recalcRectsSoon() {
clearTimeout(rectRecalcTimer)
rectRecalcTimer = setTimeout(() => nextTick().then(recalcRects), 16)
}
function deleteProcessed() {
if (!processedCount.value) return
ElMessageBox.confirm(
`确认删除所有“已处理”项(${processedCount.value} 个)吗?此操作不可撤销。`,
'提示',
{ type: 'warning' }
)
.then(() => {
// 仅保留未处理
const keep = hosts.value.filter(it => !it?.state)
hosts.value = keep
// 清理已不存在的选中项
for (const id of Array.from(selected)) {
if (!keep.find(it => it.text === id)) selected.delete(id)
}
setHostList(keep) // 同步回缓存
recalcRectsSoon() // 重新计算卡片矩形,保证框选正常
ElMessage.success('已删除已处理项')
})
.catch(() => { })
}
watch(hosts, () => nextTick().then(recalcRects))
</script>
<style scoped>
.dlg-title {
display: flex;
gap: 8px;
align-items: baseline;
}
.dlg-title .muted {
color: #909399;
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.toolbar .hint {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid #bbb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #666;
}
.grid {
position: relative;
height: 60vh;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
padding: 6px;
user-select: none;
border: 1px solid var(--el-border-color);
border-radius: 8px;
}
.item-card {
border: 1px solid var(--el-border-color);
border-radius: 10px;
padding: 8px 10px;
background: var(--el-bg-color);
cursor: pointer;
transition: box-shadow .15s, border-color .15s;
}
.item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
}
.item-card.selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
}
.item-card .row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.item-card .row.top {
margin-bottom: 4px;
}
.item-card .id {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 75%;
}
.item-card .x {
border: none;
background: transparent;
color: #f56c6c;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.item-card .x:hover {
color: #f33;
}
.item-card .meta {
color: #666;
font-size: 12px;
}
.item-card .state {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid #ddd;
}
.item-card .state.done {
color: #67c23a;
border-color: #67c23a;
}
.selection-rect {
position: absolute;
pointer-events: none;
border: 1px dashed var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 12%, transparent);
z-index: 10;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="center-line">
<div v-for="(btn, i) in buttons" :key="i" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button"
:class="[{ active: btn.key && activeKey === btn.key, disabled: btn.key && isLocked && activeKey !== btn.key }]"
:style="btn.style ? btn.style() : {}" @click="$emit('click', btn)" @mouseenter="hoverIndex = i"
@mouseleave="hoverIndex = null">
<img :src="hoverIndex === i ? btn.img.hover : btn.img.normal" alt="">
{{ btn.label }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
buttons: { type: Array, required: true },
activeKey: { type: String, default: '' },
isLocked: { type: Boolean, default: false }
})
defineEmits(['click'])
const hoverIndex = ref(null)
</script>
<!-- 注意不要 scoped直接引入全局样式避免父组件 scoped 导致样式穿透问题 -->
<style scoped lang="less">
.center-line {
display: flex;
flex-direction: column;
align-items: center;
// justify-content: center;
}
.left-button {
position: relative;
z-index: 999;
margin-bottom: 19px;
width: 100%;
height: 72px;
// background: #32C9CD;
border-radius: 10px;
font-family: Source Han Sans SC;
font-weight: 800;
font-size: 20px;
color: #7A8B97;
line-height: 16px;
align-items: center;
display: flex;
align-items: center;
img {
width: 30px;
margin: 20px;
}
}
.left-button:hover {
background: #32C9CD;
color: #F9FAFE;
}
/* 鼠标按下时效果 */
.left-button:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translateY(1px);
}
.left-button.active {
background-color: red;
}
/* 互斥期间的其它三枚按钮置灰禁点 */
.left-button.disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(0.4);
}
</style>

View File

@@ -0,0 +1,121 @@
// src/composables/useMonitor.js
import { ref } from 'vue'
/**
* 设备消息监测(含“有消息时暂停,此设备私信发送成功后恢复”)
* - 每 10s 轮询 getmesNum但跳过 paused 的设备
* - 暴露 pause/resume 方法,供外部在 onmessage 分支中调用
* - 提供 openMonitor()/closeMonitor()/stop() 以适配你现有按钮逻辑
*
* @param {object} options
* - deviceInformation: ref([]) 设备列表
* - runType: ref('') 当前运行模式
* - isStop: ref(false) 全局停止标志
* - isMonitorOn: ref(false) 监测开关UI 显示用)
* - isShowMes: ref() 定时器引用(外面已有)
* - wsActionsRef: () => wsActions 一个函数,返回你动态创建的 wsActions 对象
* - onKickOnce?: (udid, index) => void 在恢复后,立刻补一次检测的 hook默认调用 getmesNum
*/
export function useMonitor({
deviceInformation,
runType,
isStop,
isMonitorOn,
isShowMes,
wsActionsRef,
onKickOnce
}) {
// 用 UDID 做键,避免 index 变化错位
const pausedDevices = new Set();
const isPausedByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
return udid ? pausedDevices.has(udid) : false;
};
const pauseMonitorByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
if (udid) pausedDevices.add(udid);
};
const resumeMonitorByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
if (udid) pausedDevices.delete(udid);
};
const clearPaused = () => pausedDevices.clear();
const openMonitor = () => {
isStop.value = false;
// 立即跑一轮
const wsActions = wsActionsRef?.();
deviceInformation.value.forEach((device, index) => {
if (!isPausedByIndex(index)) {
wsActions?.getmesNum(device.udid, index);
}
});
runType.value = 'listen';
isMonitorOn.value = true;
// 每 10s 轮询未暂停设备
isShowMes.value = setInterval(() => {
const ws = wsActionsRef?.();
deviceInformation.value.forEach((device, index) => {
if (!isPausedByIndex(index)) {
ws?.getmesNum(device.udid, index);
}
});
}, 10000);
};
const closeMonitor = () => {
isMonitorOn.value = false;
runType.value = '';
if (isShowMes.value) {
clearInterval(isShowMes.value);
isShowMes.value = '';
}
clearPaused();
};
// 兼容你原来的 stop() 会顺手关监测
const stopAll = () => {
closeMonitor();
clearPaused();
isStop.value = true;
};
// 用于“私信成功后立刻恢复并补一次检测”
const resumeAndKick = (index) => {
resumeMonitorByIndex(index);
setTimeout(() => {
const udid = deviceInformation.value[index]?.udid;
if (!udid) return;
if (typeof onKickOnce === 'function') {
onKickOnce(udid, index);
} else {
const ws = wsActionsRef?.();
ws?.getmesNum(udid, index);
}
}, 1500);
};
return {
// state
pausedDevices,
// helpers
isPausedByIndex,
pauseMonitorByIndex,
resumeMonitorByIndex,
clearPaused,
// controls
openMonitor,
closeMonitor,
stopAll,
resumeAndKick,
}
}

View File

@@ -0,0 +1,82 @@
// composables/useStreams.js
import { nextTick } from 'vue'
export function useStreams({ instanceList, videoElement, wslist, openStr, VideoConverter }) {
// 用 Map 做延迟队列,避免固定 8 个的限制
const feedState = new Map()
function ensureState(index) {
if (!feedState.has(index)) {
feedState.set(index, { processing: false, pending: null })
}
return feedState.get(index)
}
function pushFrame(index, buf) {
const st = ensureState(index)
if (st.processing) {
// 覆盖旧的等待帧,保留最新
st.pending = buf
return
}
st.processing = true
try {
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))
}
}
}
function resetFeedState(index) {
const st = feedState.get(index)
if (!st) return
st.processing = false
st.pending = null
}
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()
await new Promise(r => setTimeout(r, delay))
}
return null
}
function refreshStream(index, hard = true) {
const devices = Object.keys(videoElement.value || {})
const udid = devices[index]
const video = videoElement.value?.[udid]
if (!video || !instanceList[index]) return
resetFeedState(index)
try { instanceList[index].converter?.destroy?.() } catch { }
instanceList[index].converter = null
if (hard) {
try { video.pause?.() } catch { }
try { video.removeAttribute?.('src') } catch { }
try { video.load?.() } catch { }
}
// 重新挂新的 converter
instanceList[index].converter = new VideoConverter(video, 60, 1)
// 让后端尽快推关键帧
try { wslist[index]?.send?.(openStr) } catch { }
}
return {
feedState,
pushFrame,
resetFeedState,
waitForVideoEl,
refreshStream,
}
}

View File

@@ -0,0 +1,108 @@
// composables/useTeardown.js
export function useTeardown(deps) {
let sseRef = null;
let multiplexWS = null;
function setSSE(es) {
try { sseRef?.close?.(); } catch { }
sseRef = es;
}
function setMultiplexWS(ws) {
try { multiplexWS?.close?.(); } catch { }
multiplexWS = ws;
}
function disposeDevice(index) {
try {
const di = deps.deviceInformation.value[index];
const udid = di?.udid;
// 停止定时器
if (deps.playTimer.value[index]) {
clearTimeout(deps.playTimer.value[index]);
deps.playTimer.value[index] = null;
}
const inst = deps.instanceList[index];
if (inst?.timer) {
clearInterval(inst.timer);
inst.timer = null;
}
// 关闭 WS
const ws = deps.wslist[index];
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
try { ws.close(); } catch { }
deps.wslist[index] = undefined;
}
if (udid) deps.wsCache.delete(udid);
// 销毁解码器
try {
inst?.converter?.destroy?.();
} catch { }
if (deps.instanceList[index]) deps.instanceList[index].converter = undefined;
// 释放 <video>
if (udid) {
const video = deps.videoElement.value?.[udid];
if (video) {
try { video.pause?.(); } catch { }
try { video.removeAttribute?.('src'); } catch { }
try { video.load?.(); } catch { }
delete deps.videoElement.value[udid];
}
if (deps.canvasRef.value?.[udid]) {
delete deps.canvasRef.value[udid];
}
}
// 清空任务队列
try { deps.createTaskQueue(index).clear(); } catch { }
} catch (e) {
console.error('[disposeDevice] error:', index, e);
}
}
function disposeAll(reason = 'manual') {
console.log('[teardown] disposeAll:', reason);
// 停止全局监控
try { deps.stopAll?.(); } catch { }
// 释放所有设备
const n = deps.deviceInformation.value.length;
for (let i = 0; i < n; i++) disposeDevice(i);
deps.deviceInformation.value = [];
// 关闭 multiplex WS
try { multiplexWS?.close?.(); } catch { }
multiplexWS = null;
// 关闭 SSE
try { sseRef?.close?.(); } catch { }
sseRef = null;
// 页级定时器
if (deps.isShowMes.value) {
clearInterval(deps.isShowMes.value);
deps.isShowMes.value = undefined;
}
// 清空缓存
deps.wsCache.clear?.();
// 全局事件监听
try { deps.removeDocListeners?.(); } catch { }
}
return {
setSSE,
setMultiplexWS,
disposeDevice,
disposeAll,
};
}

View File

@@ -74,7 +74,8 @@ body {
video {
transition: all 0.3s ease-in-out;
border: 13px solid rgba(84, 224, 206, 0.726);
border-radius: 20px;
/* 关键:让 video 不拦截鼠标事件 */
/* 添加动画 */
@@ -106,8 +107,8 @@ video {
.canvas {
position: absolute;
top: 0;
left: 0;
top: 13px;
left: 13px;
z-index: 9;
/* transform: scale(0.27); */
/* 缩小到原始尺寸的50% */
@@ -168,41 +169,7 @@ video {
transform: translateY(1px);
}
.left-button {
position: relative;
z-index: 999;
margin-bottom: 19px;
width: 100%;
height: 72px;
// background: #32C9CD;
border-radius: 10px;
font-family: Source Han Sans SC;
font-weight: 800;
font-size: 20px;
color: #7A8B97;
line-height: 16px;
align-items: center;
display: flex;
align-items: center;
img {
width: 30px;
margin: 20px;
}
}
.left-button:hover {
background: #32C9CD;
color: #F9FAFE;
}
/* 鼠标按下时效果 */
.left-button:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translateY(1px);
}
.center-justify {
display: flex;
@@ -215,16 +182,4 @@ video {
flex-direction: column;
align-items: center;
// justify-content: center;
}
.left-button.active {
background-color: red;
}
/* 互斥期间的其它三枚按钮置灰禁点 */
.left-button.disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(0.4);
}

View File

@@ -61,9 +61,10 @@ export function clearHostList() {
export function addToHostList(newItem) {
// 获取当前的数组
const currentList = JSON.parse(localStorage.getItem('hostList') || '[]');
newItem.forEach(element => {
currentList.push(element);
});
// 向数组添加新元素
currentList.push(newItem);
// 更新存储的数组
localStorage.setItem('hostList', JSON.stringify(currentList));
@@ -98,4 +99,8 @@ export function setsessionId(data) {
// 用于获取评论信息
export function getsessionId() {
return JSON.parse(sessionStorage.getItem('sessionList'));
}
}

View File

@@ -56,7 +56,7 @@ axios.interceptors.request.use((config) => {
// 响应拦截器
axios.interceptors.response.use((response) => {
// console.log("response", response.data)
console.log("response", response.data)
if (response.data.code == 0 || response.data.code == 200) {
// console.log("response", response.data.data)
return response.data.data

View File

@@ -16,6 +16,7 @@ const mouseData = {
export function createWsActions(wslist) {
// 通用 ws 发送方法
function send(index, payload) {
console.log("发送任务", payload.type)
if (wslist[index]) {
setTimeout(() => {

View File

@@ -79,7 +79,7 @@ import { getToken, setToken, setUser, setUserPass, getUserPass } from '@/stores/
import { ElLoading, ElMessage } from 'element-plus';
let version = ref('0.0.0');
let version = ref('1.5.4');
onMounted(() => {

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

Binary file not shown.

Binary file not shown.