Compare commits

...

10 Commits

Author SHA1 Message Date
efee6eef2a 联动,定时任务 2025-09-08 20:52:31 +08:00
1666d9aa14 提升稳定性 2025-09-01 14:42:04 +08:00
3a7c5556fd 退出清理 2025-08-26 13:03:08 +08:00
a8889e1ce6 分包 触发屏幕事件 2025-08-22 16:59:41 +08:00
5bfb9027b6 分包+主播库 2025-08-22 16:35:32 +08:00
f7c04c88d4 按钮互斥 2025-08-14 20:36:42 +08:00
b56ab2dfd8 分包+刷新逻辑 2025-08-14 19:58:49 +08:00
1f6fd80f48 Merge branch 'master' of http://49.235.115.212:3000/mfx/tkAiPage 2025-08-14 19:54:45 +08:00
35c6422ab5 分包+刷新逻辑 2025-08-14 19:52:34 +08:00
df5b848f08 版本回退 2025-08-13 15:27:15 +08:00
25 changed files with 2150 additions and 627 deletions

33
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/util": "^0.12.2",
"@vueuse/core": "^13.1.0",
"amqplib": "^0.10.9",
"axios": "^1.8.4",
"core-js": "^3.8.3",
"echarts": "^5.6.0",
@@ -4466,6 +4467,18 @@
"ajv": "^6.9.1"
}
},
"node_modules/amqplib": {
"version": "0.10.9",
"resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz",
"integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==",
"dependencies": {
"buffer-more-ints": "~1.0.0",
"url-parse": "~1.5.10"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ansi-escapes": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz",
@@ -4974,6 +4987,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/buffer-more-ints": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
"integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -11882,6 +11900,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -12153,7 +12176,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/resolve": {
@@ -13466,6 +13488,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -10,6 +10,7 @@
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/util": "^0.12.2",
"@vueuse/core": "^13.1.0",
"amqplib": "^0.10.9",
"axios": "^1.8.4",
"core-js": "^3.8.3",
"echarts": "^5.6.0",

View File

@@ -6,6 +6,9 @@ export function apiGetCart() {
export function login(data) {
return postAxios({ url: '/api/user/aiChat-doLogin', data })
}
export function logout(data) {
return postAxios({ url: '/api/user/aiChat-logout', data })
}
export function getIdByName(name) {
return getAxios({ url: `/api/tenant/get-id-by-name?name=${name}` })
@@ -63,3 +66,4 @@ export function prologue() {
export function comment() {
return getAxios({ url: 'api/common/comment' })
}

View File

@@ -0,0 +1,381 @@
<template>
<el-dialog v-model="show" width="70vw" :close-on-click-modal="false" :destroy-on-close="true" @open="onOpen">
<template #header>
<div class="dlg-title">
<span>主播管理</span>
<span class="muted">已选 {{ selectedCount }} / {{ hosts.length }}</span>
</div>
</template>
<!-- 工具栏 -->
<div class="toolbar">
<el-button size="small" @click="selectAll">全选</el-button>
<el-button size="small" @click="selectNone">全不选</el-button>
<el-button size="small" @click="invertSelect">反选</el-button>
<el-button size="small" type="danger" :disabled="!selectedCount" @click="deleteSelected">删除选中</el-button>
<!-- 新增一键删除已处理 -->
<el-button size="small" type="warning" :disabled="!processedCount" @click="deleteProcessed">
删除已处理
</el-button>
<el-button size="small" type="primary" @click="saveAll">保存</el-button>
<el-tooltip placement="bottom" effect="dark">
<template #content>
在空白区域按下左键拖拽进行框选<br />
</template>
<el-icon class="hint">i</el-icon>
</el-tooltip>
</div>
<!-- 列表区域 -->
<div ref="gridRef" class="grid" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp"
@mouseleave="onMouseUp" @scroll="recalcRectsSoon">
<div v-for="it in hosts" :key="it.text" class="item-card" :class="{ selected: isSelected(it.text) }"
:ref="el => setCardRef(it.text, el)" @click.stop="toggleSelect(it.text)">
<div class="row top">
<span class="id" :title="it.text">{{ it.text }}</span>
<button class="x" title="删除此项" @click.stop="deleteOne(it.text)">×</button>
</div>
<div class="row meta">
<span class="country" :title="it.country">{{ it.country || '—' }}</span>
<span class="state" :class="{ done: !!it.state }">{{ it.state ? '已处理' : '未处理' }}</span>
</div>
</div>
<!-- 框选矩形 -->
<div v-if="selecting" class="selection-rect" :style="selectionStyle"></div>
</div>
<template #footer>
<div class="foot">
<el-button @click="show = false">关闭</el-button>
<el-button type="primary" @click="saveAll">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getHostList, setHostList } from '@/stores/storage'
// v-model:visible 接口
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'save'])
const show = computed({
get: () => props.visible,
set: (v) => emit('update:visible', v)
})
// 数据
const hosts = ref([]) // {country, text, state}
const selected = reactive(new Set()) // 选中的 text 集合
// 卡片 DOM 引用与位置缓存
const gridRef = ref(null)
const cardRefs = reactive({}) // text -> el
const rectCache = reactive({}) // text -> DOMRect
let rectRecalcTimer = null
const processedCount = computed(() => hosts.value.filter(it => !!it?.state).length)
function setCardRef(key, el) {
if (el) {
cardRefs[key] = el
} else {
delete cardRefs[key]
delete rectCache[key]
}
}
function getStoredHostList() {
const v = getHostList()
return Array.isArray(v) ? v : []
}
async function onOpen() {
hosts.value = getStoredHostList()
selected.clear()
await nextTick()
recalcRects()
}
function saveAll() {
setHostList(hosts.value)
ElMessage.success('已保存')
emit('save', hosts.value)
}
// 选择相关
const selectedCount = computed(() => selected.size)
function isSelected(id) { return selected.has(id) }
function toggleSelect(id) {
if (selected.has(id)) selected.delete(id)
else selected.add(id)
}
function selectAll() { selected.clear(); hosts.value.forEach(it => selected.add(it.text)) }
function selectNone() { selected.clear() }
function invertSelect() {
const next = new Set()
hosts.value.forEach(it => { if (!selected.has(it.text)) next.add(it.text) })
selected.clear(); next.forEach(id => selected.add(id))
}
function deleteSelected() {
if (!selected.size) return
ElMessageBox.confirm(`确认删除选中的 ${selected.size} 项吗?`, '提示', { type: 'warning' })
.then(() => {
const keep = []
for (const it of hosts.value) if (!selected.has(it.text)) keep.push(it)
hosts.value = keep
selected.clear()
setHostList(keep)
recalcRectsSoon()
ElMessage.success('已删除选中')
})
.catch(() => { })
}
function deleteOne(id) {
const idx = hosts.value.findIndex(it => it.text === id)
if (idx !== -1) {
hosts.value.splice(idx, 1)
selected.delete(id)
// setHostList(hosts.value)
recalcRectsSoon()
}
}
// —— 框选逻辑 ——
const selecting = ref(false)
const anchor = ref({ x: 0, y: 0 })
const cursor = ref({ x: 0, y: 0 })
const baseSelection = ref(new Set()) // 框选开始时的已有选择(支持累加)
const selectionStyle = computed(() => {
const root = gridRef.value
if (!root) return {}
// 使用容器坐标系定位矩形
const box = root.getBoundingClientRect()
const x1 = Math.min(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
const y1 = Math.min(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
const x2 = Math.max(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
const y2 = Math.max(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
return { left: x1 + 'px', top: y1 + 'px', width: (x2 - x1) + 'px', height: (y2 - y1) + 'px' }
})
function onMouseDown(e) {
if (e.button !== 0) return
const root = gridRef.value
if (!root) return
// 只在空白处拖拽,或按着 Alt 任意处拖拽
const onItem = e.target && e.target.closest && e.target.closest('.item-card')
if (onItem && !e.altKey) return
// 记录锚点client 坐标,方便和 DOMRect 比较)
anchor.value = { x: e.clientX, y: e.clientY }
cursor.value = { x: e.clientX, y: e.clientY }
selecting.value = true
// 是否保留原选择
baseSelection.value = (e.ctrlKey || e.metaKey) ? new Set(Array.from(selected)) : new Set()
// 防止选中文本
e.preventDefault()
}
function onMouseMove(e) {
if (!selecting.value) return
cursor.value = { x: e.clientX, y: e.clientY }
updateSelectionByRect()
}
function onMouseUp() {
if (!selecting.value) return
selecting.value = false
}
function updateSelectionByRect() {
const x1 = Math.min(anchor.value.x, cursor.value.x)
const y1 = Math.min(anchor.value.y, cursor.value.y)
const x2 = Math.max(anchor.value.x, cursor.value.x)
const y2 = Math.max(anchor.value.y, cursor.value.y)
// 实时选择集合
const current = new Set(baseSelection.value)
for (const it of hosts.value) {
const r = rectCache[it.text]
if (!r) continue
const hit = !(r.left > x2 || r.right < x1 || r.top > y2 || r.bottom < y1)
if (hit) current.add(it.text)
}
// 覆盖 selected
selected.clear(); current.forEach(id => selected.add(id))
}
function recalcRects() {
for (const [key, el] of Object.entries(cardRefs)) {
try { rectCache[key] = el.getBoundingClientRect() } catch { }
}
}
function recalcRectsSoon() {
clearTimeout(rectRecalcTimer)
rectRecalcTimer = setTimeout(() => nextTick().then(recalcRects), 16)
}
function deleteProcessed() {
if (!processedCount.value) return
ElMessageBox.confirm(
`确认删除所有“已处理”项(${processedCount.value} 个)吗?此操作不可撤销。`,
'提示',
{ type: 'warning' }
)
.then(() => {
// 仅保留未处理
const keep = hosts.value.filter(it => !it?.state)
hosts.value = keep
// 清理已不存在的选中项
for (const id of Array.from(selected)) {
if (!keep.find(it => it.text === id)) selected.delete(id)
}
setHostList(keep) // 同步回缓存
recalcRectsSoon() // 重新计算卡片矩形,保证框选正常
ElMessage.success('已删除已处理项')
})
.catch(() => { })
}
watch(hosts, () => nextTick().then(recalcRects))
</script>
<style scoped>
.dlg-title {
display: flex;
gap: 8px;
align-items: baseline;
}
.dlg-title .muted {
color: #909399;
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.toolbar .hint {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid #bbb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #666;
}
.grid {
position: relative;
height: 60vh;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
padding: 6px;
user-select: none;
border: 1px solid var(--el-border-color);
border-radius: 8px;
}
.item-card {
border: 1px solid var(--el-border-color);
border-radius: 10px;
padding: 8px 10px;
background: var(--el-bg-color);
cursor: pointer;
transition: box-shadow .15s, border-color .15s;
}
.item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
}
.item-card.selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
}
.item-card .row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.item-card .row.top {
margin-bottom: 4px;
}
.item-card .id {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 75%;
}
.item-card .x {
border: none;
background: transparent;
color: #f56c6c;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.item-card .x:hover {
color: #f33;
}
.item-card .meta {
color: #666;
font-size: 12px;
}
.item-card .state {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid #ddd;
}
.item-card .state.done {
color: #67c23a;
border-color: #67c23a;
}
.selection-rect {
position: absolute;
pointer-events: none;
border: 1px dashed var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 12%, transparent);
z-index: 10;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="center-line">
<div v-for="(btn, i) in buttons" :key="i" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button"
:class="[{ active: btn.key && activeKey === btn.key, disabled: btn.key && isLocked && activeKey !== btn.key }]"
:style="btn.style ? btn.style() : {}" @click="$emit('click', btn)" @mouseenter="hoverIndex = i"
@mouseleave="hoverIndex = null">
<img :src="hoverIndex === i ? btn.img.hover : btn.img.normal" alt="">
{{ btn.label }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
buttons: { type: Array, required: true },
activeKey: { type: String, default: '' },
isLocked: { type: Boolean, default: false }
})
defineEmits(['click'])
const hoverIndex = ref(null)
</script>
<!-- 注意不要 scoped直接引入全局样式避免父组件 scoped 导致样式穿透问题 -->
<style scoped lang="less">
.center-line {
display: flex;
flex-direction: column;
align-items: center;
// justify-content: center;
}
.left-button {
cursor: default;
position: relative;
z-index: 999;
margin-bottom: 19px;
width: 100%;
height: 72px;
// background: #32C9CD;
border-radius: 10px;
font-family: Source Han Sans SC;
font-weight: 800;
font-size: 20px;
color: #7A8B97;
line-height: 16px;
align-items: center;
display: flex;
align-items: center;
img {
width: 30px;
margin: 20px;
}
}
.left-button:hover {
background: #32C9CD;
color: #F9FAFE;
}
/* 鼠标按下时效果 */
.left-button:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translateY(1px);
}
.left-button.active {
background-color: red;
}
/* 互斥期间的其它三枚按钮置灰禁点 */
.left-button.disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(0.4);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<el-dialog v-model="visible" title="分钟级轮换调度">
<div class="grid grid-cols-2 gap-3 items-center">
<div>阶段 A停留</div>
<div class="flex items-center gap-2">
<el-input-number v-model="aMin" :min="1" :max="59" />
<span>分钟</span>
</div>
<div>阶段 B执行/切换</div>
<div class="flex items-center gap-2">
<el-input-number v-model="bMin" :min="1" :max="59" />
<span>分钟</span>
</div>
<div>总时长</div>
<div><b>{{ aMin + bMin }}</b> 分钟必须等于 60</div>
<div>启用调度</div>
<div><el-switch v-model="enabled" /></div>
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" @click="onSave">保存并生效</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true }, // { aMin, bMin, enabled }
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'update:visible', 'save'])
const aMin = computed({
get: () => props.modelValue.aMin,
set: (v) => emit('update:modelValue', { ...props.modelValue, aMin: v })
})
const bMin = computed({
get: () => props.modelValue.bMin,
set: (v) => emit('update:modelValue', { ...props.modelValue, bMin: v })
})
const enabled = computed({
get: () => props.modelValue.enabled,
set: (v) => emit('update:modelValue', { ...props.modelValue, enabled: v })
})
function onSave() {
emit('save', { ...props.modelValue })
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,78 @@
// src/composables/useCanvasPointer.js
import { ref } from "vue";
/**
* @param {{ phone, toBuffer, getWs: (index:number)=>WebSocket|null }} deps
* 依赖项对象包含手机信息、缓冲转换和WebSocket获取函数
*/
export function useCanvasPointer(deps) {
const { phone, toBuffer, getWs } = deps;
const canvasRef = ref({}); // { [udid]: HTMLCanvasElement } - 存储设备ID到Canvas元素的映射
const frameMeta = ref({}); // { [udid]: { w,h, rotation? } } - 存储设备ID到帧元数据的映射
/**
* 初始化画布
* @param {string} udid - 设备唯一标识符
*/
function initCanvas(udid) {
const canvas = canvasRef.value[udid];
if (!canvas) return;
const dpr = window.devicePixelRatio || 1; // 获取设备像素比
// 设置画布样式尺寸
canvas.style.width = `${phone.value.width * 1.4}px`;
canvas.style.height = `${phone.value.height * 1.4}px`;
// 设置画布实际像素尺寸
canvas.width = phone.value.width * 1.4 * dpr;
canvas.height = phone.value.height * 1.4 * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
// 可选:参考网格(已设为透明)
ctx.strokeStyle = "#ffffff00";
for (let x = 0; x <= phone.value.width; x += 100) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, phone.value.height);
ctx.stroke();
}
}
function getCanvasCoordinate(event, udid) {
const canvas = canvasRef.value[udid];
const rect = canvas.getBoundingClientRect();
const rx = (event.clientX - rect.left) / rect.width;
const ry = (event.clientY - rect.top) / rect.height;
const meta = frameMeta.value[udid] || { w: 320, h: 720, rotation: 0 };
let x = rx * meta.w;
let y = ry * meta.h;
switch (meta.rotation ?? 0) {
case 90: [x, y] = [meta.w - y, x]; break;
case 180: [x, y] = [meta.w - x, meta.h - y]; break;
case 270: [x, y] = [y, meta.h - x]; break;
}
x = Math.max(0, Math.min(meta.w - 1, x));
y = Math.max(0, Math.min(meta.h - 1, y));
return { x: Math.round(x), y: Math.round(y), w: meta.w, h: meta.h };
}
// 统一发包point 用帧坐标screenSize 用帧宽高
function sendPointer(udid, index, action /* 0 down,1 up,2 move */, x, y) {
const meta = frameMeta.value[udid] || { w: 320, h: 720, rotation: 0 };
const payload = {
type: 2,
action,
pointerId: 0,
position: { point: { x, y }, screenSize: { width: meta.w, height: meta.h } },
pressure: action === 1 ? 0 : 1,
buttons: action === 1 ? 0 : 1,
};
const ws = getWs(index);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(toBuffer(payload));
}
}
return { canvasRef, frameMeta, initCanvas, getCanvasCoordinate, sendPointer };
}

View File

@@ -0,0 +1,103 @@
// src/composables/useDeviceDiscovery.js
import { nextTick } from 'vue'
/**
* 负责连接 multiplex WebSocket维护 deviceInformation 列表,
* 并在设备出现时按顺序调用 initCanvas / initVideoStream / getSize。
* 获取设备的ws
*/
export function useDeviceDiscovery({
deviceInformation, // ref([])
initCanvas, // (udid) => void
initVideoStream, // (udid, index) => void
wsActionsRef, // () => wsActions (可能一开始是 null)
td, // 你的 useTeardown 实例,可选
}) {
const decoder = new TextDecoder('utf-8')
function open(url, eitwoBuffer) {
const ws = new WebSocket(url)
td && td.setMultiplexWS && td.setMultiplexWS(ws) // 交给 teardown 托管
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
ws.send(eitwoBuffer) // 请求设备列表
}
ws.onmessage = async (event) => {
let data
try {
// 兼容原逻辑:先解码再清理不可见字符再 JSON 解析
data = JSON.parse(decoder.decode(event.data).replace(/[^\x20-\x7F]/g, ''))
} catch (e) {
console.error('[multiplex] parse error:', e)
return
}
if (data.type === 'devicelist') {
// 全量刷新
deviceInformation.value = []
const list = (data.data.list || []).filter(it => it.state === 'device')
for (let i = 0; i < list.length; i++) {
const item = list[i]
deviceInformation.value.push(item)
await nextTick() // 等 v-for 渲染出 <video>
initCanvas(item.udid)
initVideoStream(item.udid, i)
// 延迟拉尺寸(和你原逻辑一致)
setTimeout(() => {
const wsActions = wsActionsRef && wsActionsRef()
wsActions && wsActions.getSize && wsActions.getSize(item.udid, i)
}, 2000)
}
}
else if (data.type === 'device') {
const d = data.data.device
if (d.state === 'offline') {
// 移除离线设备
const idx = deviceInformation.value.findIndex(x => x.udid === d.udid)
if (idx !== -1) {
deviceInformation.value.splice(idx, 1)
}
}
else if (d.state === 'device') {
// 新设备上线
const exists = deviceInformation.value.some(x => x.udid === d.udid)
if (!exists) {
deviceInformation.value.push(d)
// 稍等一会再初始化,保持与原代码行为一致
setTimeout(async () => {
try {
const i = deviceInformation.value.length - 1
await nextTick()
initCanvas(d.udid)
initVideoStream(d.udid, i)
} catch (e) {
console.warn('[multiplex] online init failed:', e)
}
}, 1000)
}
}
}
}
ws.onerror = (e) => {
console.error('[multiplex] ws error:', e)
}
ws.onclose = () => {
// 可按需在这里做重连;现在保持与原逻辑一致不做自动重连
}
return ws
}
return { open }
}

View File

@@ -0,0 +1,121 @@
// src/composables/useMonitor.js
import { ref } from 'vue'
/**
* 设备消息监测(含“有消息时暂停,此设备私信发送成功后恢复”)
* - 每 10s 轮询 getmesNum但跳过 paused 的设备
* - 暴露 pause/resume 方法,供外部在 onmessage 分支中调用
* - 提供 openMonitor()/closeMonitor()/stop() 以适配你现有按钮逻辑
*
* @param {object} options
* - deviceInformation: ref([]) 设备列表
* - runType: ref('') 当前运行模式
* - isStop: ref(false) 全局停止标志
* - isMonitorOn: ref(false) 监测开关UI 显示用)
* - isShowMes: ref() 定时器引用(外面已有)
* - wsActionsRef: () => wsActions 一个函数,返回你动态创建的 wsActions 对象
* - onKickOnce?: (udid, index) => void 在恢复后,立刻补一次检测的 hook默认调用 getmesNum
*/
export function useMonitor({
deviceInformation,
runType,
isStop,
isMonitorOn,
isShowMes,
wsActionsRef,
onKickOnce
}) {
// 用 UDID 做键,避免 index 变化错位
const pausedDevices = new Set();
const isPausedByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
return udid ? pausedDevices.has(udid) : false;
};
const pauseMonitorByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
if (udid) pausedDevices.add(udid);
};
const resumeMonitorByIndex = (index) => {
const udid = deviceInformation.value[index]?.udid;
if (udid) pausedDevices.delete(udid);
};
const clearPaused = () => pausedDevices.clear();
const openMonitor = () => {
isStop.value = false;
// 立即跑一轮
const wsActions = wsActionsRef?.();
deviceInformation.value.forEach((device, index) => {
if (!isPausedByIndex(index)) {
wsActions?.getmesNum(device.udid, index);
}
});
runType.value = 'listen';
isMonitorOn.value = true;
// 每 10s 轮询未暂停设备
isShowMes.value = setInterval(() => {
const ws = wsActionsRef?.();
deviceInformation.value.forEach((device, index) => {
if (!isPausedByIndex(index)) {
ws?.getmesNum(device.udid, index);
}
});
}, 10000);
};
const closeMonitor = () => {
isMonitorOn.value = false;
runType.value = '';
if (isShowMes.value) {
clearInterval(isShowMes.value);
isShowMes.value = '';
}
clearPaused();
};
// 兼容你原来的 stop() 会顺手关监测
const stopAll = () => {
closeMonitor();
clearPaused();
isStop.value = true;
};
// 用于“私信成功后立刻恢复并补一次检测”
const resumeAndKick = (index) => {
resumeMonitorByIndex(index);
setTimeout(() => {
const udid = deviceInformation.value[index]?.udid;
if (!udid) return;
if (typeof onKickOnce === 'function') {
onKickOnce(udid, index);
} else {
const ws = wsActionsRef?.();
ws?.getmesNum(udid, index);
}
}, 1500);
};
return {
// state
pausedDevices,
// helpers
isPausedByIndex,
pauseMonitorByIndex,
resumeMonitorByIndex,
clearPaused,
// controls
openMonitor,
closeMonitor,
stopAll,
resumeAndKick,
}
}

View File

@@ -0,0 +1,45 @@
import { ref } from "vue"
import { ElMessage } from "element-plus"
import { tryActivate } from "./useTaskControl"
export function useSchedule(runTaskFn) {
const scheduleEnabled = ref(true)
let schedulePlan = [
{ key: "follow", duration: 40 * 60 * 1000 },
{ key: "like", duration: 20 * 60 * 1000 }
]
let scheduleState = { index: 0, startTime: Date.now() }
let scheduleTimer = null
function runTask(key) {
if (!scheduleEnabled.value) return
runTaskFn(key) // 交给外部实现 follow/like/brushLive 等
}
function startScheduleLoop() {
runTask(schedulePlan[scheduleState.index].key)
if (scheduleTimer) clearInterval(scheduleTimer)
scheduleTimer = setInterval(() => {
const now = Date.now()
const cur = schedulePlan[scheduleState.index]
if (now - scheduleState.startTime >= cur.duration) {
scheduleState.index = (scheduleState.index + 1) % schedulePlan.length
scheduleState.startTime = now
runTask(schedulePlan[scheduleState.index].key)
}
}, 30 * 1000)
}
function stopSchedule() {
scheduleEnabled.value = false
if (scheduleTimer) clearInterval(scheduleTimer)
}
return {
scheduleEnabled,
schedulePlan,
scheduleState,
startScheduleLoop,
stopSchedule
}
}

View File

@@ -0,0 +1,82 @@
// composables/useStreams.js
import { nextTick } from 'vue'
export function useStreams({ instanceList, videoElement, wslist, openStr, VideoConverter }) {
// 用 Map 做延迟队列,避免固定 8 个的限制
const feedState = new Map()
function ensureState(index) {
if (!feedState.has(index)) {
feedState.set(index, { processing: false, pending: null })
}
return feedState.get(index)
}
function pushFrame(index, buf) {
const st = ensureState(index)
if (st.processing) {
// 覆盖旧的等待帧,保留最新
st.pending = buf
return
}
st.processing = true
try {
instanceList[index].converter.appendRawData(new Uint8Array(buf))
} finally {
st.processing = false
if (st.pending) {
const next = st.pending
st.pending = null
queueMicrotask(() => pushFrame(index, next))
}
}
}
function resetFeedState(index) {
const st = feedState.get(index)
if (!st) return
st.processing = false
st.pending = null
}
async function waitForVideoEl(udid, tries = 20, delay = 16) {
for (let i = 0; i < tries; i++) {
const el = videoElement.value?.[udid]
if (el) return el
await nextTick()
await new Promise(r => setTimeout(r, delay))
}
return null
}
function refreshStream(index, hard = true) {
const devices = Object.keys(videoElement.value || {})
const udid = devices[index]
const video = videoElement.value?.[udid]
if (!video || !instanceList[index]) return
resetFeedState(index)
try { instanceList[index].converter?.destroy?.() } catch { }
instanceList[index].converter = null
if (hard) {
try { video.pause?.() } catch { }
try { video.removeAttribute?.('src') } catch { }
try { video.load?.() } catch { }
}
// 重新挂新的 converter
instanceList[index].converter = new VideoConverter(video, 60, 1)
// 让后端尽快推关键帧
try { wslist[index]?.send?.(openStr) } catch { }
}
return {
feedState,
pushFrame,
resetFeedState,
waitForVideoEl,
refreshStream,
}
}

View File

@@ -0,0 +1,23 @@
import { ElMessage } from "element-plus"
export function useTaskControl(runType, activeKey, stopAll) {
function stop() {
stopAll()
runType.value = ""
}
function continueAfterReset(key, cb) {
if (runType.value === key) cb()
}
function tryActivate(key, runner, force = false) {
if (!force && activeKey.value && activeKey.value !== key) {
ElMessage.warning("请先停止当前任务")
return
}
runType.value = key
runner && runner()
}
return { stop, continueAfterReset, tryActivate }
}

View File

@@ -0,0 +1,31 @@
// src/composables/useTaskQueue.js
// 创建任务队列
const _queues = new Map(); // index -> task[]
export function createTaskQueue(index) {
if (!_queues.has(index)) _queues.set(index, []);
return {
enqueue(task) {
const q = _queues.get(index);
q.push(task);
if (q.length === 1) task(); // 只有第一个任务时立即执行
},
next(type, time) {
console.log('任务发送', index, type, time);
const q = _queues.get(index) || [];
q.shift();
if (q.length > 0) q[0](); // 执行下一个
},
clear() {
_queues.set(index, []);
},
getNum() {
return _queues.get(index) || [];
}
};
}
export function clearAllQueues() {
_queues.clear();
}

View File

@@ -0,0 +1,108 @@
// composables/useTeardown.js
export function useTeardown(deps) {
let sseRef = null;
let multiplexWS = null;
function setSSE(es) {
try { sseRef?.close?.(); } catch { }
sseRef = es;
}
function setMultiplexWS(ws) {
try { multiplexWS?.close?.(); } catch { }
multiplexWS = ws;
}
function disposeDevice(index) {
try {
const di = deps.deviceInformation.value[index];
const udid = di?.udid;
// 停止定时器
if (deps.playTimer.value[index]) {
clearTimeout(deps.playTimer.value[index]);
deps.playTimer.value[index] = null;
}
const inst = deps.instanceList[index];
if (inst?.timer) {
clearInterval(inst.timer);
inst.timer = null;
}
// 关闭 WS
const ws = deps.wslist[index];
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
try { ws.close(); } catch { }
deps.wslist[index] = undefined;
}
if (udid) deps.wsCache.delete(udid);
// 销毁解码器
try {
inst?.converter?.destroy?.();
} catch { }
if (deps.instanceList[index]) deps.instanceList[index].converter = undefined;
// 释放 <video>
if (udid) {
const video = deps.videoElement.value?.[udid];
if (video) {
try { video.pause?.(); } catch { }
try { video.removeAttribute?.('src'); } catch { }
try { video.load?.(); } catch { }
delete deps.videoElement.value[udid];
}
if (deps.canvasRef.value?.[udid]) {
delete deps.canvasRef.value[udid];
}
}
// 清空任务队列
try { deps.createTaskQueue(index).clear(); } catch { }
} catch (e) {
console.error('[disposeDevice] error:', index, e);
}
}
function disposeAll(reason = 'manual') {
console.log('[teardown] disposeAll:', reason);
// 停止全局监控
try { deps.stopAll?.(); } catch { }
// 释放所有设备
const n = deps.deviceInformation.value.length;
for (let i = 0; i < n; i++) disposeDevice(i);
deps.deviceInformation.value = [];
// 关闭 multiplex WS
try { multiplexWS?.close?.(); } catch { }
multiplexWS = null;
// 关闭 SSE
try { sseRef?.close?.(); } catch { }
sseRef = null;
// 页级定时器
if (deps.isShowMes.value) {
clearInterval(deps.isShowMes.value);
deps.isShowMes.value = undefined;
}
// 清空缓存
deps.wsCache.clear?.();
// 全局事件监听
try { deps.removeDocListeners?.(); } catch { }
}
return {
setSSE,
setMultiplexWS,
disposeDevice,
disposeAll,
};
}

109
src/composables/useTouch.js Normal file
View File

@@ -0,0 +1,109 @@
// src/composables/useTouch.js (纯 JS
/**
* 提供 sleep / tapAt / longPressAt / drag / clickxy 五个工具。
* clickxy 内会调用 Back、wsActionsRef、setComText、phoneXYinfoRef。
*/
export function useTouch({
deviceInformation, // ref([]) 读取 udid
sendPointer, // 来自 useCanvasPointer
wsActionsRef, // () => wsActions
phoneXYinfoRef, // ref([...]) 内含 idscreen坐标体系的 device id
Back, // function Back('', index)
setComText, // function setComText(index)
}) {
// 小工具:延迟
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// 轻点
async function tapAt(udid, index, x, y, holdMs = 80) {
sendPointer(udid, index, 0, x, y) // down
await sleep(holdMs)
sendPointer(udid, index, 1, x, y) // up
}
// 长按
async function longPressAt(udid, index, x, y, holdMs = 1500) {
sendPointer(udid, index, 0, x, y) // down
await sleep(holdMs)
sendPointer(udid, index, 1, x, y) // up
}
// 拖拽/滑动
async function drag(udid, index, x1, y1, x2, y2, durationMs = 300, steps = 8) {
sendPointer(udid, index, 0, x1, y1) // down
const dx = (x2 - x1) / steps
const dy = (y2 - y1) / steps
const dt = Math.max(8, Math.floor(durationMs / steps))
for (let i = 1; i <= steps; i++) {
await sleep(dt)
sendPointer(udid, index, 2, Math.round(x1 + dx * i), Math.round(y1 + dy * i)) // move
}
await sleep(20)
sendPointer(udid, index, 1, x2, y2) // up
}
/**
* clickxy统一的“坐标点击”入口保留你原来的 type 语义:
* - type===3点击 -> 返回 -> 下滑
* - type===2点击 -> 返回 -> 右滑
* - type===1点击评论框后粘贴发送setComText
* - type===9长按
* - 默认:普通点击
*/
async function clickxy(x, y, index, type) {
const dev = deviceInformation.value[index]
const wsActions = wsActionsRef && wsActionsRef()
if (!dev || !dev.udid) {
console.error('clickxy: no udid at index', index)
return
}
const udid = dev.udid
try {
if (type === 3) {
await tapAt(udid, index, x, y, 80)
await sleep(300)
Back('', index)
await sleep(300)
wsActions && wsActions.slideDown && wsActions.slideDown(phoneXYinfoRef.value[index].id, index)
return
}
if (type === 2) {
await tapAt(udid, index, x, y, 80)
await sleep(300)
Back('', index)
await sleep(300)
wsActions && wsActions.slideRight && wsActions.slideRight(phoneXYinfoRef.value[index].id, index)
return
}
if (type === 1) {
await tapAt(udid, index, x, y, 80)
await sleep(300)
setComText(index) // 评论框:粘贴发送
return
}
if (type === 9) {
await longPressAt(udid, index, x, y, 1500)
return
}
// 默认:普通轻点
await tapAt(udid, index, x, y, 80)
} catch (e) {
console.error('clickxy error:', e)
}
}
return {
sleep,
tapAt,
longPressAt,
drag,
clickxy,
}
}

View File

@@ -0,0 +1,106 @@
// src/composables/useVideoStream.js
/**
* 将 h264-converter 的 MSE SourceBuffer 做“回放缓冲裁剪”,
* 防止 buffered 越积越多导致内存上涨。
*
* @param {Array<object>} instanceList - 你的 instanceList每个 index 有 converter
* @param {object} videoElementRef - 你的 videoElement ref 对象udid->video
* @param {import('vue').Ref<Array>} deviceInformationRef - 设备列表(取 udid
* @param {number} index
* @param {number} backBufferSec - 保留最近多少秒
* @param {number} intervalMs - 多久裁剪一次
*/
export function attachTrimmerForIndex(
instanceList,
videoElementRef,
deviceInformationRef,
index,
backBufferSec = 10,
intervalMs = 2000
) {
const conv = instanceList[index]?.converter;
if (!conv) return;
const ensureAttach = () => {
const ms = conv.mediaSource;
if (!ms) return false;
if (ms.readyState !== "open") return false;
if (!conv.sourceBuffer) return false;
return true;
};
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
if (conv._mseListenersInstalled !== true && conv.mediaSource) {
conv._mseListenersInstalled = true;
conv.mediaSource.addEventListener("sourceopen", () => {
attachTrimmerForIndex(
instanceList,
videoElementRef,
deviceInformationRef,
index,
backBufferSec,
intervalMs
);
});
conv.mediaSource.addEventListener("sourceclose", () => {
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
});
conv.mediaSource.addEventListener("error", () => {
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
});
}
if (!ensureAttach()) {
const waitId = setInterval(() => {
if (ensureAttach()) {
clearInterval(waitId);
attachTrimmerForIndex(
instanceList,
videoElementRef,
deviceInformationRef,
index,
backBufferSec,
intervalMs
);
}
}, 300);
return;
}
conv._trimTimer = setInterval(() => {
const currentConv = instanceList[index]?.converter;
const ms = currentConv?.mediaSource;
const sb = currentConv?.sourceBuffer;
const udid = deviceInformationRef.value[index]?.udid;
const video = udid ? videoElementRef.value[udid] : null;
if (!currentConv || !ms || ms.readyState !== "open" || !sb || !video) return;
if (sb.updating || video.seeking || video.readyState < 2) return;
const cur = video.currentTime || 0;
const trimTo = Math.max(0, cur - backBufferSec);
try {
for (let i = 0; i < sb.buffered.length; i++) {
const start = sb.buffered.start(i);
const end = sb.buffered.end(i);
if (end < trimTo - 0.25) {
try { sb.remove(0, end); } catch { }
break;
}
}
} catch (e) {
// 忽略一次性错误(例如 SourceBuffer 被移除)
}
}, intervalMs);
}

BIN
src/src.zip Normal file

Binary file not shown.

View File

@@ -74,7 +74,8 @@ body {
video {
transition: all 0.3s ease-in-out;
border: 13px solid rgba(84, 224, 206, 0.726);
border-radius: 20px;
/* 关键:让 video 不拦截鼠标事件 */
/* 添加动画 */
@@ -106,8 +107,8 @@ video {
.canvas {
position: absolute;
top: 0;
left: 0;
top: 13px;
left: 13px;
z-index: 9;
/* transform: scale(0.27); */
/* 缩小到原始尺寸的50% */
@@ -141,7 +142,8 @@ video {
.app-button {
// background-color: darkcyan;
// color: white;
// color: white;
cursor: default;
width: 260px;
height: 50px;
background: linear-gradient(0deg, #4FCACD 0%, #5FDBDE 100%);
@@ -168,41 +170,7 @@ video {
transform: translateY(1px);
}
.left-button {
position: relative;
z-index: 999;
margin-bottom: 19px;
width: 100%;
height: 72px;
// background: #32C9CD;
border-radius: 10px;
font-family: Source Han Sans SC;
font-weight: 800;
font-size: 20px;
color: #7A8B97;
line-height: 16px;
align-items: center;
display: flex;
align-items: center;
img {
width: 30px;
margin: 20px;
}
}
.left-button:hover {
background: #32C9CD;
color: #F9FAFE;
}
/* 鼠标按下时效果 */
.left-button:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translateY(1px);
}
.center-justify {
display: flex;

View File

@@ -61,9 +61,10 @@ export function clearHostList() {
export function addToHostList(newItem) {
// 获取当前的数组
const currentList = JSON.parse(localStorage.getItem('hostList') || '[]');
newItem.forEach(element => {
currentList.push(element);
});
// 向数组添加新元素
currentList.push(newItem);
// 更新存储的数组
localStorage.setItem('hostList', JSON.stringify(currentList));
@@ -99,3 +100,7 @@ export function setsessionId(data) {
export function getsessionId() {
return JSON.parse(sessionStorage.getItem('sessionList'));
}

View File

@@ -18,7 +18,7 @@ let baseURL = ''
if (process.env.NODE_ENV === 'development') {
// 生产环境
// baseURL = "https://api.tkpage.yolozs.com"
// baseURL = "http://192.168.1.155:8101"
// baseURL = "http://192.168.1.7:8101"
baseURL = "https://crawlclient.api.yolozs.com"
} else {
// 测试环境
@@ -56,7 +56,7 @@ axios.interceptors.request.use((config) => {
// 响应拦截器
axios.interceptors.response.use((response) => {
// console.log("response", response.data)
console.log("response", response.data)
if (response.data.code == 0 || response.data.code == 200) {
// console.log("response", response.data.data)
return response.data.data

View File

@@ -17,7 +17,9 @@ export function connectSSE(url, onMessage) {
}
eventSource.onmessage = (event) => {
console.log('[SSE] 收到消息:', event)
try {
const data = JSON.parse(event.data)
if (onMessage) onMessage(data)
} catch (e) {

View File

@@ -16,6 +16,7 @@ const mouseData = {
export function createWsActions(wslist) {
// 通用 ws 发送方法
function send(index, payload) {
// console.log("发送任务", payload.type)
if (wslist[index]) {
setTimeout(() => {
@@ -31,11 +32,15 @@ export function createWsActions(wslist) {
open: (udid, index) => send(index, { udid, action: 'openDY' }), //打开tk
killNow: (udid, index) => send(index, { udid, action: 'killNow' }), //关闭当前进程
install: (udid, index, path) => send(index, { udid, action: 'install', resourceId: path }), //安装应用
installc: (udid, index) => send(index, { udid, action: 'installt', resourceId: 'clipper.apk' }), //安装应用clipper
startClipserve: (udid, index) => send(index, { udid, action: 'startClipserve' }), //安装应用clipper
installt: (udid, index) => send(index, { udid, action: 'installt', resourceId: 'tiktok-39-3-3.apk' }), //安装应用 tiktok
pushFile: (udid, index, path) => send(index, { udid, action: 'pushFile', resourceId: path }), //发送文件
slideDown: (udid, index) => send(index, { udid, action: 'slideDown' }),//下滑动视频
slideUp: (udid, index) => send(index, { udid, action: 'slideUp' }),//上滑动视频
slideRight: (udid, index) => send(index, { udid, action: 'slideRight' }),//右滑动视频
getSize: (udid, index) => send(index, { udid, action: 'getSize', index }),//右滑动视频
// setClipboard: (udid, index, text, type) => send(index, { udid, action: 'setClipboard', type: type, index, resourceId: text }), //截屏测试
clickLikes: (udid, index) => send(index, { udid, action: 'click', type: 'Likes', index, resourceId: 'com.zhiliaoapp.musically:id/dy6' }),//点赞
clickComment: (udid, index) => send(index, { udid, action: 'click', type: 'Comment', index, resourceId: 'com.zhiliaoapp.musically:id/cvd' }),//打开评论
clickComtext: (udid, index) => send(index, { udid, action: 'click', type: 'Comtext', index, resourceId: 'com.zhiliaoapp.musically:id/cs0' }),//点开输入框
@@ -61,6 +66,7 @@ export function createWsActions(wslist) {
isOneLive: (udid, index) => send(index, { udid, action: 'click', type: 'isOneLive', index, resourceId: 'com.zhiliaoapp.musically:id/s1w' }), //判断是否是单人直播
hostVideo: (udid, index, num) => send(index, { udid, action: 'click', type: 'hostVideo', index, resourceId: 'com.zhiliaoapp.musically:id/d3u', num: num }), //主播视频
test: (udid, index) => send(index, { udid, action: 'test', type: 'test', index, resourceId: 'com.zhiliaoapp.musically:id/TESTFFFXXX' }), //截屏测试
openClose: (udid, index) => send(index, { udid, action: 'click', type: 'openClose', index, resourceId: 'android:id/button2' }), //关闭弹窗
// test2: (udid, index) => send(index, { udid, action: 'dump', type: 'test', index, resourceId: 'com.zhiliaoapp.musically:id/kg4' }), //截屏测试

View File

@@ -1,5 +1,5 @@
<template>
<div class="main">
<div class="main-home">
<div class="container">
<div class="right">
<img src="../assets/logoBg.png" class="background-video" alt="">
@@ -79,13 +79,11 @@ import { getToken, setToken, setUser, setUserPass, getUserPass } from '@/stores/
import { ElLoading, ElMessage } from 'element-plus';
let version = ref('0.0.0');
let version = ref('1.6.0(测试版)');
onMounted(() => {
})
const router = useRouter();
const formData = ref({
@@ -94,8 +92,6 @@ const formData = ref({
password: getUserPass() == null ? '' : getUserPass().password,
});
const onSubmit = () => {
const loading = ElLoading.service({
lock: true,
@@ -123,7 +119,7 @@ const onSubmit = () => {
</script>
<style lang="less">
.main {
.main-home {
width: 100vw;
height: 100vh;
overflow: hidden;

File diff suppressed because it is too large Load Diff

Binary file not shown.