Files
web-fusion/src/components/Sidebar.vue

446 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<aside :style="{ width: sidebarWidth + 'px', minWidth: '96px', maxWidth: '400px' }"
class="h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm flex-shrink-0">
<!-- 返回和停止按钮 -->
<div class="m-3 mb-0 flex gap-2">
<button @click="onGoBack"
class="flex-1 px-3 py-2 text-xs bg-gray-100 text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-200 transition-colors text-left">
返回
</button>
<button @click="onStopAll"
class="px-3 py-2 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="停止所有任务并清空缓存">
停止全部
</button>
</div>
<!-- Logo / 标题 -->
<div class="p-4 border-b border-gray-200">
<h1 class="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
多视图浏览器
</h1>
<p class="text-xs text-gray-500 mt-1">9 个独立浏览器视图</p>
</div>
<!-- 标签页列表 -->
<nav class="flex-1 p-3 space-y-2 overflow-auto">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
账号组
</div>
<button v-for="(tab, tabIndex) in tabs" :key="tab.id" @click="onTabSwitch(tab.id)" :disabled="isLoading"
:class="[
'w-full px-3 py-2.5 rounded-lg text-left transition-all duration-200 flex flex-col',
currentTab === tab.id ? 'bg-blue-50 text-blue-700 border border-blue-200 shadow-sm' : 'text-gray-600 hover:bg-gray-100 border border-transparent',
isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]">
<!-- 第一行组名 + 运行模式 + 活跃组运行时间 -->
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ getGroup(tabIndex)?.name || tab.label }}</span>
<span v-if="rotationStatus?.enabled"
:class="['px-1.5 py-0.5 text-[10px] font-bold rounded', isActiveGroup(getGroup(tabIndex)?.name || tab.label) ? 'bg-emerald-500 text-white' : 'bg-gray-200 text-gray-600']">
{{ isActiveGroup(getGroup(tabIndex)?.name || tab.label) ? '全功能' : '仅回复' }}
</span>
<!-- 活跃组显示运行时间 -->
<span v-if="isActiveGroup(getGroup(tabIndex)?.name || tab.label) && rotationStatus?.enabled"
class="text-[10px] text-white font-mono bg-blue-500 px-1.5 py-0.5 rounded shadow-sm">
{{ elapsedTime }}
</span>
</div>
</div>
<!-- 第二行运行账号数 / 视图ID -->
<div class="flex items-center justify-between w-full mt-1.5 text-xs">
<div class="flex items-center gap-1.5">
<template v-if="getRunningAccounts(getGroup(tabIndex)?.name || tab.label) > 0">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span class="text-emerald-600">{{ getRunningAccounts(getGroup(tabIndex)?.name ||
tab.label)
}} 个运行中</span>
</template>
<template v-else>
<span class="text-gray-500">{{ getTotalAccounts(tabIndex) }} 个账号</span>
</template>
</div>
<span class="text-gray-400 text-[10px]">
视图 {{ tabIndex * 3 + 1 }},{{ tabIndex * 3 + 2 }},{{ tabIndex * 3 + 3 }}
</span>
</div>
</button>
</nav>
<!-- 详细统计 -->
<div class="flex-1 min-h-0 border-t border-gray-200 flex flex-col">
<div
class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50 flex justify-between items-center">
<span>详细统计</span>
<span class="text-[10px] font-normal text-gray-400">招呼/邀请/回复</span>
</div>
<div class="flex-1 overflow-y-auto bg-gray-50/50">
<div v-if="!greetingStats.details || greetingStats.details.length === 0"
class="text-gray-400 text-xs text-center py-4">
暂无统计数据
</div>
<template v-else>
<div v-for="(groupStats, groupName) in statsByGroup" :key="groupName"
class="border-b border-gray-100 last:border-0">
<div class="px-3 py-1.5 bg-gray-100/50 text-xs font-medium text-gray-600">
{{ groupName }}
</div>
<div v-for="stat in groupStats" :key="stat.viewId"
class="px-3 py-1.5 flex items-center justify-between hover:bg-white transition-colors text-xs">
<div class="flex items-center gap-1">
<span class="text-gray-500">视图 {{ stat.viewId }}</span>
<span v-if="stat.unread > 0" class="w-1.5 h-1.5 rounded-full bg-red-500"
:title="`${stat.unread} 条未读消息`"></span>
</div>
<div class="flex items-center gap-3 font-mono text-gray-700">
<span class="text-blue-600 w-6 text-right">{{ stat.greeting }}</span>
<span class="text-gray-300">/</span>
<span class="text-purple-600 w-6 text-right">{{ stat.invite }}</span>
<span class="text-gray-300">/</span>
<span class="text-emerald-600 w-6 text-right">{{ stat.reply }}</span>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 底部运行状态 -->
<div class="p-3 border-t border-gray-200 bg-gray-50">
<div v-if="rotationStatus?.enabled" class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">当前活跃组</span>
<span class="text-emerald-600 font-medium">{{ rotationStatus.currentActiveGroup }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">总运行时间</span>
<span class="text-blue-600 font-mono">{{ totalElapsedTime }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">总运行账号</span>
<span class="text-gray-700">{{ rotationStatus.instanceModes.length }} </span>
</div>
</div>
<div v-else class="text-center text-xs text-gray-400">
未启动任务
</div>
<!-- 统计数据 -->
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1 relative">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已打招呼</span>
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已回复</span>
<button @click="showReplyList"
class="text-blue-500 hover:text-blue-600 hover:underline cursor-pointer" title="查看回复列表">
历史回复列表
</button>
<div class="flex items-center gap-1">
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
</div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已邀请</span>
<button @click="showInviteList"
class="text-purple-500 hover:text-purple-600 hover:underline cursor-pointer" title="查看邀请列表">
回复邀请列表
</button>
<div class="flex items-center gap-1">
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount || 0 }} </span>
</div>
</div>
<!-- 回复列表弹出框 -->
<div v-if="replyListVisible"
class="absolute left-0 right-0 bottom-full mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-48 overflow-hidden"
:style="{ maxWidth: sidebarWidth + 'px' }">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 bg-gray-50">
<span class="text-xs font-semibold text-gray-600">历史回复列表</span>
<button @click="replyListVisible = false" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto max-h-36">
<div v-if="repliedSessions.length === 0" class="text-gray-400 text-xs text-center py-3">
暂无回复记录
</div>
<div v-els e>
<div v-for="(session, index) in repliedSessions" :key="index"
class="px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 border-b border-gray-50 last:border-0 flex items-center justify-between">
<div class="truncate w-16" :title="session.name">
{{ session.name }}
</div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-[10px]">
视图: {{ session.viewId }}
</span>
<button @click="copyAnchorId(session.anchorId)"
class="text-blue-500 hover:text-blue-600 p-1 rounded hover:bg-blue-50 transition-colors"
title="复制 ID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 邀请列表弹出框 -->
<div v-if="inviteListVisible"
class="absolute left-0 right-0 bottom-full mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-48 overflow-hidden"
:style="{ maxWidth: sidebarWidth + 'px' }">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 bg-gray-50">
<span class="text-xs font-semibold text-purple-600">邀请列表</span>
<button @click="inviteListVisible = false" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto max-h-36">
<div v-if="invitedSessions.length === 0" class="text-gray-400 text-xs text-center py-3">
暂无邀请记录
</div>
<div v-else>
<div v-for="(session, index) in invitedSessions" :key="index"
class="px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 border-b border-gray-50 last:border-0 flex items-center justify-between">
<div class="truncate w-16" :title="session.name">
{{ session.name }}
</div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-[10px]">
视图: {{ session.viewId }}
</span>
<button @click="copyAnchorId(session.anchorId)"
class="text-purple-500 hover:text-purple-600 p-1 rounded hover:bg-purple-50 transition-colors"
title="复制 ID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
tabs: { type: Array, required: true },
currentTab: { type: String, required: true },
isLoading: { type: Boolean, default: false },
accountGroups: { type: Array, default: () => [] },
rotationStatus: { type: Object, default: undefined },
greetingStats: {
type: Object,
default: () => ({ greetingCount: 0, inviteCount: 0 })
},
automationLogs: { type: Array, default: () => [] },
sidebarWidth: { type: Number, default: 144 }
})
const emit = defineEmits(['tabSwitch', 'goBack', 'stopAll'])
// 回复列表相关
const replyListVisible = ref(false)
const inviteListVisible = ref(false)
/** @type {import('vue').Ref<Array<{name: string; anchorId: string; viewId: number; invited: boolean}>>} */
const repliedSessions = ref([])
/** @type {import('vue').Ref<Array<{name: string; anchorId: string; viewId: number; invited: boolean}>>} */
const invitedSessions = ref([])
// 显示回复列表
const showReplyList = async () => {
replyListVisible.value = !replyListVisible.value
inviteListVisible.value = false
if (replyListVisible.value && window.electronAPI?.getRepliedSessions) {
try {
const result = await window.electronAPI.getRepliedSessions()
console.log("回复列表里是", result)
// 按倒序展示,最新的在最前面
// 过滤出未邀请的会话
repliedSessions.value = (result || []).filter(session => !session.invited).reverse()
} catch (e) {
console.error('获取回复列表失败:', e)
repliedSessions.value = []
}
}
}
// 显示邀请列表
const showInviteList = async () => {
inviteListVisible.value = !inviteListVisible.value
replyListVisible.value = false
if (inviteListVisible.value && window.electronAPI?.getRepliedSessions) {
try {
const result = await window.electronAPI.getRepliedSessions()
console.log("邀请列表里是", result)
// 按倒序展示,最新的在最前面
// 过滤出已邀请的会话
invitedSessions.value = (result || []).filter(session => session.invited).reverse()
} catch (e) {
console.error('获取邀请列表失败:', e)
invitedSessions.value = []
}
}
}
// 复制主播 ID
const copyAnchorId = (id) => {
// 去除前面的 @ 符号
const cleanId = id.startsWith('@') ? id.substring(1) : id
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器使用 Clipboard API
navigator.clipboard.writeText(cleanId).then(() => {
showCopySuccess()
}).catch(err => {
console.error('复制失败:', err)
// 回退到传统方法
fallbackCopyTextToClipboard(cleanId)
})
} else {
// 传统方法
fallbackCopyTextToClipboard(cleanId)
}
}
// 回退复制方法(兼容旧浏览器)
const 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 successful = document.execCommand('copy')
if (successful) {
showCopySuccess()
} else {
console.error('复制命令执行失败')
}
} catch (err) {
console.error('回退复制失败:', err)
} finally {
document.body.removeChild(textArea)
}
}
// 复制成功提示
const showCopySuccess = () => {
// 回退到 alert
ElMessage.success('复制成功!')
}
// Event handlers
const onTabSwitch = (id) => emit('tabSwitch', id)
const onGoBack = () => emit('goBack')
const onStopAll = () => emit('stopAll')
// Helper functions
const getGroup = (index) => props.accountGroups[index]
const statsByGroup = computed(() => {
const map = {}
if (props.greetingStats?.details) {
props.greetingStats.details.forEach(stat => {
const groupName = stat.group || '未分组'
if (!map[groupName]) map[groupName] = []
map[groupName].push(stat)
})
}
return map
})
const isActiveGroup = (groupName) => {
if (!props.rotationStatus?.enabled) return false
return props.rotationStatus.currentActiveGroup === groupName
}
const getRunningAccounts = (groupName) => {
return props.rotationStatus?.instanceModes.filter(i => i.group === groupName).length || 0
}
const getTotalAccounts = (index) => {
return props.accountGroups[index]?.accounts?.filter(a => a.email && a.pwd).length || 0
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false })
}
// Timer logic
const elapsedTime = ref('00:00')
const totalElapsedTime = ref('00:00')
let timer1 = null
let timer2 = null
watch(() => props.rotationStatus?.modeStartTime, (newVal) => {
if (timer1) clearInterval(timer1)
if (!newVal) {
elapsedTime.value = '00:00'
return
}
const update = () => {
const elapsed = Math.floor((Date.now() - newVal) / 1000)
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
elapsedTime.value = `${minutes}:${seconds}`
}
update()
timer1 = setInterval(update, 1000)
}, { immediate: true })
watch(() => props.rotationStatus?.totalStartTime, (newVal) => {
if (timer2) clearInterval(timer2)
if (!newVal) {
totalElapsedTime.value = '00:00'
return
}
const update = () => {
const elapsed = Math.floor((Date.now() - (newVal || 0)) / 1000)
const hours = Math.floor(elapsed / 3600)
const minutes = Math.floor((elapsed % 3600) / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
if (hours > 0) {
totalElapsedTime.value = `${hours}:${minutes}:${seconds}`
} else {
totalElapsedTime.value = `${minutes}:${seconds}`
}
}
update()
timer2 = setInterval(update, 1000)
}, { immediate: true })
onUnmounted(() => {
if (timer1) clearInterval(timer1)
if (timer2) clearInterval(timer2)
})
</script>