上线版本

This commit is contained in:
2025-10-10 15:53:19 +08:00
parent 7bfe1b744a
commit cd6160deaa
8 changed files with 431 additions and 1223 deletions

View File

@@ -4,8 +4,8 @@ VUE_APP_BASE_LOCAL=http://127.0.0.1:34567/
# VUE_APP_BASE_LOCAL=http://192.168.1.209:34567/
# 业务后端(开发用内网地址)
# VUE_APP_BASE_REMOTE=https://crawlclient.api.yolozs.com
VUE_APP_BASE_REMOTE=http://192.168.1.144:8101/
VUE_APP_BASE_REMOTE=https://crawlclient.api.yolozs.com
# VUE_APP_BASE_REMOTE=http://192.168.1.144:8101/
# AI 服务
VUE_APP_BASE_SPECIAL=https://ai.yolozs.com

View File

@@ -50,6 +50,12 @@ export function monitorMessages(data) {
export function passAnchorData(data) {
return postAxios({ url: 'passAnchorData', data })
}
//联盟关注主播
export function followAndGreetUnion(data) {
return postAxios({ url: 'followAndGreetUnion', data })
}
//追加主播
export function addTempAnchorData(data) {
return postAxios({ url: 'addTempAnchorData', data })

View File

@@ -1,5 +1,6 @@
<template>
<el-dialog v-model="show" width="70vw" :close-on-click-modal="false" :destroy-on-close="true" @open="onOpen">
<el-dialog v-model="show" width="70vw" :close-on-click-modal="false" :destroy-on-close="true" draggable
@open="onOpen">
<template #header>
<div class="dlg-title">
<span>主播管理</span>
@@ -47,7 +48,7 @@
<span class="country" :title="it.country">{{ it.country || '—' }}</span>
<span class="state" :class="{ done: it.invitationType == 2 }">{{ it.invitationType == 2 ? '金票' :
'普票'
}}</span>
}}</span>
</div>
</div>

View File

@@ -1,7 +1,12 @@
<template>
<el-dialog draggable :title="title" v-model="visibleLocal" width="600px" @closed="onClosed">
<el-input type="textarea" v-model="rawText" :rows="10" :placeholder="placeholder" />
<el-dialog draggable :title="title" v-model="visibleLocal" width="600px" :close-on-click-modal="false"
@closed="onClosed">
<!-- <el-input type="textarea" v-model="rawText" :rows="10" :placeholder="placeholder" /> -->
<el-input type="textarea" v-model="rawText" :rows="10" :placeholder="placeholder" @input="enforceLineLimit" />
<template #footer>
<span v-if="title === '主播ID'" style="margin-right: 12px; color:#909399;">
{{ lineCount }}/{{ MAX_LINES_FOR_ANCHOR }}
</span>
<span v-if="title === '私信'">
自动回复
<el-switch style="margin-right: 20px;" v-model="value1" />
@@ -16,8 +21,10 @@
</template>
<script setup>
import { ElMessage } from 'element-plus';
import { ref, computed, watch } from 'vue';
import { prologue, comment } from '@/api/account';
const MAX_LINES_FOR_ANCHOR = 100;
let value1 = ref(false);
@@ -54,13 +61,15 @@ function parseLines() {
}
function handleConfirm() {
// if (!rawText.value) {
// return; // 空内容直接忽略
// };
if (props.title === '主播ID' && lineCount.value > MAX_LINES_FOR_ANCHOR) {
ElMessage.error(`“主播ID”最多 ${MAX_LINES_FOR_ANCHOR} 行,请精简后再提交。`);
enforceLineLimit(); // 再次截断以保证安全
return;
}
const items = parseLines();
emit('confirm', items, props.title, props.index, value1.value);
closingByConfirm.value = true; // 标记:这次关闭来自“确定”
emit('update:visible', false); // 关弹窗
closingByConfirm.value = true;
emit('update:visible', false);
}
function onClickCancel() {
@@ -94,6 +103,21 @@ function exportPrologue(title) {
});
}
}
function enforceLineLimit() {
if (props.title !== '主播ID') return;
const lines = (rawText.value || '').split(/\r?\n/);
if (lines.length > MAX_LINES_FOR_ANCHOR) {
rawText.value = lines.slice(0, MAX_LINES_FOR_ANCHOR).join('\n');
ElMessage.warning(`“主播ID”最多 ${MAX_LINES_FOR_ANCHOR} 行,已自动截断。`);
}
}
const lineCount = computed(() =>
(rawText.value || '')
.split(/\r?\n/)
.filter(s => s.trim().length > 0).length
);
</script>
<style scoped>

View File

@@ -70,7 +70,10 @@ export function addToHostList(newItem) {
}
/** 以“换行”拼接输出,用于 textarea 等展示 */
export function getContentpriListMultiline() {
return getContentpriList() == null ? '' : getContentpriList().join('\n');
}
// 用于获取私信信息
export function getContentpriList() {
const arr = JSON.parse(localStorage.getItem('Contentpri'))

View File

@@ -79,7 +79,7 @@ import { getToken, setToken, setUser, setUserPass, getUserPass } from '@/stores/
import { ElLoading, ElMessage } from 'element-plus';
import { passToken } from '@/api/ios';
let version = ref('1.5.4');
let version = ref('1.6.4');
onMounted(() => {
@@ -114,14 +114,16 @@ const onSubmit = () => {
console.log(res)
setToken(res.tokenValue);
setUser(res);
// setTimeout(() => {
// passToken({ token: res.tokenValue })
// }, 10000)
setTimeout(() => {
passToken({ data: res, })
}, 10000)
router.push('/Video');
}).catch((err) => {
loading.close();
});
}).finally((err) => {
loading.close();
})
})
};
</script>

View File

@@ -4,13 +4,6 @@
<div style="position: absolute;left: 20px; top: 20px;">
<el-button style="background: linear-gradient(90deg, #60a5fa, #34d399); color: azure;"
@click="showMyInfo = true">人设编辑</el-button>
<!-- <el-button style="background: linear-gradient(90deg, #60a5fa, #34d399); color: azure;" @click="MesNewList.push({
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
})">新增</el-button> -->
</div>
<div class="center-line"> <!-- 左边栏按钮 -->
@@ -35,8 +28,7 @@
<div class="video-container" v-for="(device, index) in deviceInformation" :key="device.deviceId">
<div class="video-canvas" :class="{ active: selectedDevice === index }" :style="getCanvasStyle(index)"
@click="selectDevice(index)">
<img class="stream" :key="device.deviceId + '-' + imgKeyTick"
:src="`http://localhost:${device.screenPort}/?t=${Date.now() || 0}`" :data-id="device.deviceId"
<img class="stream" :src="imgSrcMap[device.deviceId] || ''" :data-id="device.deviceId"
:ref="el => (imgRefs[device.deviceId] = el)" />
<canvas v-show="selectedDevice === index" class="overlay"
@@ -47,28 +39,9 @@
<div class="input-info" v-show="selectedDevice == index">
<div class="app-button" @click="getMesList(device.deviceId)">获取当前聊天记录</div>
<div class="app-button" @click="stopScript({ udid: device.deviceId })">停止任务</div>
<div class="app-button" @click="stopOne(device.deviceId)">停止任务</div>
<div class="app-button" @click="runTask(runType, device.deviceId)">开启</div>
<!-- <div class="app-button" @click="mqSend()">mq</div> -->
<!-- <div class="app-button"
@click="wsActions.clickCopyList(device.deviceId, index); openShowChat = true; istranslate = true">
翻译本页对话</div>
<div class="app-button" @click="wsActions.clickCopyList(device.deviceId, index); openShowChat = true;">
回复消息</div>
<div class="app-button" @click="wsActions.test(device.deviceId, index)">打印ui节点树</div>
<div class="app-button" @click="wsActions.isOneLive(device.deviceId, index)">判断单人还是双人</div>
<div class="app-button" @click="wsActions.slideDown(device.deviceId, index)">下滑</div>
<div class="app-button" @click="wsActions.killNow(device.deviceId, index)">关闭当前应用</div>
<div class="app-button" @click="chooseFile(device.deviceId, index, 1, wsActions)">安装 APK
文件</div>
<div class="app-button" @click="chooseFile(device.deviceId, index, 2, wsActions)">
传送文件</div> -->
<!-- <div style="display: flex;">
<input style="border: 1px solid #000;margin:0px 14px;" v-model="textContent[index]" type="text"></input>
<div class="app-button" style="margin: 0px;height: 40px;width: 60px;font-size: 14px;"
@click="setComText(index)">发送
</div>
</div> -->
</div>
</div>
</div>
@@ -77,8 +50,8 @@
<ChatDialog :visible="openShowChat" :messages="chatList" />
<MessageDialogd :visible="openShowChat" :messages="MesNewList" :sound-src="ding" />
</div>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='""' :title="dialogTitle" :index="selectedDevice"
@confirm="onDialogConfirm" @cancel="stopAll" />
<MultiLineInputDialog v-model:visible="showDialog" :initialText='initialTextStr' :title="dialogTitle"
:index="selectedDevice" @confirm="onDialogConfirm" @cancel="stopAll" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" @invitType="invitTypeFun" />
</div>
@@ -90,12 +63,12 @@
contact: borkerConfig.contact
}" @save="onSave" />
<!-- 定时调度配置弹窗 -->
<el-dialog v-model="showScheduleDlg" title="定时调度(每小时)" width="420px">
<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="follow" />
<el-option label="刷视频(养号)" value="like" />
<el-option label="刷直播" value="brushLive" />
<el-option label="监测消息" value="listen" />
@@ -119,8 +92,18 @@
<div>总时长</div>
<div><b>{{ schedAMin + schedBMin }}</b> 分钟必须等于 60</div>
<!-- <div>启用调度</div>
<div><el-switch v-model="scheduleEnabled" /></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="180" />
<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>
@@ -129,8 +112,6 @@
</template>
</el-dialog>
</template>
<script setup>
@@ -140,7 +121,7 @@ import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
setsessionId, getsessionId
setsessionId, getsessionId, getContentpriListMultiline
} from '@/stores/storage'
import { connectSSE } from '@/utils/sseUtils'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
@@ -163,6 +144,7 @@ import {
watchLiveForGrowth,
monitorMessages,
passAnchorData,
followAndGreetUnion,
addTempAnchorData,
deviceAppList,
launchApp,
@@ -179,7 +161,7 @@ const router = useRouter();
const openShowChat = ref(true)
//主播库
const showHostDlg = ref(false)
//ai人设
//ai人设弹框
const showMyInfo = ref(false)
// 假设这是你已有的数据
const borkerConfig = reactive({
@@ -189,6 +171,7 @@ const borkerConfig = reactive({
contact: ''
})
let initialTextStr = ref('') // 初始文本字符串
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
@@ -201,15 +184,13 @@ let chatList = ref([])
let MesNewList = ref([])
// 引入刷新方法
const reload = inject("reload")
// 刷新方法
const reloadImg = () => {
refreshAllImgs()
}
//start弹窗
let isMsgPop = ref(false)
//缓存主播联动数组
let stroageHost = ref([])
let runType = ref('')
let isMonitorOn = ref(false)
const hoverIndex = ref(null) //选中
@@ -217,8 +198,6 @@ let showDialog = ref(false);//弹窗是否显示
let dialogTitle = ref('');//当前弹窗类型
let deviceInformation = ref([])
// 你可以用这种方式声明按钮们
//联动用作标记
let batchMode = ref('init'); // 'init' | 'follow'(仅作标记)
//停止中
let stopLoading = null
@@ -229,6 +208,7 @@ const isLocked = (type) => !!runType.value && runType.value !== type
const sseEnabled = ref(JSON.parse(localStorage.getItem('SSE_ENABLED') ?? 'true'))
watch(sseEnabled, v => {
localStorage.setItem('SSE_ENABLED', JSON.stringify(v))
if (!v) dropCurrentWave() // 关掉总开关时立刻丢弃本波缓冲并取消待flush
})
// 互斥按钮的样式:激活=红,锁定=半透明且禁点
@@ -319,31 +299,7 @@ const buttons = [
},
style: () => ctrlStyle('like')
},
// {
// label: '一键关注并打招呼',
// onClick: () => {
// if (runType.value == 'follow') {
// deviceInformation.value.forEach((item) => {
// stopScript({ udid: item.deviceId })
// })
// runType.value = ''
// return
// };
// if (isLocked('follow')) return
// showDialog.value = true;
// dialogTitle.value = '主播ID';
// },
// 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: () => openScheduleDialog(),
@@ -399,13 +355,7 @@ const buttons = [
},
{
label: '登出',
onClick: () => {
refreshAllStopImgs() // 停止所有视频流
logout({ userId: userdata.id, tenantId: userdata.tenantId })
router.push('/')
},
onClick: () => doLogout(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn9.png', import.meta.url).href,
@@ -413,10 +363,34 @@ const buttons = [
}
}
]
const isAlliance = ref(false)
// —— 打断器配置 ——
// 是否开启
const interruptEnabled = ref(false)
// 每隔多少分钟打断一次(可做弹窗配置)
const interruptEveryMin = ref(15)
// 打断器最大重试次数 & 每次超时(按需调)
const interruptMaxRetries = 3
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 {
@@ -486,23 +460,101 @@ const selectedDevice = ref(null)
// 每台设备的 <img> 引用
const imgRefs = ref({}) // { [id]: HTMLImageElement }
const imgTs = ref({}) // { [id]: number } // cache-bust
const imgKeyTick = ref(0) // 强制重建 <img>
function refreshOneImg(id, port) {
const el = imgRefs.value[id]
console.log("终止", id)
// 新增:每台设备当前展示的 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')
el.src = '';
el.removeAttribute('src');
}
// 下一拍再重建
nextTick(() => {
imgTs.value[id] = Date.now()
imgKeyTick.value++ // 触发 key 变化 -> 销毁并新建 <img>
})
// 如果你用的是 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);
});
}
// —— 关键:登出/离开时“批量硬中断”所有图片流 ——
@@ -520,9 +572,7 @@ async function hardStopAllImgStreams(id, port) {
function refreshAllImgs() {
Object.keys(imgRefs.value).forEach(id => refreshOneImg(id))
}
function refreshAllStopImgs() {
Object.keys(imgRefs.value).forEach(id => hardStopAllImgStreams(id))
}
@@ -754,15 +804,18 @@ async function stopAll() {
// 所有操作完成后执行以下代码
scheduleEnabled.value = false
runType.value = ''
batchMode.value = 'init' // 初始化状态 关注状态
isMsgPop.value = false; //弹窗状态(不是弹窗)
dropCurrentWave() // 丢弃当前波的残留缓冲
try {
const res = await stopAllTask(deviceInformation.value.map((item) => item.deviceId))
console.log(`全部停止成功`, printCurrentTime())
ElMessage.success(`全部停止成功`)
stopLoading.close()
stopAllTask(deviceInformation.value.map((item) => item.deviceId)).then((res) => {
setTimeout(() => {
stopLoading.close()
console.log(`全部停止成功`, printCurrentTime())
ElMessage.success(`全部停止成功`)
}, 2000)
})
} catch (error) {
console.log(`停止失败`, printCurrentTime())
ElMessage.error(`脚本已停止`)
@@ -775,50 +828,94 @@ async function stopAll() {
//确认多行文本框内容
function onDialogConfirm(result, type, index, isMon) {
// console.log(type, result, isMon);
console.log(type)
if (type == '主播ID') {
hostList = (result || []).map(id => ({ id, country: '' }))
dialogTitle.value = '私信';
setTimeout(() => {
showDialog.value = true;
}, 600)
initialTextStr.value = getContentpriListMultiline();
}, 500)
} else if (type == '私信') {
runType.value = 'follow'
setContentpriList(result)
console.log('hostList', hostList)
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: result,
needReply: isMon
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
// console.log('hostList', hostList)
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: result,
needReply: isMon
}
).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: result,
needReply: isMon
}
).then((res) => {
ElMessage({ type: 'success', message: '任务开启成功' });
hostList = []
})
}
hostList = []
})
}
}
onMounted(async () => {
const loading = ElLoading.service({
lock: true,
text: 'Loading',
text: '检测设备中...',
background: 'rgba(0, 0, 0, 0.7)',
});
getDeviceListFun()
const res = await window.electronAPI.isiproxy({
intervalMs: 2000,
exeName: 'iproxy.exe',
maxWaitMs: 300000, // 可选5分钟超时
});
if (res.running) {
// 检测到了,你再决定是否跳转
loading.close();
console.log('检测到了')
} else {
// 超时兜底提示
loading.close();
console.log('未检测到设备')
ElMessage.error(`未检测到设备`)
}
window.electronAPI.startMq(userdata.tenantId, userdata.id)
// 初始化时获取设备列表
getListtimer = setInterval(() => {
loading.close();
getDeviceListFun()
selectLastFun()
@@ -827,8 +924,7 @@ onMounted(async () => {
if (!await isAiConfig()) {
showMyInfo.value = true
}
reconcileLoopsByDevices(deviceInformation.value || []);
function scheduleFlush(handler, delay = 400) {
if (flushTimer) clearTimeout(flushTimer);
@@ -851,110 +947,33 @@ onMounted(async () => {
// console.log('来自服务端:', data);
// console.log(1)
//总开关
// 总开关关闭:不弹窗、丢弃所有消息
if (!sseEnabled.value) {
if (data === 'start') {
// 开始一个新波时顺便清空(保险起见)
dropCurrentWave()
}
return // 其余任何数据也直接丢弃
}
if (!sseEnabled.value) return
if (data === 'start') {
console.log(2)
// 新一波开始:根据当前状态决定“本波 flush 用谁”
if (!isMsgPop.value) {
console.log(3)
// 还没开始过 -> 首次弹框,确认后使用处理本波
isMsgPop.value = true;
batchMode.value = 'init';
ElMessageBox.confirm(
'检测到YOLO助手正在爬取主播是否进行操作',
'消息提醒',
{ confirmButtonText: '开始', cancelButtonText: '取消', type: 'success' }
)
.then(() => {
// runType.value = 'follow';
batchMode.value = 'follow';
// 不在这里立刻提交;让后续主播数据先进 batch再由防抖统一 flush
// 不直接发;把这“一波”的主播先塞进 hostList然后弹出“私信”输入框
scheduleFlush((items) => {
//1普票 2金票 invitationType
hostList = (items || []).map(h => ({ id: h.text, country: h.country || '', invitationType: h.invitationType }))
})
setTimeout(() => {
showScheduleDlg.value = true
}, 600)
})
.catch(() => {
// 取消:清理状态,丢弃批次
batch.length = 0;
isMsgPop.value = false;
});
} else {
// 已经在运行 follow本波用 addTempAnchorData 追加
batchMode.value = 'follow';
// 立刻安排一次“尾随防抖”flush等本波数据齐了再送
scheduleFlush((items) => {
// 这里 items 元素是 h
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType)
}))
addTempAnchorData(list)
});
}
} else {
// 非 start本波主播数据进入批次
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 : '');
if (text == null) {
// 数据格式不对,丢弃或打印
console.warn('[SSE] 非法数据,缺少 hostsId/text:', data);
return;
}
batch.push({ country, text, invitationType });
// 根据当前模式,刷新防抖(让“最后一条到来后”延迟几百毫秒再统一提交)
if (batchMode.value === 'init') {
// 首次确认前:等用户点“开始”后由上面的 scheduleFlush 执行
scheduleFlush((items) => {
// 安全起见:只有在 runType 已经 follow 时才真正提交
if (runType.value === 'follow') {
passAnchorData({
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: items.map(h => ({ anchorId: h.text, country: h.country || '', invitationType: h.invitationType, state: stateByInvType(h.invitationType) })),
needReply: false
});
} else {
// 还没开始就来了数据:把它们留回批次,等待上面 then 里的 scheduleFlush 再处理
batch.push(...items);
}
});
} else {
// 已在关注:走追加逻辑
scheduleFlush((items) => {
// 这里 items 元素是 h
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType)
}))
addTempAnchorData(list)
});
}
// 新一波开始:清空上一波的缓冲,重置防抖
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 : '')
if (!text) return
batch.push({ country, text, invitationType })
scheduleFlush((items) => {
// 批量入库
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType),
}))
addTempAnchorData(list)
}, 400)
})
})
@@ -1065,8 +1084,7 @@ function runTask(key, deviceId) {
forceActivate(key, async () => {
if (key === 'follow') {
console.log("进入follow", scheduleEnabled.value)
if (scheduleEnabled.value) {
if (!deviceId) {
await stopAll()
@@ -1081,6 +1099,7 @@ function runTask(key, deviceId) {
).then((res) => {
hostList = []
})
return
}
@@ -1103,11 +1122,8 @@ function runTask(key, deviceId) {
}
scheduleEnabled.value = true
if (batchMode.value == 'init') {
dialogTitle.value = '主播ID';
} else {
dialogTitle.value = '私信';
}
initialTextStr.value = '';
dialogTitle.value = '主播ID';
showDialog.value = true;
} else if (key === 'like') {
@@ -1158,43 +1174,132 @@ function runTask(key, deviceId) {
})
}
async function stopCurrentMode() {
// 如果你希望“只停当前片段的设备”,也可以用 stopScript 针对设备循环
await stopAll()
}
/** 恢复:回到 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
}
async function runInterrupterOnce() {
// TODO: 换成你的真实调用,比如:替换
// const ok = await someApi({ ... })
// return !!ok
// 示例:包装一个带超时的 Promise把你的方法放到 promiseFn 里
const promiseFn = async () => {
// 这里放你的真实逻辑
changeAccount
console.log('[换号] 正在执行...')
// 比如 await doDailyCheck(); 然后返回 true/false
return true
}
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() {
// 先按照当前 index 跑一次,保证“即刻对齐”
lastInterruptTs = Date.now()
localStorage.setItem('INT_LAST_TS', String(lastInterruptTs))
// 先按当前 index 跑一次,保持“即刻对齐”
runTask(schedulePlan[scheduleState.index].key)
// 清理旧轮询,防止重复
if (scheduleTimer) clearInterval(scheduleTimer)
scheduleTimer = setInterval(() => {
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
console.log(
'due=', due,
'elapsed=', now - lastInterruptTs,
'threshold=', interruptEveryMin.value * 60_000
)
if (due) {
interrupting = true
try {
// 记录暂停前的进度(用于恢复)
const cur = schedulePlan[scheduleState.index]
const elapsedBeforePause = now - scheduleState.startTime
pauseSnapshot = { index: scheduleState.index, elapsedBeforePause }
// 停掉当前片段
await stopCurrentMode()
// 执行中断任务(带重试)
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 mqSend() {
window.electronAPI.mqSend("start")
}
function getTranslation(list) {
list.forEach((item, index) => {
translationToChinese({ msg: item.text }).then(res => {
@@ -1207,21 +1312,7 @@ function getTranslation(list) {
function onHostSaved(list) {
console.log('保存后的主播id:', list)
}
// //sse接收爬虫发送的消息
// const es = connectSSE(`http://localhost:3311/events`, (data) => {
// // connectSSE(`http://192.168.1.155:19665/api/sse/connect/${userdata.tenantId}/${userdata.id}`, (data) => {
// // 处理服务端推送的数据
// console.log('来自服务端:', data)
// //接收到start
// if (data === 'start') {
// } else {
// }
// })
//当前时间获取
function printCurrentTime() {
@@ -1240,7 +1331,8 @@ function onSave(payload) {
async function isAiConfig() {
const res = await window.electronAPI.fileExists("resources/iOSAI/data/aiConfig.json");
const res = await window.electronAPI.fileExists();
console.log(res);
return res.exists;
}
@@ -1278,7 +1370,36 @@ function dropCurrentWave() {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
// 复位本波的弹窗与模式标记
isMsgPop.value = false
batchMode.value = 'init'
}
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()
})
}
</script>

View File

@@ -1,949 +0,0 @@
<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" :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>
</div>
</div>
</el-scrollbar>
<!-- 中间手机区域 -->
<div class="content" @click.self="selectedDevice = 999">
<div v-if="isImg" class="video-container" v-for="(device, index) in deviceInformation" :key="device.deviceId">
<div class="video-canvas" :class="{ active: selectedDevice === index }" :style="imgWH(index)"
@click="selectDevice(index)">
<img class="stream" :style="imgWH(index)" :src="'http://localhost:' + device.screenPort" />
<!-- 选中时显示把down/up都放这里 -->
<canvas v-show="selectedDevice === index" class="overlay" :style="imgWH(index)"
@mousedown.stop="(e) => onCanvasDown(device.deviceId, e, index)"
@mouseup.stop="(e) => onCanvasUp(device.deviceId, e, index)"
@mousemove.stop="(e) => onCanvasMove(device.deviceId, e, index)">
</canvas>
</div>
<div class="input-info" v-show="selectedDevice == index">
<div class="app-button" @click="getMesList(device.deviceId)">获取当前聊天记录</div>
<!-- <div class="app-button" @click="mqSend()">mq</div> -->
<!-- <div class="app-button"
@click="wsActions.clickCopyList(device.deviceId, index); openShowChat = true; istranslate = true">
翻译本页对话</div>
<div class="app-button" @click="wsActions.clickCopyList(device.deviceId, index); openShowChat = true;">
回复消息</div>
<div class="app-button" @click="wsActions.test(device.deviceId, index)">打印ui节点树</div>
<div class="app-button" @click="wsActions.isOneLive(device.deviceId, index)">判断单人还是双人</div>
<div class="app-button" @click="wsActions.slideDown(device.deviceId, index)">下滑</div>
<div class="app-button" @click="wsActions.killNow(device.deviceId, index)">关闭当前应用</div>
<div class="app-button" @click="chooseFile(device.deviceId, index, 1, wsActions)">安装 APK
文件</div>
<div class="app-button" @click="chooseFile(device.deviceId, index, 2, wsActions)">
传送文件</div> -->
<!-- <div style="display: flex;">
<input style="border: 1px solid #000;margin:0px 14px;" v-model="textContent[index]" type="text"></input>
<div class="app-button" style="margin: 0px;height: 40px;width: 60px;font-size: 14px;"
@click="setComText(index)">发送
</div>
</div> -->
</div>
</div>
</div>
<div class="right center-justify" @click.self="selectedDevice = 999">
<!-- <div style="margin: 30px;"></div> -->
<ChatDialog :visible="openShowChat" :messages="chatList" />
</div>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='""' :title="dialogTitle" :index="selectedDevice"
@confirm="onDialogConfirm" @cancel="stopAll" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" />
</div>
<!-- 定时调度配置弹窗 -->
<el-dialog v-model="showScheduleDlg" title="定时调度(每小时)" width="420px">
<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><el-switch v-model="scheduleEnabled" /></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, onMounted, onUnmounted, onBeforeUnmount, watch, inject } from "vue";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
setsessionId, getsessionId
} from '@/stores/storage'
import { connectSSE } from '@/utils/sseUtils'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { chat, translationToChinese, translation } from "@/api/chat";
import HostListManagerDialog from '@/components/HostListManagerDialog.vue'
import MultiLineInputDialog from '@/components/MultiLineInputDialog.vue'; // 根据实际路径修改
import ChatDialog from '@/components/ChatDialog.vue'
import { pickTikTokBundleId } from '@/utils/arrUtils'
import { logout } from '@/api/account';
import {
getDeviceList,
toHome,
swipeAction,
tapAction,
growAccount,
stopScript,
watchLiveForGrowth,
monitorMessages,
passAnchorData,
addTempAnchorData,
deviceAppList,
launchApp,
getChatTextInfo,
setLoginInfo,
} from '@/api/ios';
const router = useRouter();
const openShowChat = ref([true])
//主播库
const showHostDlg = ref(false)
let hostList = []
//查询列表轮询
let getListtimer = null;
let userdata = getUser();
let chatList = ref([])
let isImg = ref(true)
// 引入刷新方法
const reload = inject("reload")
const reloadImg = () => {
isImg.value = false
setTimeout(() => {
isImg.value = true
}, 1000)
}
//start弹窗
let isMsgPop = ref(false)
//缓存主播联动数组
let stroageHost = ref([])
let runType = ref('')
let isMonitorOn = ref(false)
const hoverIndex = ref(null) //选中
let showDialog = ref(false);//弹窗是否显示
let dialogTitle = ref('');//当前弹窗类型
let deviceInformation = ref([])
// 你可以用这种方式声明按钮们
//联动用作标记
let batchMode = ref('init'); // 'init' | 'follow'(仅作标记)
// 当前是否被其它模式占用(四个互斥按钮专用)
const isLocked = (type) => !!runType.value && runType.value !== type
// 互斥按钮的样式:激活=红,锁定=半透明且禁点
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 }))
},
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: () => {
if (runType.value == 'follow') {
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId })
})
runType.value = ''
return
};
if (isLocked('follow')) return
showDialog.value = true;
dialogTitle.value = '主播ID';
},
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 == 'lisen') {
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId })
})
runType.value = ''
return
};
if (isLocked('lisen')) return
runType.value = 'lisen'
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId }))
},
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('lisen')
},
{
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(),
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: () => {
logout({ userId: userdata.id, tenantId: userdata.tenantId })
router.push('/')
},
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 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(true) // 需要时可手动关闭调度(例如“全部停止”)
// 弹窗
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))
// 若启用则重启轮询
if (scheduleEnabled.value) startScheduleLoop()
showScheduleDlg.value = false
ElMessage.success('已保存定时调度')
}
const selectedDevice = ref(null)
// —— 显示尺寸固定为 320x720未选中缩略为 THUMB_SCALE 倍 ——
const BASE_W = 320
const BASE_H = 720
const THUMB_SCALE = 0.6
// 当前选中的卡片:选中=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
dragState.value[index] = { ox: e.offsetX, oy: e.offsetY, t: Date.now(), udid }
}
const onCanvasMove = (udid, e, index) => {
// 如需在 overlay 上画指示、十字线等,可在这里使用 e.offsetX/e.offsetY
}
const onCanvasUp = async (udid, e, index) => {
const st = dragState.value[index]
if (!st) return
const { ox, oy, t } = st
const dx = e.offsetX - ox
const dy = e.offsetY - oy
const elapsed = Date.now() - t
delete dragState.value[index]
// 映射到真实分辨率
const p0 = mapToDeviceXY(index, ox, oy)
const p1 = mapToDeviceXY(index, e.offsetX, e.offsetY)
console.log(" x, y", p0, p1)
// 判断是“点按”还是“滑动”
const MOVE_THR = 5 // 像素阈值(可按需调整)
const isTap = Math.hypot(dx, dy) < MOVE_THR && elapsed < 500
try {
if (isTap) {
await tapAction({ udid, x: p1.x, y: p1.y })
// console.log('tap', p1)
} else {
// 只需要方向1上/2左/3下/4右
const rotation = Number((deviceInformation.value[index] || {}).rotation || 0)
const code = getSwipeCodeWithRotation(dx, dy, rotation) // 必定得到1~4
console.log("code", code)
await swipeAction({ udid, direction: code })
}
} 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)
}
})
}
function stopAll() {
if (!runType.value) return
deviceInformation.value.forEach((item) => {
stopScript({ udid: item.deviceId }).then((res) => {
ElMessage.success(`停止成功:${item.deviceId}`)
})
})
scheduleEnabled.value = false
runType.value = ''
batchMode.value = 'init';
}
//确认多行文本框内容
function onDialogConfirm(result, type, index, isMon) {
// console.log(type, result, isMon);
if (type == '主播ID') {
hostList = result
dialogTitle.value = '私信';
setTimeout(() => {
showDialog.value = true;
}, 600)
} else if (type == '私信') {
runType.value = 'follow'
passAnchorData(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: hostList.map(id => ({
anchorId: id,
country: ""
})),
prologueList: result,
needReply: isMon
}
)
}
}
onMounted(() => {
const loading = ElLoading.service({
lock: true,
text: 'Loading',
background: 'rgba(0, 0, 0, 0.7)',
});
getDeviceListFun()
window.electronAPI.startMq(userdata.tenantId, userdata.id)
// 初始化时获取设备列表
getListtimer = setInterval(() => {
loading.close();
getDeviceListFun()
}, 3000)
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
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);
if (data === 'start') {
// 新一波开始:根据当前状态决定“本波 flush 用谁”
if (!isMsgPop.value) {
// 还没开始过 -> 首次弹框,确认后使用 passAnchorData 处理本波
isMsgPop.value = true;
batchMode.value = 'init';
ElMessageBox.confirm(
'检测到YOLO助手正在爬取主播是否进行操作',
'消息提醒',
{ confirmButtonText: '开始', cancelButtonText: '取消', type: 'success' }
)
.then(() => {
ElMessage({ type: 'success', message: '任务开启成功' });
runType.value = 'follow';
batchMode.value = 'follow';
// 不在这里立刻提交;让后续主播数据先进 batch再由防抖统一 flush
// 若这一波的主播在点击之前已到达,也已经在 batch 等待
scheduleFlush((items) => {
passAnchorData({
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: items.map(h => ({ anchorId: h.text, country: h.country || '' })),
needReply: false
});
});
})
.catch(() => {
// 取消:清理状态,丢弃批次
batch.length = 0;
isMsgPop.value = false;
});
} else {
// 已经在运行 follow本波用 addTempAnchorData 追加
batchMode.value = 'follow';
// 立刻安排一次“尾随防抖”flush等本波数据齐了再送
scheduleFlush((items) => {
addTempAnchorData(items.map(h => ({ anchorId: h.text, country: h.country || '' })));
});
}
} else {
// 非 start本波主播数据进入批次
const country = data && data.country != null ? data.country : '';
const text = data && (data.hostsId != null ? data.hostsId : data.text);
if (text == null) {
// 数据格式不对,丢弃或打印
console.warn('[SSE] 非法数据,缺少 hostsId/text:', data);
return;
}
batch.push({ country, text });
// 根据当前模式,刷新防抖(让“最后一条到来后”延迟几百毫秒再统一提交)
if (batchMode.value === 'init') {
// 首次确认前:等用户点“开始”后由上面的 scheduleFlush 执行 passAnchorData
scheduleFlush((items) => {
// 安全起见:只有在 runType 已经 follow 时才真正提交
if (runType.value === 'follow') {
passAnchorData({
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: items.map(h => ({ anchorId: h.text, country: h.country || '' })),
needReply: false
});
} else {
// 还没开始就来了数据:把它们留回批次,等待上面 then 里的 scheduleFlush 再处理
batch.push(...items);
}
});
} else {
// 已在关注:走追加逻辑
scheduleFlush((items) => {
addTempAnchorData(items.map(h => ({ anchorId: h.text, country: h.country || '' })));
});
}
}
});
})
onUnmounted(() => {
clearInterval(getListtimer)
getListtimer = null
logout({ userId: userdata.id, tenantId: userdata.tenantId })
})
const getDeviceListFun = () => {
getDeviceList().then((res) => {
// console.log('返回', res.length)
if (res && res.length > 0 && deviceInformation.value.length !== res.length) {
console.log("设备变更")
deviceInformation.value = res
reloadImg()
}
if (res.length == 0) {
deviceInformation.value = []
reloadImg()
}
}).catch((err) => {
ElMessage.error(`IOSAI服务错误`)
})
}
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) {
if (!scheduleEnabled.value) return
console.log('[schedule] 切换到任务:', key)
forceActivate(key, () => {
if (key === 'follow') {
runType.value = 'follow'
// 这三行保持你现有的唤起流程弹“主播ID”输入等
// showDialog.value = true
// dialogTitle.value = '主播ID'
// selectedDevice.value = 999
showDialog.value = true;
dialogTitle.value = '主播ID';
} else if (key === 'like') {
runType.value = 'like'
stopAll()
setTimeout(() => {
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId }))
}, 1000)
} else if (key === 'brushLive') {
stopAll()
setTimeout(() => {
deviceInformation.value.forEach((item) => watchLiveForGrowth({ udid: item.deviceId }))
}, 1000)
runType.value = 'brushLive'
} else if (key === 'listen') {
stopAll()
setTimeout(() => {
runType.value = 'listen'
isMonitorOn.value = true
deviceInformation.value.forEach((item) => monitorMessages({ udid: item.deviceId }))
}, 1000)
}
})
}
function startScheduleLoop() {
// 先按照当前 index 跑一次,保证“即刻对齐”
runTask(schedulePlan[scheduleState.index].key)
// 清理旧轮询,防止重复
if (scheduleTimer) clearInterval(scheduleTimer)
scheduleTimer = setInterval(() => {
if (!scheduleEnabled.value) 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 mqSend() {
window.electronAPI.mqSend("start")
}
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)
}
// //sse接收爬虫发送的消息
// const es = connectSSE(`http://localhost:3311/events`, (data) => {
// // connectSSE(`http://192.168.1.155:19665/api/sse/connect/${userdata.tenantId}/${userdata.id}`, (data) => {
// // 处理服务端推送的数据
// console.log('来自服务端:', data)
// //接收到start
// if (data === 'start') {
// } else {
// }
// })
</script>
<style scoped lang="less">
@import '../static/css/video.less';
</style>