分包+主播库
This commit is contained in:
381
src/components/HostListManagerDialog.vue
Normal file
381
src/components/HostListManagerDialog.vue
Normal 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>
|
||||
85
src/components/LeftToolbar.vue
Normal file
85
src/components/LeftToolbar.vue
Normal 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>
|
||||
121
src/composables/useMonitor.js
Normal file
121
src/composables/useMonitor.js
Normal 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,
|
||||
}
|
||||
}
|
||||
82
src/composables/useStreams.js
Normal file
82
src/composables/useStreams.js
Normal 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,
|
||||
}
|
||||
}
|
||||
108
src/composables/useTeardown.js
Normal file
108
src/composables/useTeardown.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ const mouseData = {
|
||||
export function createWsActions(wslist) {
|
||||
// 通用 ws 发送方法
|
||||
function send(index, payload) {
|
||||
console.log("发送任务", payload.type)
|
||||
if (wslist[index]) {
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
// 当前激活的互斥 key(runType 里只要是这四个之一就视为锁定)
|
||||
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">
|
||||
|
||||
BIN
tk-ai-adb.zip
BIN
tk-ai-adb.zip
Binary file not shown.
BIN
tkAiPage.zip
BIN
tkAiPage.zip
Binary file not shown.
Reference in New Issue
Block a user