Compare commits
10 Commits
5cfdfba311
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| efee6eef2a | |||
| 1666d9aa14 | |||
| 3a7c5556fd | |||
| a8889e1ce6 | |||
| 5bfb9027b6 | |||
| f7c04c88d4 | |||
| b56ab2dfd8 | |||
| 1f6fd80f48 | |||
| 35c6422ab5 | |||
| df5b848f08 |
33
package-lock.json
generated
33
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
381
src/components/HostListManagerDialog.vue
Normal file
381
src/components/HostListManagerDialog.vue
Normal 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>
|
||||
86
src/components/LeftToolbar.vue
Normal file
86
src/components/LeftToolbar.vue
Normal 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>
|
||||
56
src/components/ScheduleDialog.vue
Normal file
56
src/components/ScheduleDialog.vue
Normal 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>
|
||||
78
src/composables/useCanvasPointer.js
Normal file
78
src/composables/useCanvasPointer.js
Normal 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 };
|
||||
}
|
||||
103
src/composables/useDeviceDiscovery.js
Normal file
103
src/composables/useDeviceDiscovery.js
Normal 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 }
|
||||
}
|
||||
121
src/composables/useMonitor.js
Normal file
121
src/composables/useMonitor.js
Normal 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,
|
||||
}
|
||||
}
|
||||
45
src/composables/useSchedule.js
Normal file
45
src/composables/useSchedule.js
Normal 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
|
||||
}
|
||||
}
|
||||
82
src/composables/useStreams.js
Normal file
82
src/composables/useStreams.js
Normal 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,
|
||||
}
|
||||
}
|
||||
23
src/composables/useTaskControl.js
Normal file
23
src/composables/useTaskControl.js
Normal 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 }
|
||||
}
|
||||
31
src/composables/useTaskQueue.js
Normal file
31
src/composables/useTaskQueue.js
Normal 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();
|
||||
}
|
||||
108
src/composables/useTeardown.js
Normal file
108
src/composables/useTeardown.js
Normal 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
109
src/composables/useTouch.js
Normal 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([...]) 内含 id(screen坐标体系的 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,
|
||||
}
|
||||
}
|
||||
106
src/composables/useVideoStream.js
Normal file
106
src/composables/useVideoStream.js
Normal 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
BIN
src/src.zip
Normal file
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }), //截屏测试
|
||||
|
||||
|
||||
@@ -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
BIN
tkAiPage.zip
BIN
tkAiPage.zip
Binary file not shown.
Reference in New Issue
Block a user