Files
iosAiPage/src/views/VideoStream.vue
2025-10-31 19:39:57 +08:00

1739 lines
52 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="main">
<el-scrollbar class="left"> <!-- 左边栏 -->
<div style="position: absolute;left: 20px; top: 20px;">
<el-button style="background: linear-gradient(90deg, #60a5fa, #34d399); color: azure; "
@click="showMyInfo = true">人设编辑</el-button>
</div>
<div class="center-line"> <!-- 左边栏按钮 -->
<div v-for="(btn, index) in buttons" :key="index" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button" :style="btn.style ? btn.style() : {}" @click="btn.onClick"
@mouseenter="hoverIndex = index" @mouseleave="hoverIndex = null">
<img :src="hoverIndex === index ? btn.img.hover : btn.img.normal" alt="">
{{ btn.label }}
</div>
</div>
<div style="position: absolute;left: 20px; bottom: 20px;">
<el-button @click="showHostDlg = true">执行主播库</el-button>
<el-button type="info" @click="uploadLogFile">上传日志</el-button>
<!-- 新增SSE 弹窗总开关 -->
<el-switch v-model="sseEnabled" inline-prompt active-text="监听爬虫" inactive-text="监听爬虫"
style="margin-left: 8px;" />
</div>
</div>
</el-scrollbar>
<!-- 中间手机区域 -->
<div class="content" @click.self="selectedDevice = 999">
<div class="video-container" v-for="(device, index) in deviceInformation" :key="device.deviceId">
<div class="video-canvas" :class="{
active: selectedDevice === index,
'net-bad': netStatus[device.deviceId] === false
}" :style="getCanvasStyle(index)" @click="selectDevice(index)">
<img class="stream" :src="imgSrcMap[device.deviceId] || ''" :data-id="device.deviceId"
:ref="el => (imgRefs[device.deviceId] = el)" />
<canvas v-show="selectedDevice === index" class="overlay"
@mousedown.stop="(e) => onCanvasDown(device.deviceId, e, index)"
@mouseup.stop="(e) => onCanvasUp(device.deviceId, e, index)"
@mousemove.stop="(e) => onCanvasMove(device.deviceId, e, index)" />
</div>
<div class="input-info" v-show="selectedDevice == index">
<div class="app-button" @click="restartTikTok({ udid: device.deviceId })">重置tiktok</div>
<div class="app-button" @click="getMesList(device.deviceId)">获取当前聊天记录</div>
<div class="app-button" @click="stopOne(device.deviceId)">停止任务</div>
<div class="app-button" @click="runTask(runType, device.deviceId)">开启</div>
</div>
</div>
</div>
<div class="right center-line" @click.self="selectedDevice = 999">
<!-- <div style="margin: 30px;"></div> -->
<ChatDialog :visible="openShowChat" :messages="chatList" />
<MessageDialogd :visible="openShowChat" :messages="MesNewList" :sound-src="ding" />
</div>
<img v-if="isWifi" style="position: absolute; right: 20px; top: 10px; height: 30px;" src="@/assets/wifi.png"></img>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='initialTextStr' :title="dialogTitle"
:index="selectedDevice" @confirm="onDialogConfirm" @cancel="stopAll(100)" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" @invitType="invitTypeFun" />
<TranslationDialog v-model="showtransDlg" :type="transDlgType" :translateFn="doTranslate"
storage-key-prefix="demo-translation" @confirm="onConfirm" @cancel="stopAll(100)" />
</div>
<!-- <AgentGuildDialog v-model="showMyInfo" :model="formInit" @save="handleSave" /> -->
<AgentGuildDialog v-model="showMyInfo" :model="{
agentName: borkerConfig.agentName,
guildName: borkerConfig.guildName,
contactTool: borkerConfig.contactTool,
contact: borkerConfig.contact
}" @save="onSave" />
<!-- 定时调度配置弹窗 -->
<el-dialog v-model="showScheduleDlg" title="定时调度(每小时)" width="550px" :close-on-click-modal="false" draggable>
<div style="display:grid;grid-template-columns: 100px 1fr; gap:12px; align-items:center;">
<div>片段 A</div>
<div style="display:flex; gap:8px; align-items:center;">
<el-select v-model="schedAKey" style="width:140px;">
<el-option label="一键私信" value="follow" />
<el-option label="刷视频(养号)" value="like" />
<el-option label="刷直播" value="brushLive" />
<el-option label="监测消息" value="listen" />
</el-select>
<el-input-number v-model="schedAMin" :min="1" :max="59" />
<span>分钟</span>
</div>
<div>片段 B</div>
<div style="display:flex; gap:8px; align-items:center;">
<el-select v-model="schedBKey" style="width:140px;">
<!-- <el-option label="一键关注" value="follow" /> -->
<el-option label="刷视频(养号)" value="like" />
<el-option label="刷直播" value="brushLive" />
<el-option label="监测消息" value="listen" />
</el-select>
<el-input-number v-model="schedBMin" :min="1" :max="59" />
<span>分钟</span>
</div>
<div>总时长</div>
<div><b>{{ schedAMin + schedBMin }}</b> 分钟必须等于 60</div>
<div>换号</div>
<div style="display:flex; gap:8px; align-items:center;">
<el-switch v-model="interruptEnabled" active-text="开启换号" />
<el-input-number v-model="interruptEveryMin" :min="1" :max="24" />
<span>小时换一次</span>
</div>
<div>联盟号</div>
<div style="display:flex; gap:8px; align-items:center;">
<el-switch v-model="isAlliance" active-text="联盟号快速私信" />
</div>
</div>
<template #footer>
<el-button @click="showScheduleDlg = false">取消</el-button>
<el-button type="primary" @click="saveSchedule">开启</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, onBeforeUnmount, watch, inject, computed, nextTick } from "vue";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
setsessionId, getsessionId, getContentListMultiline, getContentpriListMultiline
} from '@/stores/storage'
import { connectSSE } from '@/utils/sseUtils'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { chat, translationToChinese, translation, customTranslation } from "@/api/chat";
import HostListManagerDialog from '@/components/HostListManagerDialog.vue'
import AgentGuildDialog from '@/components/AgentGuildDialog.vue'
import MultiLineInputDialog from '@/components/MultiLineInputDialog.vue'; // 根据实际路径修改
import TranslationDialog from '@/components/translationDialog.vue'; // 根据实际路径修改
import ChatDialog from '@/components/ChatDialog.vue'
import MessageDialogd from '@/components/MessageDialogd.vue'
import { pickTikTokBundleId } from '@/utils/arrUtils'
import { logout, updates } from '@/api/account';
import {
getDeviceList,
toHome,
swipeAction,
tapAction,
growAccount,
stopScript,
watchLiveForGrowth,
monitorMessages,
passAnchorData,
followAndGreetUnion,
addTempAnchorData,
deviceAppList,
launchApp,
getChatTextInfo,
setLoginInfo,
aiConfig,
selectLast,
updatelast,
changeAccount,
stopAllTask,
anchorList,
restartTikTok,
getDeviceNetStatus
} from '@/api/ios';
import ding from '@/assets/mes.wav'
import { set } from "lodash";
const router = useRouter();
const openShowChat = ref(true)
//主播库
const showHostDlg = ref(false)
//ai人设弹框
const showMyInfo = ref(false)
//翻译弹框
const showtransDlg = ref(false)
let transDlgType = ref('')
// 假设这是你已有的数据
const borkerConfig = reactive({
agentName: '',
guildName: '',
contactTool: '',
contact: ''
})
//评论 自动化
let common = ref(true);
// 自动化
let auto = ref(true);
let initialTextStr = ref('') // 初始文本字符串
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
let hostList = [] // 主播列表
let comonList = [] //评论列表
//查询列表轮询
let getListtimer = null;
let userdata = getUser();
let chatList = ref([])
let MesNewList = ref([])
// 刷新方法
const reloadImg = () => {
refreshAllImgs()
}
//start弹窗
let isMsgPop = ref(false)
let runType = ref('')
let isMonitorOn = ref(false)
const hoverIndex = ref(null) //选中
let showDialog = ref(false);//弹窗是否显示
let dialogTitle = ref('');//当前弹窗类型
let deviceInformation = ref([])
// 你可以用这种方式声明按钮们
//停止中
let stopLoading = null
// 每台设备的网络状态true=正常false=异常
const netStatus = reactive({}) // { [deviceId]: boolean }
// —— 网络波动联动(每设备暂停/恢复)——
const offlineFlags = reactive({}) // { [deviceId]: true/false } 是否因断网而暂停过
const lastNet = reactive({}) // { [deviceId]: true/false } 上一次看到的网络状态
const resumeTimers = new Map() // deviceId -> setTimeout id
const NET_RESUME_STABLE_MS = 6000 // 断网恢复后,等待网络稳定的毫秒数
function clearResumeTimer(id) {
const t = resumeTimers.get(id)
if (t) {
clearTimeout(t)
resumeTimers.delete(id)
}
}
// 当前是否被其它模式占用(四个互斥按钮专用)
const isLocked = (type) => !!runType.value && runType.value !== type
// —— SSE 弹窗/接收总开关(持久化)——
const sseEnabled = ref(JSON.parse(localStorage.getItem('SSE_ENABLED') ?? 'true'))
watch(sseEnabled, v => {
localStorage.setItem('SSE_ENABLED', JSON.stringify(v))
if (!v) dropCurrentWave() // 关掉总开关时立刻丢弃本波缓冲并取消待flush
})
// 互斥按钮的样式:激活=红,锁定=半透明且禁点
const ctrlStyle = (type) => ({
backgroundColor: runType.value === type ? 'red' : '',
opacity: isLocked(type) ? 0.5 : 1,
pointerEvents: isLocked(type) ? 'none' : 'auto',
cursor: isLocked(type) ? 'not-allowed' : 'pointer',
})
const buttons = [
{
label: '刷新',
onClick: () => reloadImg(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn1-1.png', import.meta.url).href
}
},
{
label: '打开tiktok',
onClick: () => openTk(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn2.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn2-2.png', import.meta.url).href
}
},
{
label: '返回主页',
onClick: () => {
deviceInformation.value.forEach((item) => {
toHome({ udid: item.deviceId })
})
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn3.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn3-3.png', import.meta.url).href
}
},
{
label: '刷直播',
onClick: () => {
if (runType.value == 'brushLive') {
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId })
})
runType.value = ''
return
};
// 若被其它模式占用:直接返回(已在样式层禁点,这里双保险)
if (isLocked('brushLive')) return
runType.value = 'brushLive'
deviceInformation.value.forEach((item) => watchLiveForGrowth({ udid: item.deviceId }))
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn4.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn4-4.png', import.meta.url).href
},
style: () => ctrlStyle('brushLive')
},
{
label: '刷视频',
onClick: () => {
if (runType.value == 'like') {
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId })
})
runType.value = ''
return
};
if (isLocked('like')) return
// runType.value = 'like'
// deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId }))
dialogTitle.value = '视频评论';
setTimeout(() => {
showDialog.value = true;
initialTextStr.value = getContentListMultiline();
}, 500)
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn5.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn5-5.png', import.meta.url).href
},
style: () => ctrlStyle('like')
},
{
label: '启动调度任务',
onClick: () => openScheduleDialog(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn6.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn6-6.png', import.meta.url).href
},
style: () => ctrlStyle('follow')
},
{
label: '监测消息',
onClick: () => {
if (runType.value == 'listen') {
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId })
})
runType.value = ''
return
};
if (isLocked('listen')) return
//如果传评论就注释一下两行代码,解开后面代码
runType.value = 'listen'
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId }))
// dialogTitle.value = '评论(无消息将刷视频)';
// setTimeout(() => {
// showDialog.value = true;
// initialTextStr.value = getContentListMultiline();
// }, 500)
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn1-1.png', import.meta.url).href
},
style: () => ctrlStyle('listen')
},
// {
// label: '定时调度',
// onClick: () => openScheduleDialog(),
// show: () => true,
// img: {
// normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
// hover: new URL('@/assets/video/leftBtn1-1.png', import.meta.url).href
// }
// },
{
label: '全部停止',
onClick: () => stopAll(100),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn8.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn8-8.png', import.meta.url).href
},
},
{
label: '登出',
onClick: () => doLogout(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn9.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn9-9.png', import.meta.url).href
}
}
]
const isAlliance = ref(false)
// —— 打断器配置 ——
// 是否开启
const interruptEnabled = ref(false)
// 每隔多少分钟打断一次(可做弹窗配置)
const interruptEveryMin = ref(2)
// 打断器最大重试次数 & 每次超时(按需调)
const interruptMaxRetries = 1
const interruptCallTimeoutMs = 120_000
// 运行态
let interrupting = false
let lastInterruptTs = Number(localStorage.getItem('INT_LAST_TS') || '0')
// 暂存“被打断时”的片段状态,用于恢复
let pauseSnapshot = null
// 结构:{ index, elapsedBeforePause }
const schedulePlan = [
{ key: 'follow', duration: 40 * 60 * 1000 },
{ key: 'like', duration: 20 * 60 * 1000 },
]
// 调度状态(持久化一下,避免刷新丢失)
let scheduleState = (() => {
try {
const saved = JSON.parse(localStorage.getItem('SCHEDULE_STATE') || '{}')
if (saved && typeof saved.index === 'number' && typeof saved.startTime === 'number') {
return saved
}
} catch { }
return { index: 0, startTime: Date.now() }
})()
let scheduleTimer = null // 轮询定时器句柄
const scheduleTickMs = 30_000 // 每 30s 检查一次是否到切换点
let scheduleEnabled = ref(false) // 需要时可手动关闭调度(例如“全部停止”)
// 弹窗
const showScheduleDlg = ref(false)
// 两个时间片(默认 A=follow 40minB=like 20min
const schedAKey = ref('follow')
const schedAMin = ref(40)
const schedBKey = ref('like')
const schedBMin = ref(20)
// 打开弹窗:把当前 schedulePlan 映射到 UI
function openScheduleDialog() {
// 把当前计划读出来(只支持两个片段的简易版)
if (Array.isArray(schedulePlan) && schedulePlan.length >= 2) {
const a = schedulePlan[0], b = schedulePlan[1]
schedAKey.value = a?.key || 'follow'
schedAMin.value = Math.max(1, Math.round((a?.duration || 40 * 60_000) / 60_000))
schedBKey.value = b?.key || 'like'
schedBMin.value = Math.max(1, Math.round((b?.duration || 20 * 60_000) / 60_000))
}
showScheduleDlg.value = true
}
// 保存:校验=60 分钟 → 更新 schedulePlan → 持久化 → 重启轮询
function saveSchedule() {
const total = schedAMin.value + schedBMin.value
if (total !== 60) {
ElMessage.error('两个片段相加必须等于 60 分钟')
return
}
schedulePlan.splice(0, schedulePlan.length,
{ key: schedAKey.value, duration: schedAMin.value * 60_000 },
{ key: schedBKey.value, duration: schedBMin.value * 60_000 },
)
// 存 localStorage
localStorage.setItem('SCHEDULE_PLAN', JSON.stringify(schedulePlan))
localStorage.setItem('SCHEDULE_ENABLED', JSON.stringify(!!scheduleEnabled.value))
// 重置时间片起点并立即生效
scheduleState.index = 0
scheduleState.startTime = Date.now()
localStorage.setItem('SCHEDULE_STATE', JSON.stringify(scheduleState))
// 若启用则重启轮询
startScheduleLoop()
showScheduleDlg.value = false
ElMessage.success('已保存定时调度')
}
const selectedDevice = ref(null)
// 每台设备的 <img> 引用
const imgRefs = ref({}) // { [id]: HTMLImageElement }
// 新增:每台设备当前展示的 URL
const imgSrcMap = reactive({}) // { [deviceId]: string }
// 新增每台设备循环状态timer/abort 等)
const loops = new Map(); // deviceId -> { timer, stopped, lastUrl }
/** 生成一次地址 */
const makeUrl = (port) => `http://localhost:${port}/?t=${Date.now()}`;
/** 真正断开某台设备当前连接并清理 */
function hardCloseImg(deviceId) {
const el = imgRefs.value[deviceId];
if (el) {
el.src = '';
el.removeAttribute('src');
}
// 如果你用的是 blob/objectURL这里应该 revokeObjectURL我们当前直接 URL不需要。
imgSrcMap[deviceId] = '';
}
/** 只启动某台设备的 3 秒循环(第 idx 台错峰 idx*200ms */
function startLoop(dev, idx = 0) {
stopLoop(dev.deviceId); // 防止重复开
const state = { timer: null, stopped: false, lastUrl: '' };
loops.set(dev.deviceId, state);
const tick = () => {
if (state.stopped) return;
// 1) 先把老连接硬断开
// hardCloseImg(dev.deviceId);
// 2) 立刻换新地址
const url = makeUrl(dev.screenPort);
imgSrcMap[dev.deviceId] = url;
// 3) 3 秒后再来一轮(串行,不并发)
state.timer = window.setTimeout(tick, 3000);
};
// 错峰启动,避免 N 台同时一口气重连
state.timer = window.setTimeout(tick, idx * 200);
}
/** 停止某台设备的循环并断开连接 */
function stopLoop(deviceId) {
const s = loops.get(deviceId);
if (!s) {
hardCloseImg(deviceId); // 也确保断一次
return;
}
s.stopped = true;
if (s.timer) clearTimeout(s.timer);
loops.delete(deviceId);
hardCloseImg(deviceId);
}
// —— 设备列表变化时,同步循环 ——
// 注意:你的 deviceInformation 每 3 秒会被重新赋值。
// 这里不每次都重启,而是只做“增删对齐”,避免不必要中断。
watch(deviceInformation, (list) => {
reconcileLoopsByDevices(list || []);
}, { deep: true });
/** 批量控制:根据 deviceInformation 启停循环(新增启动,移除停止) */
function reconcileLoopsByDevices(list) {
const keep = new Set(list.map(d => d.deviceId));
// 停掉已不存在的设备
for (const id of Array.from(loops.keys())) {
if (!keep.has(id)) stopLoop(id);
}
// 为新增设备启动循环(带错峰)
list.forEach((d, i) => {
if (!loops.has(d.deviceId)) startLoop(d, i);
});
}// 强制重建 <img>
// 如果你还有“手动刷新”按钮,改成只刷新当前/所有设备的一轮
function refreshOneImg(deviceId) {
// 立即强制进入下一轮:先停后启
const dev = deviceInformation.value.find(d => d.deviceId === deviceId);
if (dev) {
stopLoop(deviceId);
startLoop(dev, 0);
}
}
function refreshAllImgs() {
deviceInformation.value.forEach((d, i) => {
stopLoop(d.deviceId);
startLoop(d, i);
});
}
// —— 关键:登出/离开时“批量硬中断”所有图片流 ——
async function hardStopAllImgStreams(id, port) {
const el = imgRefs.value[id]
console.log("终止", id)
if (el) {
// 硬中断旧请求
el.src = ''
el.removeAttribute('src')
}
}
function refreshAllStopImgs() {
Object.keys(imgRefs.value).forEach(id => hardStopAllImgStreams(id))
}
// —— 显示尺寸固定为 320x720未选中缩略为 THUMB_SCALE 倍 ——
// 尺寸与排布
const BASE_W = 320
const BASE_H = 720
const THUMB_SCALE = 0.6
const PER_ROW = 3
// 底行上移的位移量720*(1-0.6)=288
const BOTTOM_SHIFT = Math.round(BASE_H * (1 - THUMB_SCALE)) // 288
// 是否至少有两行
const hasTwoRows = computed(() => deviceInformation.value.length > PER_ROW)
// 真正的“底行”判定:必须有两行以上才成立
const isBottomRow = (index) => {
if (!hasTwoRows.value) return false
const lastRow = Math.floor((deviceInformation.value.length - 1) / PER_ROW)
return Math.floor(index / PER_ROW) === lastRow
}
// 统一给 .video-canvas 返回 transform缩略/放大/底行上移)
function getCanvasStyle(index) {
const isSelected = selectedDevice.value === index
if (!isSelected) {
return { transform: `scale(${THUMB_SCALE})` }
}
// 选中:默认正常放大;若在底行且至少两行 -> 先上移再放大
return isBottomRow(index)
? { transform: `translateY(-${BOTTOM_SHIFT}px) scale(1)` }
: { transform: 'scale(1)' }
}
// 当前选中的卡片:选中=1倍未选中=缩略比例
const imgWH = (index) => {
const scale = (selectedDevice.value === index) ? 1 : THUMB_SCALE
return {
width: `${BASE_W * scale}px`,
height: `${BASE_H * scale}px`,
transition: 'all 0.3s ease',
}
}
// 计算某索引当前展示宽高(用于坐标换算)
const displaySize = (index) => {
const scale = (selectedDevice.value === index) ? 1 : THUMB_SCALE
return { w: BASE_W * scale, h: BASE_H * scale }
}
// 从 Canvas offset 坐标 → 真实手机分辨率坐标
const mapToDeviceXY = (index, offsetX, offsetY) => {
const dev = deviceInformation.value[index] || {}
const realW = Number(dev.width) || BASE_W // 后端返回的真实分辨率
const realH = Number(dev.height) || BASE_H
const rotation = Number(dev.rotation || 0) // 若后端有提供旋转角,可用 0/90/180/270
const { w: dispW, h: dispH } = displaySize(index)
// 归一化到 0~1
let nx = Math.min(Math.max(offsetX / dispW, 0), 1)
let ny = Math.min(Math.max(offsetY / dispH, 0), 1)
// 处理旋转(如果你的服务器坐标基于设备原生朝向)
// 0: 直接映射90: 顺时针180、270 同理
let x, y
switch (rotation % 360) {
case 90:
case -270:
x = Math.round(ny * realW)
y = Math.round((1 - nx) * realH)
break
case 180:
case -180:
x = Math.round((1 - nx) * realW)
y = Math.round((1 - ny) * realH)
break
case 270:
case -90:
x = Math.round((1 - ny) * realW)
y = Math.round(nx * realH)
break
default: // 0°
x = Math.round(nx * realW)
y = Math.round(ny * realH)
}
return { x, y }
}
// 选中:恢复到 320x720 并显示盖层
const selectDevice = (index) => {
selectedDevice.value = index
}
// ——— 鼠标交互:按下/移动/抬起 ———
const dragState = ref({}) // 以 index 作为 key 保存 {ox, oy, t}
const onCanvasDown = (udid, e, index) => {
// 记录起点Canvas 内 offset和时间
const startDev = mapToDeviceXY(index, e.offsetX, e.offsetY) // 也记录“设备坐标”起点,便于直接打印
dragState.value[index] = {
ox: e.offsetX,
oy: e.offsetY,
t: Date.now(),
udid,
startDevXY: startDev,
startOffsetXY: { x: e.offsetX, y: e.offsetY }
}
}
const onCanvasMove = (udid, e, index) => {
// 若要实时观察滑动轨迹(可选)
const st = dragState.value[index]
if (!st) return
const curDev = mapToDeviceXY(index, e.offsetX, e.offsetY)
// 建议:调试阶段打开,稳定后可注释或做节流
// console.log('[MOVE]', {
// udid,
// offsetXY: { x: e.offsetX, y: e.offsetY },
// deviceXY: curDev,
// elapsedMs: Date.now() - st.t
// })
}
const onCanvasUp = async (udid, e, index) => {
const st = dragState.value[index]
if (!st) return
const { ox, oy, t, startDevXY, startOffsetXY } = st
const dx = e.offsetX - ox
const dy = e.offsetY - oy
const elapsed = Date.now() - t
delete dragState.value[index]
// 终点(设备坐标 & 画布 offset
const endDevXY = mapToDeviceXY(index, e.offsetX, e.offsetY)
const endOffsetXY = { x: e.offsetX, y: e.offsetY }
// ✅ 这里打印:起点/终点(两套坐标)+ 耗时
console.log('[鼠标滑动,起点/终点)+ 耗时]', {
udid,
start: {
offsetXY: startOffsetXY, // 画布内起点
deviceXY: startDevXY // 设备坐标起点
},
end: {
offsetXY: endOffsetXY, // 画布内终点
deviceXY: endDevXY // 设备坐标终点
},
deltaOffset: { dx, dy },
durationMs: elapsed,
})
// === 你原有逻辑tap or swipe ===
const MOVE_THR = 5
const isTap = Math.hypot(dx, dy) < MOVE_THR && elapsed < 500
try {
if (isTap) {
await tapAction({ udid, x: endDevXY.x, y: endDevXY.y })
} else {
//通过自定义滑动坐标和时间传参
await swipeAction({ udid, sx: startDevXY.x, sy: startDevXY.y, ex: endDevXY.x, ey: endDevXY.y, duration: elapsed / 1000 })
}
} catch (err) {
console.error(err)
}
}
/** 方向码1=上, 2=左, 3=下, 4=右不返回0始终给出一个方向 */
function getSwipeCode(dx, dy) {
// 哪个轴位移更大就取哪个轴边界≈45°
if (Math.abs(dx) >= Math.abs(dy)) {
return dx < 0 ? 2 : 4 // 左/右
} else {
return dy < 0 ? 1 : 3 // 上/下DOM坐标里向上是负
}
}
/** 带设备旋转(0/90/180/270):先把画布向量(dx,dy)旋回设备坐标系再判方向 */
function getSwipeCodeWithRotation(dx, dy, rotation = 0) {
let dxD = dx, dyD = dy
switch ((rotation % 360 + 360) % 360) {
case 90: dxD = dy; dyD = -dx; break
case 180: dxD = -dx; dyD = -dy; break
case 270: dxD = -dy; dyD = dx; break
default: break
}
return getSwipeCode(dxD, dyD)
}
async function openTk() {
if (!deviceInformation.value?.length) {
ElMessage.warning('暂无在线设备')
return
}
const loading = ElLoading.service({ text: '正在打开 TikTok …', background: 'rgba(0,0,0,.35)' })
const results = []
try {
// 为了稳妥,逐台串行(如果你希望更快,可改 Promise.all 并注意并发数)
for (const dev of deviceInformation.value) {
const udid = dev.deviceId
try {
const apps = await deviceAppList({ udid }) // 期望返回示例中的数组
const bundleId = pickTikTokBundleId(apps)
if (!bundleId) {
results.push({ udid, ok: false, msg: '未找到 TikTok' })
continue
}
await launchApp({ udid, bundleId })
results.push({ udid, ok: true, msg: `已启动 TikTok (${bundleId})` })
} catch (e) {
console.error('openTk error', udid, e)
results.push({ udid, ok: false, msg: '请求失败' })
}
}
} finally {
loading.close()
}
// 汇总提示(成功/失败各一条)
const okCount = results.filter(r => r.ok).length
const fail = results.filter(r => !r.ok)
if (okCount) ElMessage.success(`已在 ${okCount} 台设备启动 TikTok`)
if (fail.length) {
const udids = fail.map(f => f.udid).join(', ')
ElMessage.error(`以下设备未能启动:${udids}`)
}
}
function getMesList(deviceId) {
getChatTextInfo({ udid: deviceId }).then((res) => {
if (res) {
chatList.value = res
console.log(chatList.value)
getTranslation(chatList.value)
}
})
}
async function stopAll(time) {
stopLoading = ElLoading.service({
lock: true,
text: '停止中',
background: 'rgba(0, 0, 0, 0.7)',
});
scheduleEnabled.value = false;
runType.value = '';
isMsgPop.value = false;
dropCurrentWave();
try {
// 1) 等待接口完成
await stopAllTask(deviceInformation.value.map(item => item.deviceId));
// 2) 等待 2 秒(和你原逻辑一致)
await new Promise(r => setTimeout(r, time));
stopLoading.close();
console.log('全部停止成功', printCurrentTime());
ElMessage.success('全部停止成功');
// 3) 明确返回(可选)
return true;
} catch (e) {
console.log('停止失败', printCurrentTime(), e);
ElMessage.error('脚本已停止');
stopLoading.close();
return false;
}
}
//确认多行文本框内容
function onDialogConfirm(result, type, index, data) {
console.log(type, result, data);
if (type == '主播ID') {
hostList = (result || []).map(id => ({ id, country: '' }))
//无需评论,注释,如果需要注释下面代码,放开后面代码
// dialogTitle.value = '私信';
// setTimeout(() => {
// showDialog.value = true;
// initialTextStr.value = getContentpriListMultiline();
// }, 500)
dialogTitle.value = '评论';
setTimeout(() => {
showDialog.value = true;
initialTextStr.value = getContentListMultiline();
}, 500)
} else if (type == '评论') {
comonList = result
setContentList(result)
common.value = data.common
transDlgType.value = '私信'
showtransDlg.value = true
} else if (type == '视频评论') {
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId, comment: result, isComment: data.common }))
} else if (type == '评论(无消息将刷视频)') {
runType.value = 'listen'
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId, comment: result }))
}
}
onMounted(async () => {
const loading = ElLoading.service({
lock: true,
text: '检测设备中...',
background: 'rgba(0, 0, 0, 0.7)',
});
getDeviceListFun()
const res = await window.electronAPI.isiproxy({
intervalMs: 2000,
exeName: 'iproxy.exe',
// maxWaitMs: 3000, // 可选5分钟超时
maxWaitMs: 300000, // 可选5分钟超时
});
if (res.running) {
// 检测到了,你再决定是否跳转
loading.close();
console.log('检测到了')
} else {
// 超时兜底提示
loading.close();
console.log('未检测到设备')
ElMessage.error(`未检测到设备`)
}
window.electronAPI.startMq(userdata.tenantId, userdata.id)
// 初始化时获取设备列表
//每3秒获取一次设备列表 消息列表 和 查询主播列表是否还有主播
getListtimer = setInterval(async () => {
getDeviceListFun() //获取设备列表
selectLastFun() //获取手机网络状态
const hostsList = await getStoredHostList()
// console.log(hostsList.length)
//当私信主播时,主播列表没有数据了,提示列表空了 并且关闭私信
if (runType.value == 'follow') {
if (hostsList.length <= 0) {
await stopAll(5000)
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId }))
ElMessageBox.alert('私信全部完成!(刷视频中)', '提示', {
confirmButtonText: 'OK',
callback: (action) => {
},
})
}
}
}, 3000)
setInterval(async () => {
await checkVPN()
await refreshNetStatus()
}, 1000 * 20)
if (!await isAiConfig()) {
showMyInfo.value = true
}
reconcileLoopsByDevices(deviceInformation.value || []);
function scheduleFlush(handler, delay = 400) {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(() => {
if (batch.length) {
const items = batch.slice(); // 拷贝一份
batch.length = 0; // 清空批次
try {
handler(items);
} catch (e) {
console.error('[SSE flush error]', e);
// 出错不回灌,避免重复提交;必要时可根据需要 batch.push(...items)
}
}
}, delay);
}
// —— SSE 接收 ——
const es = connectSSE('http://localhost:3312/events', (data) => {
// console.log('来自服务端:', data);
// console.log(1)
//总开关
if (!sseEnabled.value) return
if (data === 'start') {
// 新一波开始:清空上一波的缓冲,重置防抖
dropCurrentWave()
return
}
// 非 start正常入缓冲 → 防抖批量 addTempAnchorData
const country = data && data.country != null ? data.country : ''
const text = data && (data.hostsId != null ? data.hostsId : data.text)
const invitationType = data && (data.invitationType != null ? data.invitationType : '')
const id = data && data.id != null ? data.id : ''
if (!text) return
batch.push({ country, text, invitationType, id })
scheduleFlush((items) => {
// 批量入库
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType),
}))
updates(items.map(h => ({
id: h.id,
aiOperation: 1,
})))
addTempAnchorData(list)
}, 400)
})
})
onUnmounted(() => {
clearInterval(getListtimer)
getListtimer = null
})
let isStartLac = false
const getDeviceListFun = () => {
getDeviceList().then((res) => {
if (res && res.length > 0 && deviceInformation.value.length !== res.length) {
console.log("设备变更")
deviceInformation.value = res
}
if (res.length == 0) {
deviceInformation.value = []
}
}).catch((err) => {
if (isStartLac) {
ElMessage.error(`IOSAI服务错误`)
isStartLac = true
} else {
}
})
}
//获取新消息
const selectLastFun = () => {
selectLast().then((res) => {
let mesInfoData = res
mesInfoData.forEach(element => {
deviceInformation.value.forEach((item, index) => {
if (item.deviceId == element.device) {
element.device = index + 1 + '号设备'
}
})
});
MesNewList.value = [...mesInfoData];
})
}
async function uploadLogFile() {
let loading = null
try {
// 先弹出确认框
await ElMessageBox.confirm(
'确定要上传日志文件吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
// 如果点了确定,就会走到这里
loading = ElLoading.service({
lock: true,
text: '上传中...',
background: 'rgba(0, 0, 0, 0.7)',
})
const res = await setLoginInfo({
"tenantId": userdata.tenantId,
"userId": userdata.id,
"token": userdata.tokenValue
})
loading.close()
console.log("上传文件返回", res)
if (res) {
console.log("✅ 上传成功:", res)
ElMessage.success('✅ 上传成功')
} else {
console.error("❌ 上传失败:", res.msg)
ElMessage.error('❌ 上传失败: ' + (res.msg || '未知错误'))
}
} catch (err) {
if (loading) {
loading.close()
}
// 如果用户点了取消,会进入这里
if (err === 'cancel' || err === 'close') {
ElMessage.info('已取消上传')
} else {
console.error("❌ 上传异常:", err)
ElMessage.error('❌ 上传异常: ' + err)
}
}
}
function runTask(key, deviceId, type) {
console.log('[schedule] 切换到任务:', key, printCurrentTime())
forceActivate(key, async () => {
if (key === 'follow') {
console.log("进入follow", scheduleEnabled.value)
if (scheduleEnabled.value) {
if (!deviceId) {
await stopAll(5000)
} else {
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: [deviceId],
anchorList: [],
prologueList: getContentpriList(),
needReply: auto.value,
}
).then((res) => {
hostList = []
})
} else {
passAnchorData(
{
deviceList: [deviceId],
anchorList: [],
prologueList: getContentpriList(),
comment: comonList,
needReply: auto.value,
isComment: common.value //是否评论
}
).then((res) => {
hostList = []
})
}
return
}
//第一个小时结束后,第二轮开始的时候,直接进入follow
setTimeout(() => {
runType.value = 'follow'
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: [],
prologueList: getContentpriList(),
needReply: auto.value
}
).then((res) => {
hostList = []
})
} else {
passAnchorData(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: [],
prologueList: getContentpriList(),
comment: comonList,
needReply: auto.value,
isComment: common.value //是否评论
}
).then((res) => {
hostList = []
})
}
}, 1000)
return
}
scheduleEnabled.value = true
initialTextStr.value = '';
dialogTitle.value = '主播ID';
showDialog.value = true;
} else if (key === 'like') {
if (!deviceId) {
await stopAll(5000)
} else {
growAccount({ udid: deviceId })
return
}
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId }))
}, 1000)
} else if (key === 'brushLive') {
if (!deviceId) {
await stopAll(5000)
} else {
watchLiveForGrowth({ udid: deviceId })
return
}
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'brushLive'
deviceInformation.value.forEach((item) => watchLiveForGrowth({ udid: item.deviceId }))
}, 1000)
runType.value = 'brushLive'
} else if (key === 'listen') {
if (!deviceId) {
await stopAll(5000)
} else {
monitorMessages({ udid: deviceId })
return
}
setTimeout(() => {
runType.value = 'listen'
scheduleEnabled.value = true
isMonitorOn.value = true
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId }))
}, 1000)
}
})
}
async function stopCurrentMode() {
// 如果你希望“只停当前片段的设备”,也可以用 stopScript 针对设备循环
await stopAll(100)
}
/** 恢复:回到 scheduleState.index 对应片段,并让 startTime 回到“暂停前进度” */
function resumeAfterInterrupt() {
if (!pauseSnapshot) return
const { index, elapsedBeforePause } = pauseSnapshot
scheduleState.index = index
scheduleEnabled.value = true
// 让当前片段已用时 = 暂停前的 elapsedBeforePause
scheduleState.startTime = Date.now() - elapsedBeforePause
localStorage.setItem('SCHEDULE_STATE', JSON.stringify(scheduleState))
// 确保任务是该片段
runTask(schedulePlan[scheduleState.index].key)
pauseSnapshot = null
}
/**
* 执行一次中断器函数
* 该函数会尝试使用设备列表中的每个设备更改账户,并检查是否所有操作都成功
* @returns {Promise<boolean>} 返回一个Promise解析为布尔值表示操作是否全部成功
*/
async function runInterrupterOnce() {
// 定义一个异步函数,用于执行设备账户更改操作
const promiseFn = async () => {
// 从deviceInformation中提取设备ID并过滤掉无效值
const devices = (deviceInformation.value || [])
.map(d => d?.deviceId)
.filter(Boolean);
// 如果没有有效设备直接返回false
if (!devices.length) return false;
console.log(devices.length + '台设备换账号')
// 为每个设备创建一个检查函数,用于尝试更改账户
const checks = devices.map(async (udid) => {
try {
// 尝试更改账户并检查返回结果
const res = await changeAccount({ udid: udid });
// 检查返回值是否表示成功
return (
res.code === 200
);
} catch {
// 如果出错返回false
return false;
}
});
// 等待所有检查完成,并获取结果
const settled = await Promise.allSettled(checks);
// 检查是否所有操作都成功完成
const allOk = settled.every(s => s.status === 'fulfilled' && s.value === true);
return allOk;
};
// 使用超时机制执行promiseFn捕获超时或其他错误并返回false
return await withTimeout(promiseFn(), interruptCallTimeoutMs).catch(() => false);
}
function withTimeout(p, ms) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timeout')), ms)
p.then(v => { clearTimeout(t); resolve(v) })
.catch(e => { clearTimeout(t); reject(e) })
})
}
function startScheduleLoop() {
lastInterruptTs = Date.now()
localStorage.setItem('INT_LAST_TS', String(lastInterruptTs))
// 先按当前 index 跑一次,保持“即刻对齐”
runTask(schedulePlan[scheduleState.index].key, null, 1)
if (scheduleTimer) clearInterval(scheduleTimer)
scheduleTimer = setInterval(async () => {
// 关总调度就什么都不做
if (!scheduleEnabled.value) return
// —— 先处理“打断器” ——
if (interruptEnabled.value && !interrupting) {
const now = Date.now()
if (!lastInterruptTs) lastInterruptTs = now // 首次初始化
const due = now - lastInterruptTs >= interruptEveryMin.value * 60_000 * 60
console.log(
'due=', due,
'elapsed=', now - lastInterruptTs,
'threshold=', interruptEveryMin.value * 60_000 * 60
)
if (due) {
interrupting = true
try {
// 记录暂停前的进度(用于恢复)
const cur = schedulePlan[scheduleState.index]
const elapsedBeforePause = now - scheduleState.startTime
pauseSnapshot = { index: scheduleState.index, elapsedBeforePause }
// 停掉当前片段
await stopAll(5000)
// 执行中断任务(带重试)
let ok = false
for (let i = 0; i < interruptMaxRetries; i++) {
ok = await runInterrupterOnce()
if (ok) break
}
// 无论成功与否都更新节拍,避免立刻再次触发
lastInterruptTs = Date.now()
localStorage.setItem('INT_LAST_TS', String(lastInterruptTs))
// 成功:恢复暂停前的片段进度;失败:也恢复(或改成重启当前片段都可)
resumeAfterInterrupt()
} catch (e) {
console.error('[Interrupter] 失败:', e)
// 失败兜底:恢复任务
resumeAfterInterrupt()
} finally {
interrupting = false
}
// ⚠️ 本轮只做打断,不做片段切换判断
return
}
}
// —— 正常片段轮换(你原有的逻辑) ——
const now = Date.now()
const cur = schedulePlan[scheduleState.index]
const elapsed = now - scheduleState.startTime
if (elapsed >= cur.duration) {
scheduleState.index = (scheduleState.index + 1) % schedulePlan.length
scheduleState.startTime = now
localStorage.setItem('SCHEDULE_STATE', JSON.stringify(scheduleState))
runTask(schedulePlan[scheduleState.index].key)
}
}, scheduleTickMs)
}
function forceActivate(key, runner) {
// 跳过互斥逻辑,直接切换
// runType.value = key;
if (typeof runner === 'function') runner();
}
function getTranslation(list) {
list.forEach((item, index) => {
translationToChinese({ msg: item.text }).then(res => {
console.log(res);
chatList.value[index].text = res
})
})
}
function onHostSaved(list) {
console.log('保存后的主播id:', list)
}
//当前时间获取
function printCurrentTime() {
const now = new Date();
return now.toLocaleString()
}
function onSave(payload) {
console.log(payload)
aiConfig(payload).then((res) => {
})
}
async function isAiConfig() {
const res = await window.electronAPI.fileExists();
console.log(res);
return res.exists;
}
//金票
const gold = ref(true) // ON=执行
//普票
const ordinary = ref(true) // ON=执行
function invitTypeFun(invitType, nextEnabled) {
// 子组件已传来“切换后的布尔值”
if (invitType === 'gold') {
gold.value = nextEnabled
} else if (invitType === 'ordinary') {
ordinary.value = nextEnabled
}
}
/** 由邀请类型 + 当前全局开关,得到 state(0/1) */
function stateByInvType(invitationType) {
// 2=金票,其他=普票(按你的写法)
const enabled = invitationType == 2 ? gold.value : ordinary.value
return enabled ? 1 : 0
}
//清空当前批次的小工具
function dropCurrentWave() {
// 丢弃当前这“波”已缓冲的数据 & 取消待 flush
batch.length = 0
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
// 复位本波的弹窗与模式标记
isMsgPop.value = false
}
async function doLogout() {
try {
dropCurrentWave()
// es?.close?.() // 如果 connectSSE 返回 EventSource调用 close
refreshAllStopImgs() // 你已有:把所有 <img> src 清空
clearInterval(getListtimer)
getListtimer = null
await logout({ userId: userdata.id, tenantId: userdata.tenantId })
} finally {
router.push('/')
}
}
function stopOne(deviceId) {
stopLoading = ElLoading.service({
lock: true,
text: '停止中',
background: 'rgba(0, 0, 0, 0.7)',
});
stopScript({ udid: deviceId }).then((res) => {
stopLoading.close()
}).catch((err) => {
stopLoading.close()
}).finally((err) => {
stopLoading.close()
})
}
//查看主播库主播信息
async function getStoredHostList() {
const v = await anchorList()
return v ? v : []
}
let isWifi = ref(false);
const checkVPN = async () => {
try {
// 设置超时 5 秒钟
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), 10000) // 10秒超时
);
// 使用 Promise.race 来进行超时控制
const response = await Promise.race([
fetch('https://www.google.com', { method: 'HEAD', mode: 'no-cors' }),
timeout
]);
// 判断 fetch 请求是否成功
if (response && response.type === 'opaque') {
// ElMessage.success('VPN连接正常');
isWifi.value = false;
} else {
ElMessage.error('VPN连接失败无法访问网络。');
isWifi.value = true;
}
} catch (error) {
// 捕获超时错误或其他错误
ElMessage.error('VPN连接失败无法访问网络。');
isWifi.value = true;
}
};
// 模拟翻译函数:你可以接入自己后端或第三方接口 sentences文本 targetLang语言
async function doTranslate(sentences, targetLang) {
const str = arrayToString(sentences)
try {
const response = await customTranslation({
"msg": str,
"language": targetLang
})
console.log(response)
// 1⃣ 去掉首尾的大括号
const raw = response.replace(/^{|}$/g, '')
// 2⃣ 按换行符切割为数组
const arr = raw.split('\n').map(s => s.trim()).filter(Boolean)
console.log(arr)
// 简单演示逻辑(真实情况应调用 API
return arr
} catch (error) {
console.error('Translation error:', error)
// 发生错误时返回空数组
return []
}
}
// 接收“确定”事件返回结果
function onConfirm({ type, strings, autoBlo }) {
console.log('✅ 确认返回:', type, strings, autoBlo)
auto.value = autoBlo
showtransDlg.value = false
runType.value = 'follow'
setContentpriList(strings)
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: hostList.map(item => ({
anchorId: item.id,
country: item.country,
invitationType: item.invitationType,
state: stateByInvType(item.invitationType),
})),
prologueList: strings,
needReply: autoBlo,
// needTranslate: data.needTranslate,
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
hostList = []
})
} else {
passAnchorData(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: hostList.map(item => ({
anchorId: item.id,
country: item.country,
invitationType: item.invitationType,
state: stateByInvType(item.invitationType),
})),
prologueList: strings, //私信对象
comment: comonList, //评论列表
needReply: autoBlo, //自动回复
// needTranslate: data.needTranslate,
isComment: common.value //是否评论
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
console.log("启动成功")
hostList = []
})
}
}
function arrayToString(arr) {
// 过滤空项并用 \n 连接
return arr.filter(Boolean).join(' \n')
}
// —— 每 20s 刷新网络状态 ——
// 写一个小函数专门刷新网络状态,方便复用
async function refreshNetStatus() {
const list = [...(deviceInformation.value || [])]
// 并发请求每台设备网络状态(安全处理错误)
const settled = await Promise.allSettled(
list.map(d => getDeviceNetStatus({ udid: d.deviceId }))
)
settled.forEach((s, i) => {
const id = list[i].deviceId
netStatus[id] = (s.status === 'fulfilled' && s.value === true)
})
console.log("设备在线状态", settled)
// 清理已下线设备的状态,避免残留
const aliveIds = new Set(list.map(d => d.deviceId))
Object.keys(netStatus).forEach(id => {
if (!aliveIds.has(id)) {
delete netStatus[id]
delete lastNet[id]
delete offlineFlags[id]
clearResumeTimer(id)
}
})
// —— 在这里做“网络→任务联动”(只在有运行模式时触发)——
if (runType.value) {
list.forEach(({ deviceId: id }) => {
const prev = lastNet[id] // 之前的网络状态
const curr = netStatus[id] // 当前的网络状态true/false/undefined)
if (typeof curr === 'undefined') return
// 边沿触发true -> false 断网false -> true 恢复
if (prev !== curr) {
// 断网:立刻停止该设备任务(只停这一台)
if (curr === false) {
clearResumeTimer(id) // 避免有遗留恢复定时器
if (!offlineFlags[id]) {
console.log(`[NET] ${id} 离线,停止该设备任务`)
offlineFlags[id] = true
// 这里调用你已有的“只停一台”的方法
stopOne(id)
}
}
// 恢复:等待网络稳定后再恢复该设备任务
if (curr === true && offlineFlags[id]) {
clearResumeTimer(id)
const timer = setTimeout(() => {
// 二次确认:还在运行模式里、该设备仍在线
if (runType.value && netStatus[id] === true) {
console.log(`[NET] ${id} 恢复在线,重新启动当前模式: ${runType.value}`)
// 仅启动这一台
runTask(runType.value, id)
// 标记已恢复
offlineFlags[id] = false
}
resumeTimers.delete(id)
}, NET_RESUME_STABLE_MS)
resumeTimers.set(id, timer)
}
}
// 更新 last
lastNet[id] = curr
})
} else {
// 不在运行模式时,清理“暂停标记”和恢复定时器,保持干净
Object.keys(offlineFlags).forEach(id => { offlineFlags[id] = false })
Array.from(resumeTimers.keys()).forEach(id => clearResumeTimer(id))
}
}
</script>
<style scoped lang="less">
@import '../static/css/video.less';
</style>