爬虫联动弹窗版本备份

This commit is contained in:
2025-09-20 13:31:06 +08:00
parent 515cbab7c3
commit 7bfe1b744a
11 changed files with 584 additions and 193 deletions

View File

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

View File

@@ -23,7 +23,7 @@ export function prologue() {
export function comment() {
return getAxios({ url: 'api/common/comment' })
}
//获取评论
//登出
export function logout(data) {
return postAxios({ url: 'api/user/aiChat-logout', data })
}

View File

@@ -72,7 +72,28 @@ export function anchorList(data) {
export function deleteAnchorWithIds(data) {
return postAxios({ url: 'deleteAnchorWithIds', data })
}
//设置主播列表
export function updateAnchorList(data) {
return postAxios({ url: 'updateAnchorList', data })
}
//设置经纪人信息
export function aiConfig(data) {
return postAxios({ url: 'aiConfig', data })
}
}
//获取消息列表
export function selectLast(data) {
return getAxios({ url: 'select_last_message', data })
}
//获更新消息列表
export function updatelast(data) {
return postAxios({ url: 'update_last_message', data })
}
//删除消息列表
export function deleteLast(data) {
return postAxios({ url: 'delete_last_message', data })
}
//全部停止
export function stopAllTask(data) {
return postAxios({ url: 'stopAllTask', data })
}

BIN
src/assets/mes.wav Normal file

Binary file not shown.

View File

@@ -2,14 +2,14 @@
<div v-if="visible" class="dialog-overlay" @click.self="close">
<div class="dialog-content">
<h3 class="text-lg font-bold mb-4">
<img style="margin: 0px 15px;" src="@/assets/video/chatMes.png"></img>
消息翻译内容
聊天翻译内容
</h3>
<el-scrollbar class="chat-box">
<el-scrollbar class="chat-box" ref="scrollbarRef">
<div v-for="(msg, index) in messages.filter(m => m.type !== 'time')" :key="index"
:class="msg.dir === 'in' ? 'left-message' : 'right-message'">
<div @click="fallbackCopyTextToClipboard(index, msg.text)"
:class="['bubble', msg.dir, { 'active': activeIndex === index }]">{{ msg.text }}
:class="['bubble', msg.dir, { 'active': activeIndex === index }]">
{{ msg.text }}
</div>
</div>
</el-scrollbar>
@@ -18,7 +18,7 @@
</template>
<script setup>
import { defineProps, defineEmits, ref } from 'vue'
import { defineProps, defineEmits, ref, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
@@ -31,36 +31,48 @@ const props = defineProps({
})
const emit = defineEmits(['close'])
const close = () => emit('close')
let activeIndex = ref(null);
let activeIndex = ref(null)
const scrollbarRef = ref(null) // 引用 el-scrollbar
// 兜底方案:传统复制方法
// 兜底复制
function fallbackCopyTextToClipboard(index, text) {
activeIndex.value = index;
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
activeIndex.value = index
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy');
const successful = document.execCommand('copy')
if (successful) {
ElMessage.success('复制成功');
ElMessage.success('复制成功')
} else {
ElMessage.error('复制失败1');
ElMessage.error('复制失败1')
}
} catch (err) {
ElMessage.error('复制失败2');
ElMessage.error('复制失败2')
}
document.body.removeChild(textArea);
document.body.removeChild(textArea)
}
// === 自动滚动到底部 ===
watch(
() => props.messages,
async () => {
await nextTick()
const wrap = scrollbarRef.value?.wrapRef // el-scrollbar 内部的原生容器
if (wrap) {
wrap.scrollTop = wrap.scrollHeight
}
},
{ deep: true }
)
</script>
<style scoped>

View File

@@ -13,7 +13,10 @@
<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-switch v-model="gold" :loading="goldLoading" :before-change="goldBeforeChange" inline-prompt
active-text="金票" inactive-text="金票" size="large" style="--el-switch-on-color: #db9600; " />
<el-switch v-model="ordinary" :loading="ordinaryLoading" :before-change="ordinaryBeforeChange" inline-prompt
inactive-text="普票" active-text="普票" size="large" />
<!-- 新增一键删除已处理 -->
<!-- <el-button size="small" type="warning" :disabled="!processedCount" @click="deleteProcessed">
删除已处理
@@ -36,11 +39,15 @@
:ref="el => setCardRef(it.anchorId, el)" @click.stop="toggleSelect(it.anchorId)">
<div class="row top">
<span class="id" :title="it.anchorId">{{ it.anchorId }}</span>
<button class="x" title="删除此项" @click.stop="deleteOne(it.anchorId)">×</button>
<button v-if="it.state == 0" class="x" title="不执行">X</button>
<button v-else class="y" title="执行"></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>
<span class="state" :class="{ done: it.invitationType == 2 }">{{ it.invitationType == 2 ? '金票' :
'普票'
}}</span>
</div>
</div>
@@ -61,13 +68,13 @@
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getHostList, setHostList } from '@/stores/storage'
import { anchorList, deleteAnchorWithIds } from '@/api/ios'
import { anchorList, deleteAnchorWithIds, updateAnchorList } from '@/api/ios'
// v-model:visible 接口
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'save'])
const emit = defineEmits(['update:visible', 'save', 'invitType'])
const show = computed({
get: () => props.visible,
set: (v) => emit('update:visible', v)
@@ -77,6 +84,14 @@ const show = computed({
const hosts = ref([]) // {country, text, state}
const selected = reactive(new Set()) // 选中的 text 集合
//金票
let gold = ref(true)
let goldLoading = ref(false)
//普票
let ordinary = ref(true)
let ordinaryLoading = ref(false)
// 卡片 DOM 引用与位置缓存
const gridRef = ref(null)
const cardRefs = reactive({}) // text -> el
@@ -102,6 +117,7 @@ async function getStoredHostList() {
async function onOpen() {
hosts.value = await getStoredHostList()
console.log(hosts.value)
selected.clear()
await nextTick()
recalcRects()
@@ -261,6 +277,51 @@ function deleteProcessed() {
.catch(() => { })
}
const ordinaryBeforeChange = () => {
const next = !ordinary.value // 目标值(切换后)
ordinaryLoading.value = true
return new Promise((resolve) => {
updateAnchorList({ invitationType: 1, state: next ? 1 : 0 })
.then(async () => {
ordinaryLoading.value = false
hosts.value = await getStoredHostList()
emit('invitType', 'ordinary', next) // 把“切换后的目标布尔”抛给父组件
resolve(true) // 允许切换
})
.catch(error => {
ordinaryLoading.value = false
ElMessage.error('Operation failed: ' + error.message)
resolve(false) // 阻止切换
})
})
}
const goldBeforeChange = () => {
console.log(ordinary.value, gold.value)
const next = !gold.value
goldLoading.value = true
return new Promise((resolve) => {
updateAnchorList({ invitationType: 2, state: next ? 1 : 0 })
.then(async () => {
goldLoading.value = false
hosts.value = await getStoredHostList()
emit('invitType', 'gold', next)
resolve(true)
})
.catch(error => {
goldLoading.value = false
ElMessage.error('Operation failed: ' + error.message)
resolve(false)
})
})
}
watch(hosts, () => nextTick().then(recalcRects))
</script>
@@ -354,6 +415,15 @@ watch(hosts, () => nextTick().then(recalcRects))
cursor: pointer;
}
.item-card .y {
border: none;
background: transparent;
color: #00a316;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.item-card .x:hover {
color: #f33;
}
@@ -370,8 +440,8 @@ watch(hosts, () => nextTick().then(recalcRects))
}
.item-card .state.done {
color: #67c23a;
border-color: #67c23a;
color: #db9600;
border-color: #db9600;
}
.selection-rect {

View File

@@ -1,9 +1,22 @@
<template>
<div v-if="visible" class="dialog-overlay" @click.self="close">
<div class="dialog-content">
<h3 class="text-lg font-bold mb-4">
<img style="margin: 0 15px;" src="@/assets/video/chatMes.png" />
新消息提醒
<!-- 右上角一键已读 + 静音按钮 + 未读徽标 -->
<div class="mark-all">
<button v-if="unreadCount > 0" class="mark-all-btn" @click.stop="markAllSeen" title="一键已读">一键已读</button>
<button class="mark-all-btn2" @click.stop="delAllSeen" title="删除已读">删除已读</button>
<button class="mute-btn" @click.stop="toggleMute" :title="muted ? '取消静音' : '静音'">
<span v-if="muted">🔕</span>
<span v-else>🔔</span>
</button>
</div>
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
<h3 style="display: flex;" class="text-lg font-bold mb-4 ">
<img style="margin: 0 2px;" src="@/assets/video/chatMes.png" />
<div>新消息提醒</div>
</h3>
<el-scrollbar class="chat-box">
@@ -20,6 +33,9 @@
</div>
</div>
</el-scrollbar>
<!-- 可选自定义提示音静音时也会被静音 -->
<audio v-if="soundSrc" ref="audioRef" :src="soundSrc" preload="auto" :muted="muted"></audio>
</div>
</div>
</template>
@@ -27,34 +43,25 @@
<script setup>
import { defineProps, defineEmits, ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { updatelast, deleteLast } from '@/api/ios'
const deleting = ref(false) // 防抖:删除过程中禁用按钮(可选)
const props = defineProps({
visible: Boolean,
messages: {
type: Array,
required: true,
default: () => []
},
// 可自定义颜色
reminderColor: {
type: String,
// Element Plus 警告浅色(看起来像“提醒”)
default: 'var(--el-color-warning-light-5, #ffe58f)' // 更深一点
},
seenColor: {
type: String,
// EP 填充浅色(更“平静”的已读态)
default: 'var(--el-fill-color-light, #f2f3f5)'
}
messages: { type: Array, required: true, default: () => [] },
reminderColor: { type: String, default: 'var(--el-color-warning-light-5, #ffe58f)' },
seenColor: { type: String, default: 'var(--el-fill-color-light, #f2f3f5)' },
soundSrc: { type: String, default: '' }
})
const emit = defineEmits(['close', 'seen'])
const emit = defineEmits(['close', 'seen', 'delete-read'])
const close = () => emit('close')
// 记录哪些消息已读(用原始索引做 key避免过滤后索引错位
const seenSet = ref(new Set())
// —— 静音 —— //
const muted = ref(false)
function toggleMute() { muted.value = !muted.value }
// 规范化并带上原始索引,过滤 type === 'time'
// 过滤 type==='time',并保留原始索引;容错旧字段
const normalizedMessages = computed(() => {
const res = []
; (props.messages || []).forEach((m, i) => {
@@ -64,33 +71,27 @@ const normalizedMessages = computed(() => {
raw: m,
sender: m?.sender ?? m?.name ?? m?.user ?? m?.from ?? '',
device: m?.device ?? m?.deviceName ?? m?.udid ?? '',
text: m?.text ?? m?.content ?? ''
text: m?.text ?? m?.content ?? '',
// 统一转成 0/1兼容旧的 seen/read 布尔
status: Number(
m?.status ?? ((m?.seen === true || m?.read === true) ? 1 : 0)
)
})
})
return res
})
// 初始化:如果消息本身标注了 seen/read则置为已读
watch(
() => props.messages,
(list) => {
const s = new Set()
list?.forEach((m, i) => {
if (m && (m.seen === true || m.read === true)) s.add(i)
})
seenSet.value = s
},
{ immediate: true }
)
// —— 基于 status 判断 —— //
function isSeen(item) {
return seenSet.value.has(item.origIndex)
// 优先读 item.raw.status其次读规范化的 item.status
return Number(item?.raw?.status ?? item?.status ?? 0) === 1
}
function markSeen(item) {
if (!isSeen(item)) {
seenSet.value.add(item.origIndex)
// 通知父组件(可选):你也可以在父里把 messages[item.origIndex].seen = true 持久化
// 标记为已读
item.raw.status = 1
try { updatelast(item.raw) } catch (_) { }
emit('seen', { index: item.origIndex, message: item.raw })
}
}
@@ -102,36 +103,112 @@ function bubbleStyle(item) {
}
}
// 点击:标记已读 + 复制文本
function onClickMessage(item) {
markSeen(item)
fallbackCopyTextToClipboard(item.text)
// —— 未读数量status === 0—— //
const unreadCount = computed(() =>
normalizedMessages.value.reduce((acc, it) => acc + (isSeen(it) ? 0 : 1), 0)
)
// —— 一键已读:把所有 status=0 的设为 1 —— //
async function markAllSeen() {
let changed = 0
for (const it of normalizedMessages.value) {
if (!isSeen(it)) {
it.raw.status = 1
try { await updatelast(it.raw) } catch (_) { }
emit('seen', { index: it.origIndex, message: it.raw })
changed++
}
}
if (changed > 0) ElMessage.success(`已标记 ${changed} 条为已读`)
else ElMessage.info('没有未读可标记')
}
// 兜底复制
function fallbackCopyTextToClipboard(text) {
// const textArea = document.createElement('textarea')
// textArea.value = text || ''
// textArea.style.position = 'fixed'
// textArea.style.left = '-999999px'
// textArea.style.top = '-999999px'
// document.body.appendChild(textArea)
// textArea.focus()
// textArea.select()
try {
// const ok = document.execCommand('copy')
// ok ? ElMessage.success('已读') : ElMessage.error('复制失败1')
ElMessage.success('已读')
} catch (err) {
// ElMessage.error('复制失败2')
} finally {
// document.body.removeChild(textArea)
// —— 删除已读:把所有 status=1 的抛给父组件处理 —— //
// 子组件不直接改 props.messages向上抛事件让父组件过滤
// —— 一键删除(删除所有 status=1—— //
async function delAllSeen() {
// 先拍一张快照,避免删除过程中索引变化
const readItems = normalizedMessages.value.filter(it => isSeen(it))
if (readItems.length === 0) {
ElMessage.info('没有已读可删除')
return
}
if (deleting.value) return
deleting.value = true
try {
// 并发删除(每条把“已读的消息体”传给后端)
const results = await Promise.allSettled(
readItems.map(it => deleteLast(it.raw))
)
// 统计删除成功/失败
const okIndexes = []
const okMessages = []
let ok = 0, fail = 0
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
ok++
okIndexes.push(readItems[i].origIndex)
okMessages.push(readItems[i].raw)
} else {
fail++
console.warn('[deleteLast] 失败:', readItems[i].raw, r.reason)
}
})
// 通知父组件把成功删除的从列表移除
if (okIndexes.length > 0) {
emit('delete-read', { indexes: okIndexes, messages: okMessages })
}
if (ok > 0) ElMessage.success(`已删除 ${ok}${fail ? `,失败 ${fail}` : ''}`)
else ElMessage.error('删除失败')
} finally {
deleting.value = false
}
}
// —— 提示音 —— //
const audioRef = ref(null)
function playNotice() {
if (muted.value) return
if (props.soundSrc && audioRef.value) {
audioRef.value.currentTime = 0
audioRef.value.play().catch(() => { })
} else {
try {
const Ctx = window.AudioContext || window.webkitAudioContext
if (!Ctx) return
const ctx = new Ctx()
const o = ctx.createOscillator()
const g = ctx.createGain()
o.type = 'sine'; o.frequency.value = 880
o.connect(g); g.connect(ctx.destination)
const t0 = ctx.currentTime
g.gain.setValueAtTime(0.0001, t0)
g.gain.exponentialRampToValueAtTime(0.12, t0 + 0.02)
g.gain.exponentialRampToValueAtTime(0.00001, t0 + 0.28)
o.start(t0); o.stop(t0 + 0.3)
o.onended = () => ctx.close().catch(() => { })
} catch (_) { }
}
}
// 未读数增加时才提示
watch(unreadCount, (now, old) => {
if (old !== undefined && now > old) playNotice()
})
// 点击条目 => 单条已读
function onClickMessage(item) {
markSeen(item)
try { ElMessage.success('已读') } catch (_) { }
}
</script>
<style scoped>
<style scoped lang="less">
.dialog-overlay {
display: flex;
justify-content: center;
@@ -139,6 +216,7 @@ function fallbackCopyTextToClipboard(text) {
}
.dialog-content {
position: relative;
margin-top: 3vh;
background: rgb(246, 246, 246);
padding: 0 20px 20px 20px;
@@ -146,6 +224,91 @@ function fallbackCopyTextToClipboard(text) {
width: 320px;
}
.mark-all {
position: absolute;
top: 20px;
right: 5px;
button {
margin-right: 8px;
}
}
/* 一键已读按钮(靠右,位于静音按钮左侧) */
.mark-all-btn {
/* 未读徽标(10) + 静音按钮(28+间距) 预留 */
height: 28px;
padding: 0 10px;
border: none;
background: var(--el-color-primary, #409eff);
color: #fff;
border-radius: 9999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
cursor: pointer;
font-size: 12px;
user-select: none;
}
.mark-all-btn2 {
/* 未读徽标(10) + 静音按钮(28+间距) 预留 */
height: 28px;
padding: 0 10px;
border: none;
background: #ff4040;
color: #fff;
border-radius: 9999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
cursor: pointer;
font-size: 12px;
user-select: none;
}
.mark-all-btn:hover {
filter: brightness(0.98);
}
/* 静音按钮 */
.mute-btn {
width: 28px;
height: 28px;
border: none;
background: var(--el-fill-color, #f5f7fa);
border-radius: 9999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
cursor: pointer;
/* display: grid; */
place-items: center;
line-height: 1;
font-size: 14px;
user-select: none;
}
.mute-btn:hover {
filter: brightness(0.98);
}
/* 未读徽标 */
.unread-badge {
position: absolute;
top: -10px;
right: -10px;
min-width: 22px;
height: 22px;
padding: 0 6px;
background: var(--el-color-danger, #f56c6c);
color: #fff;
font-size: 12px;
line-height: 22px;
text-align: center;
border-radius: 9999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
user-select: none;
}
.chat-box {
display: flex;
flex-direction: column;
@@ -190,7 +353,7 @@ function fallbackCopyTextToClipboard(text) {
word-break: break-word;
}
/* 颜色通过内联样式控制(.unread/.seen 仅用于语义钩子) */
/* 颜色通过内联样式控制(.unread/.seen 仅语义钩子) */
.bubble.unread {}
.bubble.seen {}

View File

@@ -47,7 +47,7 @@ function attachInterceptors(instance) {
}
// 业务失败:提示 + reject
const msg = `${data?.code ?? ''} ${data?.msg ?? '请求失败'}`
const msg = `${data?.code ?? ''} ${data?.message ?? '请求失败'}`
ElMessage.error(msg)
const err = new Error(msg)

View File

@@ -79,7 +79,7 @@ import { getToken, setToken, setUser, setUserPass, getUserPass } from '@/stores/
import { ElLoading, ElMessage } from 'element-plus';
import { passToken } from '@/api/ios';
let version = ref('1.1.4');
let version = ref('1.5.4');
onMounted(() => {

View File

@@ -4,7 +4,15 @@
<div style="position: absolute;left: 20px; top: 20px;">
<el-button style="background: linear-gradient(90deg, #60a5fa, #34d399); color: azure;"
@click="showMyInfo = true">人设编辑</el-button>
<!-- <el-button style="background: linear-gradient(90deg, #60a5fa, #34d399); color: azure;" @click="MesNewList.push({
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
})">新增</el-button> -->
</div>
<div class="center-line"> <!-- 左边栏按钮 -->
<div v-for="(btn, index) in buttons" :key="index" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button" :style="btn.style ? btn.style() : {}" @click="btn.onClick"
@@ -16,15 +24,21 @@
<div style="position: absolute;left: 20px; bottom: 20px;">
<el-button @click="showHostDlg = true">执行主播库</el-button>
<el-button type="info" @click="uploadLogFile">上传日志</el-button>
<!-- 新增SSE 弹窗总开关 -->
<el-switch v-model="sseEnabled" inline-prompt active-text="监听爬虫" inactive-text="监听爬虫"
style="margin-left: 8px;" />
</div>
</div>
</el-scrollbar>
<!-- 中间手机区域 -->
<div class="content" @click.self="selectedDevice = 999">
<div v-if="isImg" class="video-container" v-for="(device, index) in deviceInformation" :key="device.deviceId">
<div class="video-container" v-for="(device, index) in deviceInformation" :key="device.deviceId">
<div class="video-canvas" :class="{ active: selectedDevice === index }" :style="getCanvasStyle(index)"
@click="selectDevice(index)">
<img class="stream" :src="'http://localhost:' + device.screenPort" />
<img class="stream" :key="device.deviceId + '-' + imgKeyTick"
:src="`http://localhost:${device.screenPort}/?t=${Date.now() || 0}`" :data-id="device.deviceId"
:ref="el => (imgRefs[device.deviceId] = el)" />
<canvas v-show="selectedDevice === index" class="overlay"
@mousedown.stop="(e) => onCanvasDown(device.deviceId, e, index)"
@mouseup.stop="(e) => onCanvasUp(device.deviceId, e, index)"
@@ -61,11 +75,11 @@
<div class="right center-line" @click.self="selectedDevice = 999">
<!-- <div style="margin: 30px;"></div> -->
<ChatDialog :visible="openShowChat" :messages="chatList" />
<MessageDialogd :visible="openShowChat" :messages="MesNewList" />
<MessageDialogd :visible="openShowChat" :messages="MesNewList" :sound-src="ding" />
</div>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='""' :title="dialogTitle" :index="selectedDevice"
@confirm="onDialogConfirm" @cancel="stopAll" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" />
<HostListManagerDialog v-model:visible="showHostDlg" @save="onHostSaved" @invitType="invitTypeFun" />
</div>
<!-- <AgentGuildDialog v-model="showMyInfo" :model="formInit" @save="handleSave" /> -->
@@ -120,7 +134,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, onBeforeUnmount, watch, inject, computed } from "vue";
import { ref, reactive, onMounted, onUnmounted, onBeforeUnmount, watch, inject, computed, nextTick } from "vue";
import { useRouter } from 'vue-router';
import {
setphoneXYinfo, getphoneXYinfo, getUser,
@@ -155,11 +169,14 @@ import {
getChatTextInfo,
setLoginInfo,
aiConfig,
selectLast,
updatelast,
stopAllTask
} from '@/api/ios';
import ding from '@/assets/mes.wav'
import { set } from "lodash";
const router = useRouter();
const openShowChat = ref([true])
const openShowChat = ref(true)
//主播库
const showHostDlg = ref(false)
//ai人设
@@ -172,66 +189,22 @@ const borkerConfig = reactive({
contact: ''
})
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
let hostList = []
//查询列表轮询
let getListtimer = null;
let userdata = getUser();
let chatList = ref([])
let MesNewList = ref([{
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
}, {
sender: 'Alice', // 或 name/user/from
device: 'iPhone 14 Pro', // 或 deviceName/udid
text: '你好呀~', // 或 content
type: 'msg' // 可选;有些是 'time' 会被过滤
},])
let isImg = ref(true)
let MesNewList = ref([])
// 引入刷新方法
const reload = inject("reload")
const reloadImg = () => {
isImg.value = false
setTimeout(() => {
isImg.value = true
}, 1000)
refreshAllImgs()
}
//start弹窗
let isMsgPop = ref(false)
@@ -252,6 +225,12 @@ let stopLoading = null
// 当前是否被其它模式占用(四个互斥按钮专用)
const isLocked = (type) => !!runType.value && runType.value !== type
// —— SSE 弹窗/接收总开关(持久化)——
const sseEnabled = ref(JSON.parse(localStorage.getItem('SSE_ENABLED') ?? 'true'))
watch(sseEnabled, v => {
localStorage.setItem('SSE_ENABLED', JSON.stringify(v))
})
// 互斥按钮的样式:激活=红,锁定=半透明且禁点
const ctrlStyle = (type) => ({
backgroundColor: runType.value === type ? 'red' : '',
@@ -421,7 +400,9 @@ const buttons = [
{
label: '登出',
onClick: () => {
refreshAllStopImgs() // 停止所有视频流
logout({ userId: userdata.id, tenantId: userdata.tenantId })
router.push('/')
},
@@ -502,6 +483,51 @@ function saveSchedule() {
const selectedDevice = ref(null)
// 每台设备的 <img> 引用
const imgRefs = ref({}) // { [id]: HTMLImageElement }
const imgTs = ref({}) // { [id]: number } // cache-bust
const imgKeyTick = ref(0) // 强制重建 <img>
function refreshOneImg(id, port) {
const el = imgRefs.value[id]
console.log("终止", id)
if (el) {
// 硬中断旧请求
el.src = ''
el.removeAttribute('src')
}
// 下一拍再重建
nextTick(() => {
imgTs.value[id] = Date.now()
imgKeyTick.value++ // 触发 key 变化 -> 销毁并新建 <img>
})
}
// —— 关键:登出/离开时“批量硬中断”所有图片流 ——
async function hardStopAllImgStreams(id, port) {
const el = imgRefs.value[id]
console.log("终止", id)
if (el) {
// 硬中断旧请求
el.src = ''
el.removeAttribute('src')
}
}
function refreshAllImgs() {
Object.keys(imgRefs.value).forEach(id => refreshOneImg(id))
}
function refreshAllStopImgs() {
Object.keys(imgRefs.value).forEach(id => hardStopAllImgStreams(id))
}
// —— 显示尺寸固定为 320x720未选中缩略为 THUMB_SCALE 倍 ——
// 尺寸与排布
const BASE_W = 320
@@ -725,29 +751,25 @@ async function stopAll() {
background: 'rgba(0, 0, 0, 0.7)',
});
// if (!runType.value) return
// 所有操作完成后执行以下代码
scheduleEnabled.value = false
runType.value = ''
batchMode.value = 'init' // 初始化状态 关注状态
isMsgPop.value = false; //弹窗状态(不是弹窗)
dropCurrentWave() // 丢弃当前波的残留缓冲
try {
// 使用 Promise.all 并行处理所有设备的停止操作
await Promise.all(deviceInformation.value.map(async (item) => {
try {
const res = await stopScript({ udid: item.deviceId })
console.log(`停止成功:${item.deviceId}`, res, printCurrentTime())
ElMessage.success(`停止成功:${item.deviceId}`)
stopLoading.close()
} catch (error) {
console.log(`停止失败`, printCurrentTime())
ElMessage.error(`脚本已停止`)
stopLoading.close()
}
}))
// 所有操作完成后执行以下代码
scheduleEnabled.value = false
runType.value = ''
batchMode.value = 'init'
const res = await stopAllTask(deviceInformation.value.map((item) => item.deviceId))
console.log(`全部停止成功`, printCurrentTime())
ElMessage.success(`全部停止成功`)
stopLoading.close()
} catch (error) {
console.error('批量停止过程中发生错误:', error)
console.log(`停止失败`, printCurrentTime())
ElMessage.error(`脚本已停止`)
stopLoading.close()
}
}
//确认多行文本框内容
@@ -763,12 +785,15 @@ function onDialogConfirm(result, type, index, isMon) {
} else if (type == '私信') {
runType.value = 'follow'
setContentpriList(result)
console.log('hostList', hostList)
passAnchorData(
{
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: hostList.map(item => ({
anchorId: item.id,
country: item.country
country: item.country,
invitationType: item.invitationType,
state: stateByInvType(item.invitationType),
})),
prologueList: result,
needReply: isMon
@@ -783,7 +808,7 @@ function onDialogConfirm(result, type, index, isMon) {
}
onMounted(() => {
onMounted(async () => {
const loading = ElLoading.service({
lock: true,
text: 'Loading',
@@ -795,14 +820,16 @@ onMounted(() => {
getListtimer = setInterval(() => {
loading.close();
getDeviceListFun()
selectLastFun()
}, 3000)
if (!await isAiConfig()) {
showMyInfo.value = true
}
// 批次缓冲(仅用于当前“波”)
let batch = []; // [{ country, text }]
let flushTimer = null;
function scheduleFlush(handler, delay = 400) {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(() => {
@@ -821,11 +848,26 @@ onMounted(() => {
// —— SSE 接收 ——
const es = connectSSE('http://localhost:3312/events', (data) => {
console.log('来自服务端:', data);
// console.log('来自服务端:', data);
// console.log(1)
//总开关
// 总开关关闭:不弹窗、丢弃所有消息
if (!sseEnabled.value) {
if (data === 'start') {
// 开始一个新波时顺便清空(保险起见)
dropCurrentWave()
}
return // 其余任何数据也直接丢弃
}
if (data === 'start') {
console.log(2)
// 新一波开始:根据当前状态决定“本波 flush 用谁”
if (!isMsgPop.value) {
console.log(3)
// 还没开始过 -> 首次弹框,确认后使用处理本波
isMsgPop.value = true;
batchMode.value = 'init';
@@ -841,7 +883,8 @@ onMounted(() => {
// 不在这里立刻提交;让后续主播数据先进 batch再由防抖统一 flush
// 不直接发;把这“一波”的主播先塞进 hostList然后弹出“私信”输入框
scheduleFlush((items) => {
hostList = (items || []).map(h => ({ id: h.text, country: h.country || '' }))
//1普票 2金票 invitationType
hostList = (items || []).map(h => ({ id: h.text, country: h.country || '', invitationType: h.invitationType }))
})
setTimeout(() => {
@@ -859,19 +902,27 @@ onMounted(() => {
batchMode.value = 'follow';
// 立刻安排一次“尾随防抖”flush等本波数据齐了再送
scheduleFlush((items) => {
addTempAnchorData(items.map(h => ({ anchorId: h.text, country: h.country || '' })));
// 这里 items 元素是 h
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType)
}))
addTempAnchorData(list)
});
}
} else {
// 非 start本波主播数据进入批次
const country = data && data.country != null ? data.country : '';
const text = data && (data.hostsId != null ? data.hostsId : data.text);
const invitationType = data && (data.invitationType != null ? data.invitationType : '');
if (text == null) {
// 数据格式不对,丢弃或打印
console.warn('[SSE] 非法数据,缺少 hostsId/text:', data);
return;
}
batch.push({ country, text });
batch.push({ country, text, invitationType });
// 根据当前模式,刷新防抖(让“最后一条到来后”延迟几百毫秒再统一提交)
if (batchMode.value === 'init') {
@@ -881,7 +932,7 @@ onMounted(() => {
if (runType.value === 'follow') {
passAnchorData({
deviceList: deviceInformation.value.map(item => item.deviceId),
anchorList: items.map(h => ({ anchorId: h.text, country: h.country || '' })),
anchorList: items.map(h => ({ anchorId: h.text, country: h.country || '', invitationType: h.invitationType, state: stateByInvType(h.invitationType) })),
needReply: false
});
} else {
@@ -892,7 +943,14 @@ onMounted(() => {
} else {
// 已在关注:走追加逻辑
scheduleFlush((items) => {
addTempAnchorData(items.map(h => ({ anchorId: h.text, country: h.country || '' })));
// 这里 items 元素是 h
const list = items.map(h => ({
anchorId: h.text,
country: h.country || '',
invitationType: h.invitationType,
state: stateByInvType(h.invitationType)
}))
addTempAnchorData(list)
});
}
}
@@ -909,16 +967,18 @@ onUnmounted(() => {
let isStartLac = false
const getDeviceListFun = () => {
getDeviceList().then((res) => {
console.log('返回', res.length)
// console.log('返回', res.length)
if (res && res.length > 0 && deviceInformation.value.length !== res.length) {
console.log("设备变更")
deviceInformation.value = res
reloadImg()
// refreshAllStopImgs()
// reloadImg()
}
if (res.length == 0) {
deviceInformation.value = []
reloadImg()
// refreshAllStopImgs()
// reloadImg()
}
// deviceInformation.value = ['', '', '', '', '', '',]
}).catch((err) => {
@@ -932,6 +992,27 @@ const getDeviceListFun = () => {
})
}
//获取新消息
const selectLastFun = () => {
selectLast().then((res) => {
// console.log("返回", deviceInformation, res)
let mesInfoData = res
mesInfoData.forEach(element => {
deviceInformation.value.forEach((item, index) => {
console.log(res)
console.log(item.deviceId == element.device)
if (item.deviceId == element.device) {
element.device = index + 1 + '号设备'
console.log(element.device)
}
})
});
// console.log('============', mesInfoData)
MesNewList.value = mesInfoData
})
}
async function uploadLogFile() {
let loading = null
try {
@@ -1156,6 +1237,50 @@ function onSave(payload) {
})
}
async function isAiConfig() {
const res = await window.electronAPI.fileExists("resources/iOSAI/data/aiConfig.json");
console.log(res);
return res.exists;
}
//金票
const gold = ref(true) // ON=执行
//普票
const ordinary = ref(true) // ON=执行
function invitTypeFun(invitType, nextEnabled) {
// 子组件已传来“切换后的布尔值”
if (invitType === 'gold') {
gold.value = nextEnabled
} else if (invitType === 'ordinary') {
ordinary.value = nextEnabled
}
}
/** 由邀请类型 + 当前全局开关,得到 state(0/1) */
function stateByInvType(invitationType) {
// 2=金票,其他=普票(按你的写法)
const enabled = invitationType == 2 ? gold.value : ordinary.value
return enabled ? 1 : 0
}
//清空当前批次的小工具
function dropCurrentWave() {
// 丢弃当前这“波”已缓冲的数据 & 取消待 flush
batch.length = 0
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
// 复位本波的弹窗与模式标记
isMsgPop.value = false
batchMode.value = 'init'
}
</script>
<style scoped lang="less">