Files
iosAiPage/src/views/VideoStream.vue
2025-11-18 18:05:29 +08:00

1625 lines
50 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() : {}"
:title="btn.tooltip ? btn.tooltip() : ''" @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>
<div>
<!-- 监听 q.tenant.*爬虫 -->
<el-switch v-model="sseCrawlerEnabled" inline-prompt active-text="监听爬虫" inactive-text="监听爬虫"
style="margin-left: 8px;" @change="onToggleCrawler" />
<!-- 监听 b.tenant.*大哥 -->
<el-switch v-model="sseBossEnabled" inline-prompt active-text="监听大哥" inactive-text="监听大哥"
style="margin-left: 8px;" @change="onToggleBoss" />
</div>
</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="scheduleEnabled = true; 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(2000, 'click')" />
<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(2000, 'click')" />
</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, watch, computed } from "vue";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
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 { logout, updates, health } from '@/api/account';
import {
toHome,
growAccount,
stopScript,
watchLiveForGrowth,
monitorMessages,
passAnchorData,
followAndGreetUnion,
addTempAnchorData,
getChatTextInfo,
setLoginInfo,
aiConfig,
selectLast,
changeAccount,
stopAllTask,
anchorList,
restartTikTok,
getDeviceNetStatus
} from '@/api/ios';
import ding from '@/assets/mes.wav'
//引入两个分包方法
import { useDevices } from '@/composables/useDevices'
import { useScreenStreams } from '@/composables/useScreenStreams'
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 isTranslate = ref(false)
let initialTextStr = ref('') // 初始文本字符串
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
let hostList = [] // 主播列表
let comonList = [] //评论列表
//查询 列表 新消息轮询
let getNetworkListtimer = null;
let userdata = getUser();
let chatList = ref([])
let MesNewList = ref([])
//start弹窗
let isMsgPop = ref(false)
let runType = ref('')
let isMonitorOn = ref(false)
const hoverIndex = ref(null) //选中
let showDialog = ref(false);//弹窗是否显示
let dialogTitle = ref('');//当前弹窗类型
// 你可以用这种方式声明按钮们
//停止中
let stopLoading = null
// 是否未开通 AI 自动回复(= 监测消息不可用)
const isListenLockedByPlan = computed(() => String(userdata?.aiReplay ?? '0') === '0')
// === 1. 设备相关 ===
const {
deviceInformation,
getDeviceListFun,
getListTimer,
openTk,
stopOne,
} = useDevices()
// === 2. 屏幕流相关 ===
const {
selectedDevice,
imgRefs,
imgSrcMap,
refreshAllImgs,
refreshOneImg,
refreshAllStopImgs,
getCanvasStyle,
imgWH,
selectDevice,
onCanvasDown,
onCanvasMove,
onCanvasUp,
} = useScreenStreams(deviceInformation)
// 每台设备的网络状态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)
}
}
// —— 在线判定/过滤 ——
// 红框=netStatus[id] === false 视为离线其它true/undefined都当作可用
const isOnline = (id) => netStatus[id] !== false
const onlineOnly = (ids) => ids.filter(isOnline)
// 当前是否被其它模式占用(四个互斥按钮专用)
const isLocked = (type) => !!runType.value && runType.value !== type
// —— SSE 开关(分别控制爬虫队列 & 大哥队列)——
const sseCrawlerEnabled = ref(JSON.parse(localStorage.getItem('SSE_CRAWLER_ENABLED') ?? 'true')) // q.tenant.*
const sseBossEnabled = ref(JSON.parse(localStorage.getItem('SSE_BOSS_ENABLED') ?? 'false')) // b.tenant.*
watch(sseCrawlerEnabled, v => {
localStorage.setItem('SSE_CRAWLER_ENABLED', JSON.stringify(v))
})
watch(sseBossEnabled, v => {
localStorage.setItem('SSE_BOSS_ENABLED', JSON.stringify(v))
})
// 至少有一个开着才算启用 SSE
const sseAnyEnabled = computed(() => sseCrawlerEnabled.value || sseBossEnabled.value)
// 互斥按钮的样式:激活=红,锁定=半透明且禁点
const ctrlStyle = (type) => {
const lockedByMode = isLocked(type)
const disableListen = (type === 'listen') && isListenLockedByPlan.value
const style = {
backgroundColor: runType.value === type ? 'red' : '',
opacity: (lockedByMode || disableListen) ? 0.5 : 1,
// 允许事件,以便显示浏览器原生 title 提示 & 点击弹出 ElMessage
pointerEvents: lockedByMode ? 'none' : 'auto',
cursor: (lockedByMode || disableListen) ? 'not-allowed' : 'pointer',
filter: disableListen ? 'grayscale(1)' : ''
}
return style
}
const buttons = [
{
label: '刷新',
onClick: () => refreshAllImgs(),
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 (isListenLockedByPlan.value) {
ElMessage.warning('未开通 AI 自动回复功能,请联系管理员开通');
return
}
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 }))
},
// 悬停提示文案
tooltip: () => (isListenLockedByPlan.value ? '未开通AI 自动回复' : ''),
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(2000, 'click'),
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('已保存定时调度')
}
function getMesList(deviceId) {
getChatTextInfo({ udid: deviceId }).then((res) => {
if (res) {
chatList.value = res
console.log(chatList.value)
getTranslation(chatList.value)
}
})
}
async function stopAll(time, type) {
stopLoading = ElLoading.service({
lock: true,
text: '停止中',
background: 'rgba(0, 0, 0, 0.7)',
});
// 🔒 先强制关闭所有提示弹窗
ElMessageBox.close();
if (type == 'click') {
// 关闭调度定时器
clearInterval(scheduleTimer)
scheduleTimer = null
}
scheduleEnabled.value = false;
runType.value = '';
isMsgPop.value = false;
dropCurrentWave();
// ========== ⏰ 新增超时逻辑 ==========
const STOP_TASK_TIMEOUT_MS = 120000; // 120 秒超时,可调整
// 封装一个带超时保护的 Promise
const withTimeout = (p, ms) => {
return Promise.race([
p,
new Promise((resolve) =>
setTimeout(() => {
console.warn(`[stopAllTask] 超时 ${ms}ms`);
resolve('timeout');
}, ms)
),
]);
};
try {
// 1⃣ 带超时保护地调用接口
const res = await withTimeout(
stopAllTask(deviceInformation.value.map(item => item.deviceId)),
STOP_TASK_TIMEOUT_MS
);
if (res === 'timeout') {
console.warn('stopAllTask 请求超时,自动继续后续逻辑');
}
// 2⃣ 等待指定时间(原逻辑保留)
await new Promise(r => setTimeout(r, time));
stopLoading.close();
console.log('全部停止成功', printCurrentTime());
ElMessage.success('全部停止成功');
// 3⃣ 无论接口成功或超时都返回 true
return true;
} catch (e) {
console.error('停止失败', 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(`未检测到设备`)
}
//MQ链接
window.electronAPI.startMq(userdata.tenantId, userdata.id)
// 初始化时获取设备列表
//每3秒获取一次设备列表 消息列表 和 查询主播列表是否还有主播
getListTimer.value = setInterval(async () => {
getDeviceListFun() //获取设备列表
selectLastFun() //获取新消息
const hostsList = await getStoredHostList() //获取主播列表
// console.log(hostsList.length)
//当私信主播时,主播列表没有数据了,提示列表空了 并且关闭私信
if (runType.value == 'follow') {
if (hostsList.length <= 0) {
await stopAll(2000)
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId, comment: getContentList(), isComment: common.value }))
ElMessageBox.alert('私信全部完成!(刷视频中)', '提示', {
confirmButtonText: 'OK',
callback: (action) => {
},
})
}
}
}, 3000)
getNetworkListtimer = setInterval(async () => {
await checkVPN()
await refreshNetStatus()
health().then((res) => {
}).catch((err) => {
if (err.code === 40400) {
//关闭获取设备列表的方法
clearInterval(getListTimer.value)
getListTimer.value = null
//关闭获取网络状态的方法
clearInterval(getNetworkListtimer)
getNetworkListtimer = null
}
})
}, 1000 * 90)
if (!await isAiConfig()) {
showMyInfo.value = true
}
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) => {
// 所有 SSE 关掉的话,直接不处理
if (!sseAnyEnabled.value) return
if (data === 'start') {
// 新一波开始:清空上一波的缓冲,重置防抖
dropCurrentWave()
return
}
// 1⃣ 判断来源_mqMeta = 1(q.tenant.*) / 2(b.tenant.*)
const metaCode = data && data._mqMeta
const fromCrawler = metaCode === 1 || metaCode === '1' // 爬虫队列 q.tenant.*
const fromBoss = metaCode === 2 || metaCode === '2' // 大哥队列 b.tenant.*
// 没有标记的老数据,一律按爬虫处理(可选)
const isUnknown = !fromCrawler && !fromBoss
// 2⃣ 按开关过滤
if (fromCrawler && !sseCrawlerEnabled.value) return
if (fromBoss && !sseBossEnabled.value) return
if (isUnknown && !sseCrawlerEnabled.value) return // 老数据当爬虫看
// 3⃣ 按来源取不同字段
let country = ''
let text = ''
let invitationType = ''
let id = ''
if (fromBoss) {
// 大哥队列 b.tenant.* 进来的那条 JSON 结构:
// {"id":5681,"displayId":"80",...,"region":"西南",...,"_mqMeta":2}
country = data.region || ''
// 主播ID 优先 displayId → hostDisplayId → userIdStr → userId
text = data.displayId
|| data.hostDisplayId
|| data.userIdStr
|| (data.userId != null ? String(data.userId) : '')
invitationType = data.invitationType != null ? data.invitationType : 2 // 金票默认 2
id = data.id != null ? data.id : ''
} else {
// 爬虫队列 q.tenant.*(以前的老逻辑)
country = data && data.country != null ? data.country : ''
text = data && (data.hostsId != null ? data.hostsId : data.text)
invitationType = data && (data.invitationType != null ? data.invitationType : '')
id = data && data.id != null ? data.id : ''
}
if (!text) return
batch.push({ country, text, invitationType, id })
//400ms内如果没有数据了 进行入库如果400ms内进入了数据 重置计时器 一波一波入库
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.value)
getListTimer.value = null
//关闭获取网络状态的方法
clearInterval(getNetworkListtimer)
getNetworkListtimer = null
//关闭调度定时器
if (scheduleTimer) {
clearInterval(scheduleTimer)
scheduleTimer = null
}
})
//获取新消息
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) {
// —— 若指定单设备,且该设备当前离线,则直接跳过 ——
if (deviceId && !isOnline(deviceId)) {
markPendingForOffline([deviceId]); // ⭐ 新增:让它后续来网能自动跟上
ElMessage.warning('该设备当前网络不可用,已跳过');
return;
}
console.log('[schedule] 切换到任务:', key, printCurrentTime())
forceActivate(key, async () => {
if (key === 'follow') {
console.log("进入follow", scheduleEnabled.value)
if (scheduleEnabled.value) {
//如果有id 就只执行一个 并且return出去
if (deviceId) {
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: [deviceId],
anchorList: [],
prologueList: getContentpriList(),
needReply: auto.value,
needTranslate: isTranslate.value,
}
).then((res) => {
hostList = []
return
})
} else {
passAnchorData(
{
deviceList: [deviceId],
anchorList: [],
prologueList: getContentpriList(),
comment: comonList,
needReply: auto.value,
needTranslate: isTranslate.value,
isComment: common.value //是否评论
}
).then((res) => {
hostList = []
return
})
}
return
} else {
//没有id 直接停止所有设备
await stopAll(2000)
//第一个小时结束后,第二轮开始的时候,直接进入follow
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'follow'
//过滤无网络设备
const allIds = deviceInformation.value.map(item => item.deviceId)
// 在线要启动的
const deviceIds = onlineOnly(allIds)
if (!deviceIds.length) {
ElMessage.warning('没有在线设备可执行任务');
// 也别忘了把离线的标成待恢复
markPendingForOffline(allIds.filter(id => netStatus[id] === false))
return
}
// ⭐ 给这次被跳过的离线设备打标记(后续来网自动补上)
markPendingForOffline(allIds.filter(id => !deviceIds.includes(id)))
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: deviceIds,
anchorList: [],
prologueList: getContentpriList(),
needReply: auto.value,
needTranslate: isTranslate.value,
}
).then((res) => {
hostList = []
return
})
} else {
passAnchorData(
{
deviceList: deviceIds,
anchorList: [],
prologueList: getContentpriList(),
comment: comonList,
needReply: auto.value,
needTranslate: isTranslate.value,
isComment: common.value //是否评论
}
).then((res) => {
hostList = []
return
})
}
}, 1000)
return
}
} else {
// 如果是暂停状态,则打开弹窗进行开启第一轮任务
scheduleEnabled.value = true
initialTextStr.value = '';
dialogTitle.value = '主播ID';
showDialog.value = true;
}
} else if (key === 'like') {
if (!deviceId) {
if (scheduleEnabled.value) {
await stopAll(2000)
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId, comment: getContentList(), isComment: common.value }))
}, 1000)
} else {
dialogTitle.value = '视频评论';
setTimeout(() => {
showDialog.value = true;
initialTextStr.value = getContentListMultiline();
}, 500)
}
} else {
growAccount({ udid: deviceId, comment: getContentList(), isComment: common.value })
return
}
} else if (key === 'brushLive') {
if (!deviceId) {
await stopAll(2000)
} 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(2000)
} else {
monitorMessages({ udid: deviceId })
return
}
setTimeout(() => {
runType.value = 'listen'
scheduleEnabled.value = true
isMonitorOn.value = true
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId }))
}, 1000)
}
})
}
/** 恢复:回到 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))
// 确保任务是该片段
scheduleEnabled.value = true
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(2000)
// 执行中断任务(带重试)
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 = JSON.parse(localStorage.getItem('SCHEDULE_PLAN'))[scheduleState.index]
const elapsed = now - scheduleState.startTime
console.log('已调度', elapsed / 1000, '秒', cur.duration / 1000 + '秒后调度', printCurrentTime())
if (elapsed >= cur.duration) {
scheduleState.index = (scheduleState.index + 1) % schedulePlan.length
scheduleState.startTime = now
localStorage.setItem('SCHEDULE_STATE', JSON.stringify(scheduleState))
scheduleEnabled.value = true
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.value)
getListTimer.value = null
await logout({ userId: userdata.id, tenantId: userdata.tenantId })
} finally {
router.push('/')
}
}
//查看主播库主播信息
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, needTranslate }) {
console.log('✅ 确认返回:', type, strings, autoBlo, needTranslate)
auto.value = autoBlo
isTranslate.value = needTranslate
showtransDlg.value = false
runType.value = 'follow'
setContentpriList(strings)
//过滤无网络设备
const allIds = deviceInformation.value.map(item => item.deviceId)
// 在线要启动的
const deviceIds = onlineOnly(allIds)
if (!deviceIds.length) {
ElMessage.warning('没有在线设备可执行任务');
// 也别忘了把离线的标成待恢复
markPendingForOffline(allIds.filter(id => netStatus[id] === false))
return
}
// ⭐ 给这次被跳过的离线设备打标记(后续来网自动补上)
markPendingForOffline(allIds.filter(id => !deviceIds.includes(id)))
if (isAlliance.value) {
followAndGreetUnion(
{
deviceList: deviceIds,
anchorList: hostList.map(item => ({
anchorId: item.id,
country: item.country,
invitationType: item.invitationType,
state: stateByInvType(item.invitationType),
})),
prologueList: strings,
needReply: autoBlo,
needTranslate: needTranslate,
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
hostList = []
})
} else {
passAnchorData(
{
deviceList: deviceIds,
anchorList: hostList.map(item => ({
anchorId: item.id,
country: item.country,
invitationType: item.invitationType,
state: stateByInvType(item.invitationType),
})),
prologueList: strings, //私信对象
comment: comonList, //评论列表
needReply: autoBlo, //自动回复
needTranslate: needTranslate, //翻译
isComment: common.value //是否评论
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
console.log("启动成功")
hostList = []
})
}
}
function arrayToString(arr) {
// 过滤空项并用 \n 连接
return arr.filter(Boolean).join(' \n')
}
// 每台设备请求间隔(毫秒)
const REQ_GAP_MS = 5000
async function refreshNetStatus() {
const list = [...(deviceInformation.value || [])]
if (!list.length) return
const firstPass = {}
// ✅ 串行请求:每台设备间隔 5 秒
for (let i = 0; i < list.length; i++) {
const d = list[i]
try {
const res = await getDeviceNetStatus({ udid: d.deviceId })
firstPass[d.deviceId] = (res === true)
} catch (err) {
firstPass[d.deviceId] = true
}
// 不是最后一台才等待 5s
if (i < list.length - 1) {
await new Promise(r => setTimeout(r, REQ_GAP_MS))
}
}
// 二次复核
const falseIds = Object.keys(firstPass).filter(id => firstPass[id] === false)
const confirmResults = await Promise.all(
falseIds.map(async id => {
const stableOffline = await confirmStableOffline(id)
return { id, stableOffline }
})
)
const effective = { ...firstPass }
confirmResults.forEach(({ id, stableOffline }) => {
effective[id] = !stableOffline
})
// 写回 UI
Object.keys(effective).forEach(id => { netStatus[id] = effective[id] })
console.log('设备在线状态(有效值)', effective)
// 清理已下线设备
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]
if (typeof curr === 'undefined') return
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 t = setTimeout(() => {
if (runType.value && netStatus[id] === true) {
console.log(`[NET] ${id} 恢复在线,重新启动当前模式: ${runType.value}`)
scheduleEnabled.value = true
runTask(runType.value, id)
offlineFlags[id] = false
}
resumeTimers.delete(id)
}, NET_RESUME_STABLE_MS)
resumeTimers.set(id, t)
}
}
lastNet[id] = curr
})
} else {
Object.keys(offlineFlags).forEach(id => { offlineFlags[id] = false })
Array.from(resumeTimers.keys()).forEach(id => clearResumeTimer(id))
}
}
// ===== 新增:二次复核断网所需的小工具 =====
const verifyingOffline = {} // { [deviceId]: Promise<boolean> } 去重并发
const RETRY_COUNT = 2 // 额外再查几次
const RETRY_GAP_MS = 1000 // 每次间隔(可调)
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function safeGetStatus(udid) {
try {
return await getDeviceNetStatus({ udid }) === true
} catch {
return true
}
}
/** 返回 true=稳定离线false=误报/抖动(有一次为 true 就当没离线) */
async function confirmStableOffline(udid) {
if (verifyingOffline[udid]) return verifyingOffline[udid]
verifyingOffline[udid] = (async () => {
for (let i = 0; i < RETRY_COUNT; i++) {
await sleep(RETRY_GAP_MS)
const ok = await safeGetStatus(udid) // true=在线
if (ok) return false // 有一次在线 => 抖动
}
return true // 全部都是 false => 稳定离线
})().finally(() => { delete verifyingOffline[udid] })
return verifyingOffline[udid]
}
//小工具:标记离线待恢复
const markPendingForOffline = (ids) => {
ids.forEach(id => {
if (netStatus[id] === false) {
offlineFlags[id] = true; // 标记成“暂停/待恢复”
// 可选:初始化上次状态,便于日志判断
if (typeof lastNet[id] === 'undefined') lastNet[id] = false;
}
});
};
const onToggleCrawler = (val) => {
if (val) sseBossEnabled.value = false
}
const onToggleBoss = (val) => {
if (val) sseCrawlerEnabled.value = false
}
</script>
<style scoped lang="less">
@import '../static/css/video.less';
</style>