This commit is contained in:
2025-11-18 18:05:29 +08:00
parent a2da6cc21a
commit f0c725449d
8 changed files with 505 additions and 446 deletions

View File

@@ -1,11 +1,11 @@
# iOS 控制服务
# VUE_APP_BASE_LOCAL=http://192.168.1.218:34567/
VUE_APP_BASE_LOCAL=http://127.0.0.1:34567/
# VUE_APP_BASE_LOCAL=http://192.168.1.209:34567/
VUE_APP_BASE_LOCAL=https://192.168.1.231:34567/
# VUE_APP_BASE_LOCAL=https://127.0.0.1:34567/
# VUE_APP_BASE_LOCAL=https://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

@@ -0,0 +1,105 @@
// src/composables/useDevices.js
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
getDeviceList,
stopScript,
deviceAppList,
launchApp,
} from '@/api/ios'
import { pickTikTokBundleId } from '@/utils/arrUtils'
export function useDevices() {
const deviceInformation = ref([]) // 设备列表
const getListTimer = ref(null) // 定时器句柄(方便在外面清理)
// 是否已经提示过 IOSAI 服务错误
let isStartLac = false
// 拉设备列表
const getDeviceListFun = async () => {
try {
const res = await getDeviceList()
if (res && res.length > 0 && deviceInformation.value.length !== res.length) {
console.log('设备变更')
deviceInformation.value = res
}
if (!res || res.length === 0) {
deviceInformation.value = []
}
} catch (err) {
if (isStartLac) {
ElMessage.error('IOSAI 服务错误')
} else {
// 第一次忽略,等下次再报(保持你原来的行为)
isStartLac = true
}
}
}
// 打开 Tiktok
const openTk = async () => {
if (!deviceInformation.value?.length) {
ElMessage.warning('暂无在线设备')
return
}
const { ElLoading } = await import('element-plus')
const loading = ElLoading.service({
text: '正在打开 TikTok …',
background: 'rgba(0,0,0,.35)'
})
const results = []
try {
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}`)
}
}
// 停止单台任务
const stopOne = async (deviceId) => {
try {
await stopScript({ udid: deviceId })
// 你原来这里没有提示,我也保持不弹
// ElMessage.success('停止成功')
} catch (e) {
console.error('stopOne error', e)
}
}
return {
deviceInformation,
getDeviceListFun,
getListTimer,
openTk,
stopOne,
}
}

View File

View File

View File

View File

@@ -0,0 +1,255 @@
// src/composables/useScreenStreams.js
import { ref, reactive, computed, watch } from 'vue'
import { tapAction, swipeAction } from '@/api/ios'
const BASE_W = 320
const BASE_H = 720
const THUMB_SCALE = 0.6
const PER_ROW = 3
const BOTTOM_SHIFT = Math.round(BASE_H * (1 - THUMB_SCALE)) // 288
export function useScreenStreams(deviceInformation) {
// 选中的设备索引
const selectedDevice = ref(null)
// 每台设备的 <img> 引用
const imgRefs = ref({}) // { [id]: HTMLImageElement }
// 每台设备当前展示的 URL
const imgSrcMap = reactive({}) // { [deviceId]: string }
// 每台设备循环状态
const loops = new Map() // deviceId -> { timer, stopped }
const makeUrl = (port) => `http://192.168.1.231:${port}/?t=${Date.now()}`
// const makeUrl = (port) => `http://localhost:${port}/?t=${Date.now()}`
function hardCloseImg(deviceId) {
const el = imgRefs.value[deviceId]
if (el) {
el.src = ''
el.removeAttribute('src')
}
imgSrcMap[deviceId] = ''
}
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)
}
function startLoop(dev, idx = 0) {
stopLoop(dev.deviceId)
const state = { timer: null, stopped: false }
loops.set(dev.deviceId, state)
const tick = () => {
if (state.stopped) return
const url = makeUrl(dev.screenPort)
imgSrcMap[dev.deviceId] = url
state.timer = window.setTimeout(tick, 3000)
}
state.timer = window.setTimeout(tick, idx * 200)
}
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)
})
}
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)
})
}
function refreshAllStopImgs() {
Object.keys(imgRefs.value).forEach(id => hardCloseImg(id))
}
// 设备变动时自动对齐循环
watch(deviceInformation, (list) => {
reconcileLoopsByDevices(list || [])
}, { deep: true })
// ——— 选中 + 缩放/上移样式 ———
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
}
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)' }
}
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 }
}
const selectDevice = (index) => {
selectedDevice.value = index
}
// ——— 坐标映射 + 鼠标交互 ———
const dragState = ref({}) // index -> { ox, oy, t, udid, ... }
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)
const { w: dispW, h: dispH } = displaySize(index)
let nx = Math.min(Math.max(offsetX / dispW, 0), 1)
let ny = Math.min(Math.max(offsetY / dispH, 0), 1)
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:
x = Math.round(nx * realW)
y = Math.round(ny * realH)
}
return { x, y }
}
const onCanvasDown = (udid, e, index) => {
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)
// 调试需要再打印
}
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]
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,
})
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)
}
}
return {
selectedDevice,
imgRefs,
imgSrcMap,
refreshAllImgs,
refreshOneImg,
refreshAllStopImgs,
getCanvasStyle,
imgWH,
selectDevice,
onCanvasDown,
onCanvasMove,
onCanvasUp,
}
}

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('2.9.1');
let version = ref('3.0.0');
onMounted(() => {

View File

@@ -19,9 +19,16 @@
<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>
<!-- 监听 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>
@@ -125,13 +132,13 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, onBeforeUnmount, watch, inject, computed, nextTick } from "vue";
import { ref, reactive, onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
getHostList, setHostList, getContentpriList,
setContentpriList, getContentList, setContentList,
setsessionId, getsessionId, getContentListMultiline, getContentpriListMultiline
getContentListMultiline, getContentpriListMultiline
} from '@/stores/storage'
import { connectSSE } from '@/utils/sseUtils'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
@@ -144,13 +151,9 @@ 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, health } from '@/api/account';
import {
getDeviceList,
toHome,
swipeAction,
tapAction,
growAccount,
stopScript,
watchLiveForGrowth,
@@ -158,13 +161,10 @@ import {
passAnchorData,
followAndGreetUnion,
addTempAnchorData,
deviceAppList,
launchApp,
getChatTextInfo,
setLoginInfo,
aiConfig,
selectLast,
updatelast,
changeAccount,
stopAllTask,
anchorList,
@@ -172,7 +172,10 @@ import {
getDeviceNetStatus
} from '@/api/ios';
import ding from '@/assets/mes.wav'
import { set } from "lodash";
//引入两个分包方法
import { useDevices } from '@/composables/useDevices'
import { useScreenStreams } from '@/composables/useScreenStreams'
const router = useRouter();
const openShowChat = ref(true)
//主播库
@@ -204,17 +207,12 @@ let flushTimer = null;
let hostList = [] // 主播列表
let comonList = [] //评论列表
//查询 列表 新消息轮询
let getListtimer = null;
let getNetworkListtimer = null;
let userdata = getUser();
let chatList = ref([])
let MesNewList = ref([])
// 刷新方法
const reloadImg = () => {
refreshAllImgs()
}
//start弹窗
let isMsgPop = ref(false)
@@ -223,7 +221,6 @@ let isMonitorOn = ref(false)
const hoverIndex = ref(null) //选中
let showDialog = ref(false);//弹窗是否显示
let dialogTitle = ref('');//当前弹窗类型
let deviceInformation = ref([])
// 你可以用这种方式声明按钮们
//停止中
let stopLoading = null
@@ -232,6 +229,31 @@ let stopLoading = null
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 }
@@ -257,13 +279,21 @@ const onlineOnly = (ids) => ids.filter(isOnline)
// 当前是否被其它模式占用(四个互斥按钮专用)
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
// —— 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)
@@ -284,7 +314,7 @@ const ctrlStyle = (type) => {
const buttons = [
{
label: '刷新',
onClick: () => reloadImg(),
onClick: () => refreshAllImgs(),
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
@@ -528,363 +558,6 @@ function saveSchedule() {
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()}`;
// const makeUrl = (port) => `http://192.168.1.209:${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) {
@@ -1029,7 +702,7 @@ onMounted(async () => {
// 初始化时获取设备列表
//每3秒获取一次设备列表 消息列表 和 查询主播列表是否还有主播
getListtimer = setInterval(async () => {
getListTimer.value = setInterval(async () => {
getDeviceListFun() //获取设备列表
selectLastFun() //获取新消息
const hostsList = await getStoredHostList() //获取主播列表
@@ -1061,8 +734,8 @@ onMounted(async () => {
}).catch((err) => {
if (err.code === 40400) {
//关闭获取设备列表的方法
clearInterval(getListtimer)
getListtimer = null
clearInterval(getListTimer.value)
getListTimer.value = null
//关闭获取网络状态的方法
clearInterval(getNetworkListtimer)
getNetworkListtimer = null
@@ -1070,13 +743,12 @@ onMounted(async () => {
})
}, 1000 * 60 * 3)
}, 1000 * 90)
if (!await isAiConfig()) {
showMyInfo.value = true
}
reconcileLoopsByDevices(deviceInformation.value || []);
function scheduleFlush(handler, delay = 400) {
if (flushTimer) clearTimeout(flushTimer);
@@ -1096,10 +768,8 @@ onMounted(async () => {
// —— SSE 接收 ——
const es = connectSSE('http://localhost:3312/events', (data) => {
// console.log('来自服务端:', data);
// console.log(1)
//总开关
if (!sseEnabled.value) return
// 所有 SSE 关掉的话,直接不处理
if (!sseAnyEnabled.value) return
if (data === 'start') {
// 新一波开始:清空上一波的缓冲,重置防抖
@@ -1107,15 +777,49 @@ onMounted(async () => {
return
}
// 非 start正常入缓冲 → 防抖批量 addTempAnchorData
const country = data && data.country != null ? data.country : data.region
const text = data && (data.hostsId != null ? data.hostsId : data.displayId)
const invitationType = data && (data.invitationType != null ? data.invitationType : '')
const id = data && data.id != null ? data.id : ''
// 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
// console.log(text)
batch.push({ country, text, invitationType, id })
//400ms内如果没有数据了 进行入库如果400ms内进入了数据 重置计时器 一波一波入库
scheduleFlush((items) => {
// 批量入库
const list = items.map(h => ({
@@ -1133,39 +837,24 @@ onMounted(async () => {
})
})
onUnmounted(() => {
//关闭获取设备列表的方法
clearInterval(getListtimer)
getListtimer = null
clearInterval(getListTimer.value)
getListTimer.value = null
//关闭获取网络状态的方法
clearInterval(getNetworkListtimer)
getNetworkListtimer = null
//关闭调度定时器
if (scheduleTimer) {
clearInterval(scheduleTimer)
scheduleTimer = 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 = () => {
@@ -1285,6 +974,7 @@ function runTask(key, deviceId, type) {
//第一个小时结束后,第二轮开始的时候,直接进入follow
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'follow'
//过滤无网络设备
const allIds = deviceInformation.value.map(item => item.deviceId)
@@ -1349,17 +1039,29 @@ function runTask(key, deviceId, type) {
} else if (key === 'like') {
if (!deviceId) {
await stopAll(2000)
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
}
setTimeout(() => {
scheduleEnabled.value = true
runType.value = 'like'
deviceInformation.value.forEach((item) => growAccount({ udid: item.deviceId, comment: getContentList(), isComment: common.value }))
}, 1000)
} else if (key === 'brushLive') {
if (!deviceId) {
@@ -1394,10 +1096,6 @@ function runTask(key, deviceId, type) {
})
}
async function stopCurrentMode() {
// 如果你希望“只停当前片段的设备”,也可以用 stopScript 针对设备循环
await stopAll(2000)
}
/** 恢复:回到 scheduleState.index 对应片段,并让 startTime 回到“暂停前进度” */
function resumeAfterInterrupt() {
@@ -1629,20 +1327,14 @@ async function doLogout() {
dropCurrentWave()
// es?.close?.() // 如果 connectSSE 返回 EventSource调用 close
refreshAllStopImgs() // 你已有:把所有 <img> src 清空
clearInterval(getListtimer)
getListtimer = null
clearInterval(getListTimer.value)
getListTimer.value = null
await logout({ userId: userdata.id, tenantId: userdata.tenantId })
} finally {
router.push('/')
}
}
function stopOne(deviceId) {
stopScript({ udid: deviceId }).then((res) => {
// ElMessage.success('停止成功');
})
}
//查看主播库主播信息
async function getStoredHostList() {
@@ -1795,7 +1487,7 @@ async function refreshNetStatus() {
const res = await getDeviceNetStatus({ udid: d.deviceId })
firstPass[d.deviceId] = (res === true)
} catch (err) {
firstPass[d.deviceId] = false
firstPass[d.deviceId] = true
}
// 不是最后一台才等待 5s
@@ -1888,7 +1580,7 @@ async function safeGetStatus(udid) {
try {
return await getDeviceNetStatus({ udid }) === true
} catch {
return false
return true
}
}
@@ -1918,6 +1610,13 @@ const markPendingForOffline = (ids) => {
}
});
};
const onToggleCrawler = (val) => {
if (val) sseBossEnabled.value = false
}
const onToggleBoss = (val) => {
if (val) sseCrawlerEnabled.value = false
}
</script>
<style scoped lang="less">