大哥 主播 即时消息 三合一

This commit is contained in:
2026-02-04 19:56:19 +08:00
parent 85e5d1ccb7
commit 791560af2e
52 changed files with 8324 additions and 4611 deletions

View File

@@ -1,102 +0,0 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
interface AIConfig {
agentName: string
guildName: string
contactTool: string
contact: string
}
interface AIConfigDialogProps {
visible: boolean
config: AIConfig
onClose: () => void
onSave: () => void
onChange: (key: keyof AIConfig, value: string) => void
}
function AIConfigDialog({ visible, config, onClose, onSave, onChange }: AIConfigDialogProps) {
// 锁定 Body 滚动
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [visible])
if (!visible) return null
return createPortal(
<div className="fixed inset-0 bg-black/50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6 mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
placeholder="请输入经纪人名字"
value={config.agentName}
onChange={(e) => onChange('agentName', e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
placeholder="请输入公会名字"
value={config.guildName}
onChange={(e) => onChange('guildName', e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
placeholder="例如:微信 / Telegram"
value={config.contactTool}
onChange={(e) => onChange('contactTool', e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
placeholder="请输入联系方式"
value={config.contact}
onChange={(e) => onChange('contact', e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
</button>
<button
onClick={onSave}
className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
</div>,
document.body
)
}
export default AIConfigDialog

View File

@@ -0,0 +1,75 @@
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 bg-black/50 flex items-center justify-center" style="z-index: 9999">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md p-6 mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">设置经纪信息</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">经纪人</label>
<input type="text" placeholder="请输入经纪人名字" :value="config.agentName"
@input="onChange('agentName', $event.target.value)"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">公会</label>
<input type="text" placeholder="请输入公会名字" :value="config.guildName"
@input="onChange('guildName', $event.target.value)"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系工具</label>
<input type="text" placeholder="例如:微信 / Telegram" :value="config.contactTool"
@input="onChange('contactTool', $event.target.value)"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系方式</label>
<input type="text" placeholder="请输入联系方式" :value="config.contact"
@input="onChange('contact', $event.target.value)"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="onSave" class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
required: true
},
config: {
type: Object,
required: true
}
})
const emit = defineEmits(['close', 'save', 'change'])
const onClose = () => emit('close')
const onSave = () => emit('save')
const onChange = (key, value) => emit('change', key, value)
// 锁定 Body 滚动
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>

View File

@@ -1,150 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { isElectron } from '../utils/electronBridge'
interface Account {
email: string
pwd: string
}
interface AutomationLog {
viewId: number
level: 'info' | 'warn' | 'error'
message: string
}
interface AutomationPanelProps {
viewId: number
}
function AutomationPanel({ viewId }: AutomationPanelProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isRunning, setIsRunning] = useState(false)
const [logs, setLogs] = useState<string[]>([])
// 监听自动化日志
useEffect(() => {
if (!isElectron()) return
const unsubscribe = window.electronAPI!.onAutomationLog((log: AutomationLog) => {
if (log.viewId === viewId) {
setLogs(prev => [...prev.slice(-49), log.message])
}
})
return unsubscribe
}, [viewId])
const handleStart = useCallback(async () => {
if (!isElectron()) {
setLogs(prev => [...prev, '❌ 非 Electron 环境,无法启动自动化'])
return
}
if (!email || !password) {
setLogs(prev => [...prev, '❌ 请输入邮箱和密码'])
return
}
const account: Account = { email, pwd: password }
setIsRunning(true)
setLogs(prev => [...prev, `🚀 启动自动化: ${email}`])
const result = await window.electronAPI!.startTikTokAutomation(viewId, account)
if (!result.success) {
setLogs(prev => [...prev, `❌ 启动失败: ${result.error}`])
setIsRunning(false)
}
}, [email, password, viewId])
const handleStop = useCallback(async () => {
if (!isElectron()) return
const result = await window.electronAPI!.stopTikTokAutomation(viewId)
if (result.success) {
setLogs(prev => [...prev, '⏹️ 自动化已停止'])
}
setIsRunning(false)
}, [viewId])
return (
<div className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-slate-200">
TikTok - {viewId}
</h3>
{/* 账号配置 */}
<div className="space-y-3">
<input
type="email"
placeholder="TikTok 邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 rounded-lg bg-slate-700 border border-slate-600
text-slate-200 text-sm placeholder-slate-400
focus:outline-none focus:border-primary-500
disabled:opacity-50 disabled:cursor-not-allowed"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 rounded-lg bg-slate-700 border border-slate-600
text-slate-200 text-sm placeholder-slate-400
focus:outline-none focus:border-primary-500
disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* 控制按钮 */}
<div className="flex gap-2">
{!isRunning ? (
<button
onClick={handleStart}
className="flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-gradient-to-r from-emerald-500 to-teal-500 text-white
hover:from-emerald-400 hover:to-teal-400 transition-all"
>
</button>
) : (
<button
onClick={handleStop}
className="flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-gradient-to-r from-red-500 to-rose-500 text-white
hover:from-red-400 hover:to-rose-400 transition-all"
>
</button>
)}
</div>
{/* 日志区域 */}
<div className="mt-4">
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
</div>
<div className="h-40 overflow-y-auto bg-slate-900/50 rounded-lg p-3
text-xs text-slate-400 font-mono space-y-1
border border-slate-700/50">
{logs.length === 0 ? (
<div className="text-slate-600">...</div>
) : (
logs.map((log, i) => (
<div key={i} className="break-all">{log}</div>
))
)}
</div>
</div>
</div>
)
}
export default AutomationPanel

View File

@@ -0,0 +1,117 @@
<template>
<div class="p-4 space-y-4">
<h3 class="text-lg font-semibold text-slate-200">
TikTok 自动化 - 视图 {{ viewId }}
</h3>
<!-- 账号配置 -->
<div class="space-y-3">
<input type="email" placeholder="TikTok 邮箱" v-model="email" :disabled="isRunning" class="w-full px-3 py-2 rounded-lg bg-slate-700 border border-slate-600
text-slate-200 text-sm placeholder-slate-400
focus:outline-none focus:border-primary-500
disabled:opacity-50 disabled:cursor-not-allowed" />
<input type="password" placeholder="密码" v-model="password" :disabled="isRunning" class="w-full px-3 py-2 rounded-lg bg-slate-700 border border-slate-600
text-slate-200 text-sm placeholder-slate-400
focus:outline-none focus:border-primary-500
disabled:opacity-50 disabled:cursor-not-allowed" />
</div>
<!-- 控制按钮 -->
<div class="flex gap-2">
<button v-if="!isRunning" @click="handleStart" class="flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-gradient-to-r from-emerald-500 to-teal-500 text-white
hover:from-emerald-400 hover:to-teal-400 transition-all">
启动
</button>
<button v-else @click="handleStop" class="flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-gradient-to-r from-red-500 to-rose-500 text-white
hover:from-red-400 hover:to-rose-400 transition-all">
停止
</button>
</div>
<!-- 日志区域 -->
<div class="mt-4">
<div class="text-xs font-semibold text-slate-500 uppercase mb-2">
运行日志
</div>
<div class="h-40 overflow-y-auto bg-slate-900/50 rounded-lg p-3
text-xs text-slate-400 font-mono space-y-1
border border-slate-700/50">
<div v-if="logs.length === 0" class="text-slate-600">暂无日志...</div>
<div v-else v-for="(log, i) in logs" :key="i" class="break-all">{{ log }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
const props = defineProps({
viewId: {
type: Number,
required: true
}
})
const email = ref('')
const password = ref('')
const isRunning = ref(false)
const logs = ref([])
// 监听自动化日志
let unsubscribe = null
watch(() => props.viewId, (newViewId) => {
if (!isElectron()) return
if (unsubscribe) unsubscribe()
unsubscribe = window.electronAPI.onAutomationLog((log) => {
if (log.viewId === newViewId) {
logs.value = [...logs.value.slice(-49), log.message]
}
})
}, { immediate: true })
onUnmounted(() => {
if (unsubscribe) unsubscribe()
})
const handleStart = async () => {
if (!isElectron()) {
logs.value = [...logs.value, '❌ 非 Electron 环境,无法启动自动化']
return
}
if (!email.value || !password.value) {
logs.value = [...logs.value, '❌ 请输入邮箱和密码']
return
}
const account = { email: email.value, pwd: password.value }
isRunning.value = true
logs.value = [...logs.value, `🚀 启动自动化: ${email.value}`]
const result = await window.electronAPI.startTikTokAutomation(props.viewId, account)
if (!result.success) {
logs.value = [...logs.value, `❌ 启动失败: ${result.error}`]
isRunning.value = false
}
}
const handleStop = async () => {
if (!isElectron()) return
const result = await window.electronAPI.stopTikTokAutomation(props.viewId)
if (result.success) {
logs.value = [...logs.value, '⏹️ 自动化已停止']
}
isRunning.value = false
}
</script>

View File

@@ -1,500 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { getRegions, getLanguagesForRegions } from '../utils/regionLanguageMapper'
import { isElectron } from '../utils/electronBridge'
const STORAGE_KEY = 'greeting_dialog_data'
// 获取大区列表
const REGION_LIST = getRegions()
interface GreetingDialogProps {
visible: boolean
onClose: () => void
onConfirm: (data: { sentences: string[]; translations: Record<string, string[]>; needTranslate: boolean }) => void
}
function GreetingDialog({ visible, onClose, onConfirm }: GreetingDialogProps) {
const [sentences, setSentences] = useState<string[]>([''])
const [bulkText, setBulkText] = useState('')
const [selectedRegions, setSelectedRegions] = useState<string[]>([])
const [translations, setTranslations] = useState<Record<string, string[]>>({})
const [activeTab, setActiveTab] = useState('')
const [needTranslate, setNeedTranslate] = useState(false)
const [isTranslating, setIsTranslating] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
// 根据选中的大区获取语言列表
const selectedLanguages = getLanguagesForRegions(selectedRegions)
// 当选中的大区变化时,检查并更新 activeTab
useEffect(() => {
if (selectedLanguages.length > 0) {
// 如果当前 activeTab 不在新的语言列表中,切换到第一个有翻译的语言
if (!selectedLanguages.includes(activeTab)) {
const firstLangWithTranslation = selectedLanguages.find(lang => translations[lang])
setActiveTab(firstLangWithTranslation || selectedLanguages[0])
}
}
}, [selectedRegions, selectedLanguages, activeTab, translations])
const filteredRegions = REGION_LIST.filter(r =>
r.toLowerCase().includes(searchTerm.toLowerCase())
)
// 初始化时从 localStorage 加载数据
useEffect(() => {
if (!visible) return
document.body.style.overflow = 'hidden'
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const data = JSON.parse(saved)
if (data.sentences?.length) setSentences(data.sentences)
if (data.selectedRegions?.length) setSelectedRegions(data.selectedRegions)
if (data.translations) setTranslations(data.translations)
if (typeof data.needTranslate === 'boolean') setNeedTranslate(data.needTranslate)
if (data.activeTab) setActiveTab(data.activeTab)
} catch (e) {
console.error('加载本地数据失败:', e)
}
}
return () => {
document.body.style.overflow = ''
}
}, [visible])
// 保存数据到 localStorage
const saveToStorage = useCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
sentences,
selectedRegions,
translations,
needTranslate,
activeTab,
}))
}, [sentences, selectedRegions, translations, needTranslate, activeTab])
useEffect(() => {
if (visible) {
saveToStorage()
}
}, [visible, saveToStorage])
const addSentence = () => {
setSentences(prev => [...prev, ''])
}
const updateSentence = (index: number, value: string) => {
setSentences(prev => {
const updated = [...prev]
updated[index] = value
return updated
})
}
const removeSentence = (index: number) => {
setSentences(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault()
const text = e.clipboardData.getData('text')
setBulkText(text)
const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
setSentences(lines.length ? lines : [''])
}
const handleBulkChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value
setBulkText(text)
const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
setSentences(lines.length ? lines : [''])
}
const clearAll = () => {
setSentences([''])
setBulkText('')
setTranslations({})
}
const toggleRegion = (region: string) => {
setSelectedRegions(prev =>
prev.includes(region)
? prev.filter(r => r !== region)
: [...prev, region]
)
}
// 调用真实翻译 API
const [isFetching, setIsFetching] = useState(false)
const fetchPrologue = async () => {
if (!isElectron()) {
alert('此功能仅在 Electron 环境中可用')
return
}
if (sentences.some(s => s.trim()) && !confirm('当前已有内容,获取新内容将清空现有内容,是否继续?')) {
return
}
setIsFetching(true)
try {
console.log('[GreetingDialog] 开始获取打招呼内容...')
const result = await window.electronAPI!.fetchPrologue()
console.log('[GreetingDialog] 获取结果:', result)
if (result.success && result.data && Array.isArray(result.data)) {
console.log('[GreetingDialog] 更新 sentences:', result.data.length, '条')
setSentences(result.data)
setTranslations({}) // Clear translations as source changed
} else {
console.error('[GreetingDialog] 数据格式错误:', result)
alert(result.error || '获取失败:格式错误')
}
} catch (e) {
console.error('获取失败:', e)
alert('获取失败,请重试')
} finally {
setIsFetching(false)
}
}
const handleTranslate = async () => {
if (!isElectron()) {
alert('此功能仅在 Electron 环境中可用')
return
}
const validSentences = sentences.filter(Boolean)
if (validSentences.length === 0 || selectedRegions.length === 0) return
// 获取选中大区的所有语言
const languagesToTranslate = getLanguagesForRegions(selectedRegions)
if (languagesToTranslate.length === 0) {
alert('选中的大区没有可翻译的语言')
return
}
setIsTranslating(true)
try {
const newTranslations: Record<string, string[]> = {}
// 对每种语言并行翻译所有句子
// API 支持批量翻译,使用 \n 分隔
const joinedText = validSentences.join('\n')
await Promise.all(languagesToTranslate.map(async (lang) => {
try {
const result = await window.electronAPI!.translate(joinedText, lang)
if (result.success) {
// 将结果按换行符分割回数组
// 注意API 返回的结果可能会有额外的空行或格式差异,尽量匹配
let translatedLines = result.result.split('\n').map((s: string) => s.trim())
// 去除第一条开头的 { 和最后一条结尾的 }
if (translatedLines.length > 0) {
if (translatedLines[0].startsWith('{')) {
translatedLines[0] = translatedLines[0].slice(1).trim()
}
const lastIdx = translatedLines.length - 1
if (translatedLines[lastIdx].endsWith('}')) {
translatedLines[lastIdx] = translatedLines[lastIdx].slice(0, -1).trim()
}
}
// 如果返回行数少于原行数,用空字符串补齐;如果多于,截取
const finalSentences: string[] = []
let transIndex = 0
for (let i = 0; i < sentences.length; i++) {
if (sentences[i]) {
// 这是一个非空原句,取下一个翻译结果
finalSentences.push(translatedLines[transIndex] || sentences[i])
transIndex++
} else {
// 这是空行,保留空行
finalSentences.push('')
}
}
newTranslations[lang] = finalSentences
} else {
// 翻译失败,保留原文
newTranslations[lang] = sentences
}
} catch (e) {
console.error(`Lang ${lang} translate error:`, e)
newTranslations[lang] = sentences
}
}))
setTranslations(newTranslations)
setActiveTab(languagesToTranslate[0] || '')
} catch (error) {
console.error('翻译失败:', error)
alert('翻译失败,请重试')
} finally {
setIsTranslating(false)
}
}
const handleConfirm = () => {
onConfirm({
sentences: sentences.filter(Boolean),
translations,
needTranslate,
})
onClose()
}
// 获取语言标签
const getLangLabel = (langCode: string) => {
const langNames: Record<string, string> = {
'ar': '阿拉伯语', 'es': '西班牙语', 'en': '英语', 'fr': '法语',
'pt': '葡萄牙语', 'de': '德语', 'it': '意大利语', 'ja': '日语',
'ko': '韩语', 'zh-TW': '繁体中文', 'id': '印尼语', 'ms': '马来语',
'tl': '菲律宾语', 'th': '泰语', 'vi': '越南语', 'tr': '土耳其语',
'ro': '罗马尼亚语', 'pl': '波兰语', 'nl': '荷兰语', 'hy': '亚美尼亚语',
'az': '阿塞拜疆语', 'be': '白俄罗斯语', 'ka': '格鲁吉亚语', 'ky': '吉尔吉斯语',
'kk': '哈萨克语', 'tg': '塔吉克语', 'tk': '土库曼语', 'uk': '乌克兰语',
'uz': '乌兹别克语', 'da': '丹麦语', 'fi': '芬兰语', 'is': '冰岛语',
'no': '挪威语', 'sv': '瑞典语', 'cs': '捷克语', 'hu': '匈牙利语',
'sk': '斯洛伐克语', 'et': '爱沙尼亚语', 'lt': '立陶宛语', 'lv': '拉脱维亚语',
'sq': '阿尔巴尼亚语', 'bs': '波斯尼亚语', 'bg': '保加利亚语', 'el': '希腊语',
'hr': '克罗地亚语', 'sr': '塞尔维亚语', 'mk': '马其顿语', 'sl': '斯洛文尼亚语',
'mt': '马耳他语', 'ca': '加泰罗尼亚语', 'sm': '萨摩亚语', 'to': '汤加语',
'bi': '比斯拉马语', 'so': '索马里语', 'kl': '格陵兰语',
}
return langNames[langCode] || langCode
}
if (!visible) return null
return createPortal(
<div className="fixed inset-0 bg-black/50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<button onClick={onClose} className="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* 源文本区 */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-3">
<span className="font-medium text-gray-800"></span>
<div className="flex gap-2">
<button onClick={addSentence} className="px-3 py-1 text-sm bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200">
</button>
<button
onClick={fetchPrologue}
disabled={isFetching || !isElectron()}
className="px-3 py-1 text-sm bg-purple-100 text-purple-700 border border-purple-300 rounded hover:bg-purple-200 disabled:opacity-50"
>
{isFetching ? '获取中...' : '从服务端获取'}
</button>
<button onClick={clearAll} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 border border-gray-300 rounded hover:bg-gray-200">
</button>
</div>
</div>
<textarea
value={bulkText || sentences.join('\n')}
onChange={handleBulkChange}
onPaste={handlePaste}
placeholder="每行一句打招呼内容..."
className="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none"
/>
<div className="text-xs text-gray-500 mt-2">
提示: 每行一句
</div>
</div>
{/* 翻译开关 */}
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={needTranslate}
onChange={(e) => setNeedTranslate(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
{/* 大区选择与翻译区 */}
{needTranslate && (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-gray-800 text-lg"></h3>
<div className="flex gap-2">
<input
type="text"
placeholder="搜索大区..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded text-sm w-40 focus:border-blue-500 focus:outline-none"
/>
<button
onClick={handleTranslate}
disabled={isTranslating || selectedRegions.length === 0 || !isElectron()}
className="px-4 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{isTranslating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</>
) : (
<span> ({selectedLanguages.length} )</span>
)}
</button>
</div>
</div>
{/* 大区选择网格 */}
<div className="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 gap-2 max-h-48 overflow-y-auto mb-4 p-1 border rounded bg-gray-50">
{filteredRegions.map(region => (
<div
key={region}
onClick={() => toggleRegion(region)}
className={`
cursor-pointer text-center py-2 px-2 text-sm rounded border transition-all select-none
${selectedRegions.includes(region)
? 'bg-blue-50 border-blue-500 text-blue-600 font-medium shadow-sm'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:shadow-sm'
}
`}
>
{region}
</div>
))}
{filteredRegions.length === 0 && (
<div className="col-span-full text-center py-8 text-gray-400">
</div>
)}
</div>
{/* 选中大区的语言预览 */}
{selectedRegions.length > 0 && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div className="text-sm text-blue-800 font-medium mb-2">
{selectedRegions.length} {selectedLanguages.length}
</div>
<div className="flex flex-wrap gap-1">
{selectedLanguages.map(lang => (
<span key={lang} className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
{getLangLabel(lang)}
</span>
))}
</div>
</div>
)}
{/* 翻译结果标签页 */}
{selectedLanguages.length > 0 && Object.keys(translations).length > 0 && (
<div>
<div className="flex gap-1 border-b border-gray-200 mb-3 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{selectedLanguages.filter(lang => translations[lang]).map(lang => (
<button
key={lang}
onClick={() => setActiveTab(lang)}
className={`px-3 py-2 text-sm border-b-2 transition-all whitespace-nowrap flex-shrink-0 ${activeTab === lang
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-700 hover:text-gray-700'
}`}
>
{getLangLabel(lang)}
</button>
))}
</div>
{/* 当前语言的翻译结果 */}
<div className="space-y-2 max-h-40 overflow-auto">
{translations[activeTab]?.length ? (
translations[activeTab].map((t, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={t}
onChange={(e) => {
const newTrans = { ...translations }
newTrans[activeTab][i] = e.target.value
setTranslations(newTrans)
}}
className="flex-1 px-3 py-1.5 text-sm text-gray-900 border border-gray-300 rounded focus:border-blue-500 focus:outline-none"
/>
<div className="relative group/source">
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded cursor-help hover:bg-blue-100 transition-colors border border-blue-200">
</span>
{/* 悬停时显示源文本 - 向左展开 */}
<div className="absolute top-1/2 right-full -translate-y-1/2 mr-2 hidden group-hover/source:block z-50">
<div className="bg-white text-gray-800 text-sm px-4 py-2 rounded-lg shadow-xl border border-gray-200 min-w-[280px] max-w-[400px]">
{sentences[i] || '(空)'}
</div>
</div>
</div>
{/* 删除按钮 */}
<button
onClick={() => {
// 只删除当前语言的这条翻译
const newTrans = { ...translations }
newTrans[activeTab] = newTrans[activeTab].filter((_, idx) => idx !== i)
setTranslations(newTrans)
}}
className="text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded transition-colors"
title="删除此行"
>
</button>
</div>
))
) : (
<div className="text-sm text-gray-700"></div>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* 底部 */}
<div className="p-4 border-t border-gray-200 flex justify-between items-center">
<span className="text-xs text-gray-500">
{sentences.filter(Boolean).length} · {selectedRegions.length} · {selectedLanguages.length}
</span>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
export default GreetingDialog

View File

@@ -0,0 +1,465 @@
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 bg-black/50 flex items-center justify-center" style="z-index: 9999">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">打招呼内容</h3>
<button @click="onClose" class="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<div class="flex-1 overflow-auto p-4 space-y-4">
<!-- 源文本区 -->
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex items-center justify-between mb-3">
<span class="font-medium text-gray-800">源文本</span>
<div class="flex gap-2">
<button @click="addSentence"
class="px-3 py-1 text-sm bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200">
新增一行
</button>
<button @click="fetchPrologue" :disabled="isFetching || !isElectronEnv"
class="px-3 py-1 text-sm bg-purple-100 text-purple-700 border border-purple-300 rounded hover:bg-purple-200 disabled:opacity-50">
{{ isFetching ? '获取中...' : '从服务端获取' }}
</button>
<button @click="clearAll"
class="px-3 py-1 text-sm bg-gray-100 text-gray-700 border border-gray-300 rounded hover:bg-gray-200">
清空
</button>
</div>
</div>
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange" @paste="handlePaste"
placeholder="每行一句打招呼内容..."
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
<div class="text-xs text-gray-500 mt-2">
提示: 每行一句可直接粘贴多行文本
</div>
</div>
<!-- 翻译开关 -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="needTranslate" class="w-4 h-4" />
<span class="text-sm text-gray-700">启用翻译</span>
</label>
</div>
<!-- 大区选择与翻译区 -->
<div v-if="needTranslate" class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-medium text-gray-800 text-lg">选择大区</h3>
<div class="flex gap-2">
<input type="text" placeholder="搜索大区..." v-model="searchTerm"
class="px-3 py-1.5 border border-gray-300 rounded text-sm w-40 focus:border-blue-500 focus:outline-none" />
<button @click="handleTranslate"
:disabled="isTranslating || selectedRegions.length === 0 || !isElectronEnv"
class="px-4 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center gap-2 transition-colors">
<template v-if="isTranslating">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin">
</div>
<span>翻译中...</span>
</template>
<template v-else>
<span>翻译 ({{ selectedLanguages.length }} 种语言)</span>
</template>
</button>
</div>
</div>
<!-- 大区选择网格 -->
<div
class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 gap-2 max-h-48 overflow-y-auto mb-4 p-1 border rounded bg-gray-50">
<div v-for="region in filteredRegions" :key="region" @click="toggleRegion(region)" :class="[
'cursor-pointer text-center py-2 px-2 text-sm rounded border transition-all select-none',
selectedRegions.includes(region) ? 'bg-blue-50 border-blue-500 text-blue-600 font-medium shadow-sm' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:shadow-sm'
]">
{{ region }}
</div>
<div v-if="filteredRegions.length === 0" class="col-span-full text-center py-8 text-gray-400">
未找到相关大区
</div>
</div>
<!-- 选中大区的语言预览 -->
<div v-if="selectedRegions.length > 0" class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div class="text-sm text-blue-800 font-medium mb-2">
选中 {{ selectedRegions.length }} 个大区将翻译以下 {{ selectedLanguages.length }} 种语言
</div>
<div class="flex flex-wrap gap-1">
<span v-for="lang in selectedLanguages" :key="lang"
class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
{{ getLangLabel(lang) }}
</span>
</div>
</div>
<!-- 翻译结果标签页 -->
<div v-if="selectedLanguages.length > 0 && Object.keys(translations).length > 0">
<div class="flex gap-1 border-b border-gray-200 mb-3 overflow-x-auto pb-1"
style="scrollbar-width: thin">
<button v-for="lang in selectedLanguages.filter(l => translations[l])" :key="lang"
@click="activeTab = lang" :class="[
'px-3 py-2 text-sm border-b-2 transition-all whitespace-nowrap flex-shrink-0',
activeTab === lang ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-700 hover:text-gray-700'
]">
{{ getLangLabel(lang) }}
</button>
</div>
<!-- 当前语言的翻译结果 -->
<div class="space-y-2 max-h-40 overflow-auto">
<template v-if="translations[activeTab]?.length">
<div v-for="(t, i) in translations[activeTab]" :key="i" class="flex items-center gap-2">
<input type="text" :value="t" @input="updateTranslation($event.target.value, i)"
class="flex-1 px-3 py-1.5 text-sm text-gray-900 border border-gray-300 rounded focus:border-blue-500 focus:outline-none" />
<div class="relative group/source">
<span
class="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded cursor-help hover:bg-blue-100 transition-colors border border-blue-200">
</span>
<!-- 悬停时显示源文本 - 向左展开 -->
<div
class="absolute top-1/2 right-full -translate-y-1/2 mr-2 hidden group-hover/source:block z-50">
<div
class="bg-white text-gray-800 text-sm px-4 py-2 rounded-lg shadow-xl border border-gray-200 min-w-[280px] max-w-[400px]">
{{ sentences[i] || '(空)' }}
</div>
</div>
</div>
<!-- 删除按钮 -->
<button @click="removeTranslation(i)"
class="text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded transition-colors"
title="删除此行">
</button>
</div>
</template>
<div v-else class="text-sm text-gray-700">无数据点击翻译获取</div>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span class="text-xs text-gray-500">
{{ sentences.filter(Boolean).length }} · 选择 {{ selectedRegions.length }} 个大区 · {{
selectedLanguages.length }} 种语言
</span>
<div class="flex gap-3">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleConfirm"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
确定
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getRegions, getLanguagesForRegions } from '../utils/regionLanguageMapper'
import { isElectron } from '../utils/electronBridge'
const props = defineProps({
visible: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'confirm'])
const STORAGE_KEY = 'greeting_dialog_data'
const REGION_LIST = getRegions()
const sentences = ref([''])
const bulkText = ref('')
const selectedRegions = ref([])
const translations = ref({})
const activeTab = ref('')
const needTranslate = ref(false)
const isTranslating = ref(false)
const searchTerm = ref('')
const isFetching = ref(false)
const isElectronEnv = isElectron()
// Helper Functions
const getLangLabel = (langCode) => {
const langNames = {
'ar': '阿拉伯语', 'es': '西班牙语', 'en': '英语', 'fr': '法语',
'pt': '葡萄牙语', 'de': '德语', 'it': '意大利语', 'ja': '日语',
'ko': '韩语', 'zh-TW': '繁体中文', 'id': '印尼语', 'ms': '马来语',
'tl': '菲律宾语', 'th': '泰语', 'vi': '越南语', 'tr': '土耳其语',
'ro': '罗马尼亚语', 'pl': '波兰语', 'nl': '荷兰语', 'hy': '亚美尼亚语',
'az': '阿塞拜疆语', 'be': '白俄罗斯语', 'ka': '格鲁吉亚语', 'ky': '吉尔吉斯语',
'kk': '哈萨克语', 'tg': '塔吉克语', 'tk': '土库曼语', 'uk': '乌克兰语',
'uz': '乌兹别克语', 'da': '丹麦语', 'fi': '芬兰语', 'is': '冰岛语',
'no': '挪威语', 'sv': '瑞典语', 'cs': '捷克语', 'hu': '匈牙利语',
'sk': '斯洛伐克语', 'et': '爱沙尼亚语', 'lt': '立陶宛语', 'lv': '拉脱维亚语',
'sq': '阿尔巴尼亚语', 'bs': '波斯尼亚语', 'bg': '保加利亚语', 'el': '希腊语',
'hr': '克罗地亚语', 'sr': '塞尔维亚语', 'mk': '马其顿语', 'sl': '斯洛文尼亚语',
'mt': '马耳他语', 'ca': '加泰罗尼亚语', 'sm': '萨摩亚语', 'to': '汤加语',
'bi': '比斯拉马语', 'so': '索马里语', 'kl': '格陵兰语',
}
return langNames[langCode] || langCode
}
const filteredRegions = computed(() => {
return REGION_LIST.filter(r => r.toLowerCase().includes(searchTerm.value.toLowerCase()))
})
const selectedLanguages = computed(() => {
return getLanguagesForRegions(selectedRegions.value)
})
// Lifecycle
onMounted(() => {
if (props.visible) {
document.body.style.overflow = 'hidden'
loadFromStorage()
}
})
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
loadFromStorage()
} else {
document.body.style.overflow = ''
}
})
// Auto save
watch([sentences, selectedRegions, translations, needTranslate, activeTab], () => {
if (props.visible) {
saveToStorage()
}
}, { deep: true })
watch(selectedLanguages, (newLangs) => {
if (newLangs.length > 0) {
if (!newLangs.includes(activeTab.value)) {
const firstLangWithTranslation = newLangs.find(lang => translations.value[lang])
activeTab.value = firstLangWithTranslation || newLangs[0]
}
}
})
function loadFromStorage() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const data = JSON.parse(saved)
if (data.sentences?.length) sentences.value = data.sentences
if (data.selectedRegions?.length) selectedRegions.value = data.selectedRegions
if (data.translations) translations.value = data.translations
if (typeof data.needTranslate === 'boolean') needTranslate.value = data.needTranslate
if (data.activeTab) activeTab.value = data.activeTab
} catch (e) {
console.error('加载本地数据失败:', e)
}
}
}
function saveToStorage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
sentences: sentences.value,
selectedRegions: selectedRegions.value,
translations: translations.value,
needTranslate: needTranslate.value,
activeTab: activeTab.value,
}))
}
// Actions
const onClose = () => emit('close')
const addSentence = () => {
sentences.value.push('')
}
const handlePaste = (e) => {
// e.preventDefault() handled by Vue if needed but standard logic applies
// We can rely on @paste event
// prevent default to handle manually
// Actually Vue @paste doesn't prevent default automatically.
// Let's grab data and prevent default.
/* Note: In Vue script setup, event is passed directly */
}
const handleBulkChange = (e) => {
const text = e.target.value
bulkText.value = text
const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
sentences.value = lines.length ? lines : ['']
}
// We need to implement handlePaste manually to match React logic slightly better if we want exact same behavior
// but textarea default paste behavior + v-model or @input works too.
// However, the React code did:
// e.preventDefault(); getData; setBulkText; split...
// So we should do the same to strip formatting etc.
// In the template I used @paste="handlePaste"
// Let's refine handlePaste:
const handlePasteEvent = (e) => {
// e is the event
// But I used handlePaste name above which was empty
}
// Re-defining for clarity/correctness
const onPaste = (e) => {
// e is clipboard event
// But actually simpler to just let it paste and handle input?
// React code was explicit. Let's match it.
// But textarea v-model will sync. The React code set sentences from text split.
// If I paste, I get newlines.
// Let's stick with input handler which syncs sentences.
}
// Actually, converting React logic:
// const handlePaste = (e) ... setBulkText(text); setSentences(lines...)
// We can do that in Vue:
const clearAll = () => {
sentences.value = ['']
bulkText.value = ''
translations.value = {}
}
const toggleRegion = (region) => {
if (selectedRegions.value.includes(region)) {
selectedRegions.value = selectedRegions.value.filter(r => r !== region)
} else {
selectedRegions.value.push(region)
}
}
const fetchPrologue = async () => {
if (!isElectronEnv) {
alert('此功能仅在 Electron 环境中可用')
return
}
if (sentences.value.some(s => s.trim()) && !confirm('当前已有内容,获取新内容将清空现有内容,是否继续?')) {
return
}
isFetching.value = true
try {
console.log('[GreetingDialog] 开始获取打招呼内容...')
const result = await window.electronAPI.fetchPrologue()
console.log('[GreetingDialog] 获取结果:', result)
if (result.success && result.data && Array.isArray(result.data)) {
console.log('[GreetingDialog] 更新 sentences:', result.data.length, '条')
sentences.value = result.data
translations.value = {}
} else {
console.error('[GreetingDialog] 数据格式错误:', result)
alert(result.error || '获取失败:格式错误')
}
} catch (e) {
console.error('获取失败:', e)
alert('获取失败,请重试')
} finally {
isFetching.value = false
}
}
const handleTranslate = async () => {
if (!isElectronEnv) {
alert('此功能仅在 Electron 环境中可用')
return
}
const validSentences = sentences.value.filter(Boolean)
if (validSentences.length === 0 || selectedRegions.value.length === 0) return
const languagesToTranslate = getLanguagesForRegions(selectedRegions.value)
if (languagesToTranslate.length === 0) {
alert('选中的大区没有可翻译的语言')
return
}
isTranslating.value = true
try {
const newTranslations = {}
const joinedText = validSentences.join('\n')
await Promise.all(languagesToTranslate.map(async (lang) => {
try {
const result = await window.electronAPI.translate(joinedText, lang)
if (result.success) {
let translatedLines = result.result.split('\n').map(s => s.trim())
if (translatedLines.length > 0) {
if (translatedLines[0].startsWith('{')) {
translatedLines[0] = translatedLines[0].slice(1).trim()
}
const lastIdx = translatedLines.length - 1
if (translatedLines[lastIdx].endsWith('}')) {
translatedLines[lastIdx] = translatedLines[lastIdx].slice(0, -1).trim()
}
}
const finalSentences = []
let transIndex = 0
for (let i = 0; i < sentences.value.length; i++) {
if (sentences.value[i]) {
finalSentences.push(translatedLines[transIndex] || sentences.value[i])
transIndex++
} else {
finalSentences.push('')
}
}
newTranslations[lang] = finalSentences
} else {
newTranslations[lang] = sentences.value
}
} catch (e) {
console.error(`Lang ${lang} translate error:`, e)
newTranslations[lang] = sentences.value
}
}))
translations.value = newTranslations
activeTab.value = languagesToTranslate[0] || ''
} catch (error) {
console.error('翻译失败:', error)
alert('翻译失败,请重试')
} finally {
isTranslating.value = false
}
}
const handleConfirm = () => {
// console.log('sentences', sentences.value.filter(Boolean), 'translations',translations.value,'needTranslate', needTranslate.value)
emit('confirm', {
sentences: sentences.value.filter(Boolean),
translations: translations.value,
needTranslate: needTranslate.value,
})
onClose()
}
const updateTranslation = (val, index) => {
if (!translations.value[activeTab.value]) return
const newArr = [...translations.value[activeTab.value]]
newArr[index] = val
translations.value = { ...translations.value, [activeTab.value]: newArr }
}
const removeTranslation = (index) => {
if (!translations.value[activeTab.value]) return
const newArr = translations.value[activeTab.value].filter((_, i) => i !== index)
translations.value = { ...translations.value, [activeTab.value]: newArr }
}
</script>

View File

@@ -1,474 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { isElectron } from '../utils/electronBridge'
interface Host {
anchorId: string
country: string
invitationType: number // 1=普票, 2=金票
state: number
onlineFans?: number
hostsLevel?: string
}
interface HostListDialogProps {
visible: boolean
onClose: () => void
onSave: (hosts: Host[]) => void
}
// 等级数据定义
const LEVEL_OPTIONS = [
{
label: 'A', value: 'A',
children: [
{ label: 'A1', value: 'A1' },
{ label: 'A2', value: 'A2' },
{ label: 'A3', value: 'A3' },
]
},
{
label: 'B', value: 'B',
children: [
{ label: 'B1', value: 'B1' },
{ label: 'B2', value: 'B2' },
{ label: 'B3', value: 'B3' },
{ label: 'B4', value: 'B4' },
{ label: 'B5', value: 'B5' },
]
},
{
label: 'C', value: 'C',
children: [
{ label: 'C1', value: 'C1' },
{ label: 'C2', value: 'C2' },
{ label: 'C3', value: 'C3' },
{ label: 'C4', value: 'C4' },
{ label: 'C5', value: 'C5' },
]
},
{
label: 'D', value: 'D',
children: [
{ label: 'D1', value: 'D1' },
{ label: 'D2', value: 'D2' },
{ label: 'D3', value: 'D3' },
{ label: 'D4', value: 'D4' },
{ label: 'D5', value: 'D5' },
]
}
]
// 获取所有子级等级值
const getAllChildLevels = (parentValue: string): string[] => {
const parent = LEVEL_OPTIONS.find(p => p.value === parentValue)
return parent ? parent.children.map(c => c.value) : []
}
function HostListDialog({ visible, onClose, onSave }: HostListDialogProps) {
const [hosts, setHosts] = useState<Host[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [filters, setFilters] = useState({
gold: true,
ordinary: true,
minOnlineFans: '',
maxOnlineFans: '',
})
const [maxCount, setMaxCount] = useState<number>(100)
const [selectedLevels, setSelectedLevels] = useState<Set<string>>(new Set()) // 选中的等级
const [showLevelDropdown, setShowLevelDropdown] = useState(false)
// 锁定 Body 滚动
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [visible])
// 加载主播数据和配置
useEffect(() => {
if (visible) {
loadHosts()
loadConfig()
}
}, [visible])
const loadHosts = async () => {
if (!isElectron()) return
try {
const data = await window.electronAPI!.loadAnchorData()
setHosts(data as Host[])
setSelected(new Set())
} catch (e) {
console.error('加载主播数据失败:', e)
}
}
// 从后端加载配置(包括 maxAnchorCount 和 hostsLevelList
const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI!.getAutomationConfig()
if ((config as any)?.maxAnchorCount !== undefined) {
setMaxCount((config as any).maxAnchorCount)
}
// 加载等级过滤配置
if (config?.filters?.hostsLevelList) {
setSelectedLevels(new Set(config.filters.hostsLevelList))
}
} catch (e) {
console.error('加载配置失败:', e)
}
}
// 更新等级过滤配置到后端
const updateLevelFilter = async (levels: Set<string>) => {
setSelectedLevels(levels)
if (!isElectron()) return
try {
await window.electronAPI!.updateAutomationConfig({
filters: { hostsLevelList: Array.from(levels) }
} as any)
console.log('[HostListDialog] 等级过滤已更新:', Array.from(levels))
} catch (e) {
console.error('更新等级配置失败:', e)
}
}
// 切换单个等级选中状态
const toggleLevel = (level: string) => {
const newSet = new Set(selectedLevels)
if (newSet.has(level)) {
newSet.delete(level)
} else {
newSet.add(level)
}
updateLevelFilter(newSet)
}
// 切换整个大类
const toggleParentLevel = (parentValue: string) => {
const childLevels = getAllChildLevels(parentValue)
const allSelected = childLevels.every(l => selectedLevels.has(l))
const newSet = new Set(selectedLevels)
if (allSelected) {
// 全部取消
childLevels.forEach(l => newSet.delete(l))
} else {
// 全部选中
childLevels.forEach(l => newSet.add(l))
}
updateLevelFilter(newSet)
}
// 更新 maxAnchorCount 到后端
const updateMaxCount = async (value: number) => {
setMaxCount(value)
if (!isElectron()) return
try {
await window.electronAPI!.updateAutomationConfig({ maxAnchorCount: value } as any)
console.log('[HostListDialog] 主播数据上限已更新:', value)
} catch (e) {
console.error('更新配置失败:', e)
}
}
// 筛选后的主播列表
const filteredHosts = hosts.filter(h => {
if (!filters.gold && h.invitationType === 2) return false
if (!filters.ordinary && h.invitationType === 1) return false
if (filters.minOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans < parseInt(filters.minOnlineFans)) return false
}
if (filters.maxOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans > parseInt(filters.maxOnlineFans)) return false
}
// 等级过滤:如果选择了等级,则只显示选中等级的主播
if (selectedLevels.size > 0 && h.hostsLevel) {
if (!selectedLevels.has(h.hostsLevel)) return false
}
return true
})
const selectedCount = selected.size
const toggleSelect = useCallback((id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = () => {
setSelected(new Set(filteredHosts.map(h => h.anchorId)))
}
const selectNone = () => {
setSelected(new Set())
}
const invertSelect = () => {
setSelected(prev => {
const next = new Set<string>()
filteredHosts.forEach(h => {
if (!prev.has(h.anchorId)) next.add(h.anchorId)
})
return next
})
}
const deleteSelected = () => {
if (!selected.size) return
if (!confirm(`确认删除选中的 ${selected.size} 项吗?`)) return
const remaining = hosts.filter(h => !selected.has(h.anchorId))
setHosts(remaining)
setSelected(new Set())
}
const handleSave = async () => {
if (isElectron()) {
await window.electronAPI!.saveAnchorData(hosts)
}
onSave(hosts)
onClose()
}
if (!visible) return null
return createPortal(
<div className="fixed inset-0 bg-black/50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col mx-4">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-600"></h3>
<span className="text-sm text-gray-700">
{selectedCount} / {filteredHosts.length}
</span>
</div>
<button onClick={onClose} className="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
{/* 工具栏 */}
<div className="p-4 border-b border-gray-100 space-y-3">
<div className="flex flex-wrap gap-2">
<button onClick={selectAll} className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 rounded border border-blue-300"></button>
<button onClick={selectNone} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300"></button>
<button onClick={invertSelect} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300"></button>
<button
onClick={deleteSelected}
disabled={!selectedCount}
className="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50"
>
</button>
</div>
{/* 筛选 */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.gold}
onChange={(e) => setFilters(f => ({ ...f, gold: e.target.checked }))}
className="w-4 h-4"
/>
<span className="text-yellow-600"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.ordinary}
onChange={(e) => setFilters(f => ({ ...f, ordinary: e.target.checked }))}
className="w-4 h-4"
/>
<span></span>
</label>
<span className="text-gray-700">线</span>
<input
type="number"
placeholder="最小"
value={filters.minOnlineFans}
onChange={(e) => setFilters(f => ({ ...f, minOnlineFans: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span>~</span>
<input
type="number"
placeholder="最大"
value={filters.maxOnlineFans}
onChange={(e) => setFilters(f => ({ ...f, maxOnlineFans: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
{/* 等级过滤 */}
<div className="relative border-l border-gray-200 pl-4 ml-2">
<button
onClick={() => setShowLevelDropdown(!showLevelDropdown)}
className="flex items-center gap-2 px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50"
>
<span className="text-gray-700 font-medium"></span>
<span className="text-xs text-blue-600">
{selectedLevels.size > 0 ? `已选 ${selectedLevels.size}` : '全部'}
</span>
<svg className={`w-4 h-4 transition-transform ${showLevelDropdown ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 下拉菜单 */}
{showLevelDropdown && (
<div className="absolute top-full left-4 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 p-3 w-72">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="space-y-2 max-h-60 overflow-auto">
{LEVEL_OPTIONS.map(parent => {
const childLevels = parent.children.map(c => c.value)
const selectedChildCount = childLevels.filter(l => selectedLevels.has(l)).length
const allSelected = selectedChildCount === childLevels.length
const partialSelected = selectedChildCount > 0 && !allSelected
return (
<div key={parent.value} className="border border-gray-100 rounded p-2">
<label className="flex items-center gap-2 cursor-pointer font-medium text-gray-700">
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = partialSelected }}
onChange={() => toggleParentLevel(parent.value)}
className="w-4 h-4"
/>
{parent.label}
<span className="text-xs text-gray-400">({selectedChildCount}/{childLevels.length})</span>
</label>
<div className="flex flex-wrap gap-2 mt-1 ml-6">
{parent.children.map(child => (
<label key={child.value} className="flex items-center gap-1 cursor-pointer text-gray-600">
<input
type="checkbox"
checked={selectedLevels.has(child.value)}
onChange={() => toggleLevel(child.value)}
className="w-3 h-3"
/>
<span className="text-xs">{child.label}</span>
</label>
))}
</div>
</div>
)
})}
</div>
<div className="mt-2 pt-2 border-t border-gray-100 flex justify-between">
<button
onClick={() => updateLevelFilter(new Set())}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
<button
onClick={() => setShowLevelDropdown(false)}
className="text-xs text-blue-600 hover:text-blue-700"
>
</button>
</div>
</div>
)}
</div>
{/* 接收上限 - 紧凑布局 */}
<div className="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2">
<span className="text-gray-700 font-medium whitespace-nowrap"></span>
<input
type="number"
min={0}
placeholder="无限制"
value={maxCount || ''}
onChange={(e) => {
const val = parseInt(e.target.value)
updateMaxCount(isNaN(val) ? 0 : val)
}}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
{/* 主播列表 */}
<div className="flex-1 overflow-auto p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filteredHosts.map(host => (
<div
key={host.anchorId}
onClick={() => toggleSelect(host.anchorId)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${selected.has(host.anchorId)
? 'border-blue-500 bg-blue-50 shadow'
: 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1" title={host.anchorId}>
{host.anchorId}
</span>
<span className={host.state ? 'text-green-500' : 'text-red-500'}>
{host.state ? '✓' : '✗'}
</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-700">
<span>{host.country || '—'}</span>
<div className="flex items-center gap-1">
{host.hostsLevel && (
<span className="px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 text-xs">
{host.hostsLevel}
</span>
)}
<span className={`px-1.5 py-0.5 rounded border ${host.invitationType === 2
? 'text-yellow-600 border-yellow-400'
: 'border-gray-300'
}`}>
{host.invitationType === 2 ? '金票' : '普票'}
</span>
</div>
</div>
</div>
))}
</div>
{filteredHosts.length === 0 && (
<div className="text-center text-gray-700 py-12">
</div>
)}
</div>
{/* 底部 */}
<div className="p-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
</div>,
document.body
)
}
export default HostListDialog

View File

@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 bg-black/50 flex items-center justify-center" style="z-index: 9999">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col mx-4">
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-600">主播管理</h3>
<span class="text-sm text-gray-700">
已选 {{ selectedCount }} / {{ filteredHosts.length }}
</span>
</div>
<button @click="onClose" class="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<!-- 工具栏 -->
<div class="p-4 border-b border-gray-100 space-y-3">
<div class="flex flex-wrap gap-2">
<button @click="selectAll"
class="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 rounded border border-blue-300">全选</button>
<button @click="selectNone"
class="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300">全不选</button>
<button @click="invertSelect"
class="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300">反选</button>
<button @click="deleteSelected" :disabled="!selectedCount"
class="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50">
删除选中
</button>
</div>
<!-- 筛选 -->
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.gold" class="w-4 h-4" />
<span class="text-yellow-600">金票</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.ordinary" class="w-4 h-4" />
<span>普票</span>
</label>
<span class="text-gray-700">在线人数</span>
<input type="number" placeholder="最小" v-model="filters.minOnlineFans"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm" />
<span>~</span>
<input type="number" placeholder="最大" v-model="filters.maxOnlineFans"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm" />
<!-- 等级过滤 -->
<div class="relative border-l border-gray-200 pl-4 ml-2">
<button @click="showLevelDropdown = !showLevelDropdown"
class="flex items-center gap-2 px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50">
<span class="text-gray-700 font-medium">等级过滤</span>
<span class="text-xs text-blue-600">
{{ selectedLevels.size > 0 ? `已选 ${selectedLevels.size}` : '全部' }}
</span>
<svg :class="['w-4 h-4 transition-transform', showLevelDropdown ? 'rotate-180' : '']"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- 下拉菜单 -->
<div v-if="showLevelDropdown"
class="absolute top-full left-4 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 p-3 w-72">
<div class="text-xs text-gray-500 mb-2">选择接收的主播等级不选则接收全部</div>
<div class="space-y-2 max-h-60 overflow-auto">
<div v-for="parent in LEVEL_OPTIONS" :key="parent.value"
class="border border-gray-100 rounded p-2">
<label
class="flex items-center gap-2 cursor-pointer font-medium text-gray-700">
<input type="checkbox"
:checked="isParentSelected(parent).allSelected"
:indeterminate="isParentSelected(parent).partialSelected"
@change="toggleParentLevel(parent.value)" class="w-4 h-4" />
{{ parent.label }}
<span class="text-xs text-gray-400">({{
isParentSelected(parent).selectedChildCount }}/{{
parent.children.length
}})</span>
</label>
<div class="flex flex-wrap gap-2 mt-1 ml-6">
<label v-for="child in parent.children" :key="child.value"
class="flex items-center gap-1 cursor-pointer text-gray-600">
<input type="checkbox" :checked="selectedLevels.has(child.value)"
@change="toggleLevel(child.value)" class="w-3 h-3" />
<span class="text-xs">{{ child.label }}</span>
</label>
</div>
</div>
</div>
<div class="mt-2 pt-2 border-t border-gray-100 flex justify-between">
<button @click="updateLevelFilter(new Set())"
class="text-xs text-gray-500 hover:text-gray-700">
清空选择
</button>
<button @click="showLevelDropdown = false"
class="text-xs text-blue-600 hover:text-blue-700">
完成
</button>
</div>
</div>
</div>
<!-- 接收上限 - 紧凑布局 -->
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2">
<span class="text-gray-700 font-medium whitespace-nowrap">接收上限</span>
<input type="number" min="0" placeholder="无限制" v-model.number="maxCount"
@change="updateMaxCount(maxCount)"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
<!-- 主播列表 -->
<div class="flex-1 overflow-auto p-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div v-for="host in filteredHosts" :key="host.anchorId" @click="toggleSelect(host.anchorId)"
:class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selected.has(host.anchorId) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
]">
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
{{ host.anchorId }}
</span>
<span :class="host.state ? 'text-green-500' : 'text-red-500'">
{{ host.state ? '✓' : '✗' }}
</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-700">
<span>{{ host.country || '—' }}</span>
<div class="flex items-center gap-1">
<span v-if="host.hostsLevel"
class="px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 text-xs">
{{ host.hostsLevel }}
</span>
<span :class="[
'px-1.5 py-0.5 rounded border',
host.invitationType === 2 ? 'text-yellow-600 border-yellow-400' : 'border-gray-300'
]">
{{ host.invitationType === 2 ? '金票' : '普票' }}
</span>
</div>
</div>
</div>
</div>
<div v-if="filteredHosts.length === 0" class="text-center text-gray-700 py-12">
暂无主播数据
</div>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
关闭
</button>
<button @click="handleSave"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
const props = defineProps({
visible: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'save'])
// Level Options
const LEVEL_OPTIONS = [
{
label: 'A', value: 'A',
children: [
{ label: 'A1', value: 'A1' },
{ label: 'A2', value: 'A2' },
{ label: 'A3', value: 'A3' },
]
},
{
label: 'B', value: 'B',
children: [
{ label: 'B1', value: 'B1' },
{ label: 'B2', value: 'B2' },
{ label: 'B3', value: 'B3' },
{ label: 'B4', value: 'B4' },
{ label: 'B5', value: 'B5' },
]
},
{
label: 'C', value: 'C',
children: [
{ label: 'C1', value: 'C1' },
{ label: 'C2', value: 'C2' },
{ label: 'C3', value: 'C3' },
{ label: 'C4', value: 'C4' },
{ label: 'C5', value: 'C5' },
]
},
{
label: 'D', value: 'D',
children: [
{ label: 'D1', value: 'D1' },
{ label: 'D2', value: 'D2' },
{ label: 'D3', value: 'D3' },
{ label: 'D4', value: 'D4' },
{ label: 'D5', value: 'D5' },
]
}
]
// State
const hosts = ref([])
const selected = ref(new Set())
const filters = ref({
gold: true,
ordinary: true,
minOnlineFans: '',
maxOnlineFans: '',
})
const maxCount = ref(100)
const selectedLevels = ref(new Set())
const showLevelDropdown = ref(false)
// Lifecycle
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
loadHosts()
loadConfig()
} else {
document.body.style.overflow = ''
}
})
onMounted(() => {
if (props.visible) {
document.body.style.overflow = 'hidden'
loadHosts()
loadConfig()
}
})
// Loading
const loadHosts = async () => {
if (!isElectron()) return
try {
const data = await window.electronAPI.loadAnchorData()
hosts.value = data
selected.value = new Set()
} catch (e) {
console.error('加载主播数据失败:', e)
}
}
const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI.getAutomationConfig()
if (config?.maxAnchorCount !== undefined) {
maxCount.value = config.maxAnchorCount
}
if (config?.filters?.hostsLevelList) {
selectedLevels.value = new Set(config.filters.hostsLevelList)
}
} catch (e) {
console.error('加载配置失败:', e)
}
}
// Helpers
const getAllChildLevels = (parentValue) => {
const parent = LEVEL_OPTIONS.find(p => p.value === parentValue)
return parent ? parent.children.map(c => c.value) : []
}
// Filtering
const isParentSelected = (parent) => {
const childLevels = parent.children.map(c => c.value)
const selectedChildCount = childLevels.filter(l => selectedLevels.value.has(l)).length
const allSelected = selectedChildCount === childLevels.length
const partialSelected = selectedChildCount > 0 && !allSelected
return { allSelected, partialSelected, selectedChildCount }
}
const updateLevelFilter = async (levels) => {
selectedLevels.value = levels
if (!isElectron()) return
try {
await window.electronAPI.updateAutomationConfig({
filters: { hostsLevelList: Array.from(levels) }
})
console.log('[HostListDialog] 等级过滤已更新:', Array.from(levels))
} catch (e) {
console.error('更新等级配置失败:', e)
}
}
const toggleLevel = (level) => {
const newSet = new Set(selectedLevels.value)
if (newSet.has(level)) {
newSet.delete(level)
} else {
newSet.add(level)
}
updateLevelFilter(newSet)
}
const toggleParentLevel = (parentValue) => {
const childLevels = getAllChildLevels(parentValue)
const allSelected = childLevels.every(l => selectedLevels.value.has(l))
const newSet = new Set(selectedLevels.value)
if (allSelected) {
childLevels.forEach(l => newSet.delete(l))
} else {
childLevels.forEach(l => newSet.add(l))
}
updateLevelFilter(newSet)
}
const updateMaxCount = async (value) => {
// value is already updated via v-model
if (!isElectron()) return
try {
await window.electronAPI.updateAutomationConfig({ maxAnchorCount: value })
console.log('[HostListDialog] 主播数据上限已更新:', value)
} catch (e) {
console.error('更新配置失败:', e)
}
}
const filteredHosts = computed(() => {
return hosts.value.filter(h => {
if (!filters.value.gold && h.invitationType === 2) return false
if (!filters.value.ordinary && h.invitationType === 1) return false
if (filters.value.minOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans < parseInt(filters.value.minOnlineFans)) return false
}
if (filters.value.maxOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans > parseInt(filters.value.maxOnlineFans)) return false
}
if (selectedLevels.value.size > 0 && h.hostsLevel) {
if (!selectedLevels.value.has(h.hostsLevel)) return false
}
return true
})
})
const selectedCount = computed(() => selected.value.size)
const toggleSelect = (id) => {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
const selectAll = () => {
selected.value = new Set(filteredHosts.value.map(h => h.anchorId))
}
const selectNone = () => {
selected.value = new Set()
}
const invertSelect = () => {
const next = new Set()
filteredHosts.value.forEach(h => {
if (!selected.value.has(h.anchorId)) next.add(h.anchorId)
})
selected.value = next
}
const deleteSelected = () => {
if (!selected.value.size) return
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
const remaining = hosts.value.filter(h => !selected.value.has(h.anchorId))
hosts.value = remaining
selected.value = new Set()
}
const onClose = () => emit('close')
const handleSave = async () => {
if (isElectron()) {
await window.electronAPI.saveAnchorData(JSON.parse(JSON.stringify(hosts.value)))
}
emit('save', hosts.value)
onClose()
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<el-dialog v-model="visible" :title="$t('hostList.liveSessions') || '直播记录'" width="80vw" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div class="toolbar">
<div class="left">
<el-input v-model="kw" :placeholder="$t('hostList.searchPlaceholder') || 'Search...'" style="width: 200px; margin-right: 10px;" clearable />
<el-checkbox v-model="onlyAbnormal">{{ $t('hostList.onlyAbnormal') || '只看异常' }}</el-checkbox>
</div>
<div class="right">
<el-tag type="info">{{ $t('hostList.total') || '总条数' }}{{ filteredRows.length }}</el-tag>
<el-tag type="success">{{ $t('hostList.totalLikes') || '点赞合计' }}{{ totalLikes }}</el-tag>
<el-tag type="warning">{{ $t('hostList.zeroLikes') || '无点赞' }}{{ zeroLikeCount }}</el-tag>
</div>
</div>
<el-table :data="filteredRows" border height="62vh" style="width: 100%"
:default-sort="{ prop: 'startTimeFormatted', order: 'descending' }" table-layout="auto"
@row-dblclick="copyRow">
<el-table-column prop="hostsId" :label="$t('hostList.hostId') || '主播id'" />
<el-table-column prop="startTimeFormatted" :label="$t('hostList.startTime') || '开始时间'" sortable />
<el-table-column prop="endTimeFormatted" :label="$t('hostList.endTime') || '结束时间'" sortable />
<el-table-column prop="durationFormatted" :label="$t('hostList.duration') || '时长'" />
<el-table-column prop="likeCount" :label="$t('hostList.likeCount') || '点赞'" sortable>
<template #default="{ row }">
<el-tag v-if="row.likeCount === 0" type="danger">0</el-tag>
<span v-else>{{ row.likeCount }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" :label="$t('hostList.createTime') || '入库时间'" sortable />
</el-table>
<template #footer>
<el-button @click="visible = false">{{ $t('hostList.close') || '关闭' }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
rows: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "select"]);
const visible = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
});
const kw = ref("");
const sortKey = ref("startTimeFormatted");
const sortOrder = ref("desc");
const onlyAbnormal = ref(false);
function parseTime(s) {
if (!s) return 0;
// Handle various date formats if necessary, but standard string sort might be enough if ISO
// Original code used simple replacement, assume backend sends usable strings
return new Date(s.replace(" ", "T")).getTime() || 0;
}
function durationSeconds(durationFormatted) {
if (!durationFormatted) return 0;
const h = Number((durationFormatted.match(/(\d+)\s*小时/) || [])[1] || 0);
const m = Number((durationFormatted.match(/(\d+)\s*分钟/) || [])[1] || 0);
const s = Number((durationFormatted.match(/(\d+)\s*秒/) || [])[1] || 0);
return h * 3600 + m * 60 + s;
}
const filteredRows = computed(() => {
const k = kw.value.trim().toLowerCase();
// Pre-process rows to ensure formatted fields exist if they come from backend differently
// The original component assumed rows already had `startTimeFormatted` etc.
// We'll trust the prop data or add basic fallback if needed.
let arr = (props.rows || []).map(r => ({
...r,
// If props don't have formatted times, we might need to create them.
// Assuming 'startTime' is the key from backend based on previous version of this file
startTimeFormatted: r.startTimeFormatted || r.startTime || '',
endTimeFormatted: r.endTimeFormatted || r.endTime || '',
durationFormatted: r.durationFormatted || r.duration || '',
})).filter((r) => {
if (!k) return true;
const hay = `${r.id || ''} ${r.userId || ''} ${r.hostsId || ''} ${r.tenantId || ''}`.toLowerCase();
return hay.includes(k);
});
if (onlyAbnormal.value) {
arr = arr.filter((r) => r.likeCount === 0 || durationSeconds(r.durationFormatted) < 60);
}
// Element Plus table handles sorting if we use 'sortable' on columns,
// but if we want custom client-side sort for everything:
// The original used custom sort logic. We can stick to Element Plus default sort
// by removing the manual sort here OR keep it if we want to ensure specific logic.
// The original code RETURNED the sorted array. Element Plus :data usually expects the full array
// and handles sort if 'sortable' is set.
// BUT the original code manually sorted `arr`. Let's keep it to be safe.
// Actually, Element Plus's local sort works on the current page/data.
// If we modify 'arr' order here, it sets the default order.
return arr;
});
const totalLikes = computed(() =>
filteredRows.value.reduce((sum, r) => sum + (Number(r.likeCount) || 0), 0)
);
const zeroLikeCount = computed(() => filteredRows.value.filter((r) => r.likeCount === 0).length);
async function copyRow(row) {
try {
await navigator.clipboard.writeText(JSON.stringify(row, null, 2));
ElMessage.success("已复制该行 JSON");
} catch {
ElMessage.warning("复制失败:浏览器不支持或无权限");
}
}
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar .left {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toolbar .right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
</style>

View File

@@ -1,283 +0,0 @@
import { memo, useState, useEffect } from 'react'
type TabId = 'A' | 'B' | 'C'
interface TabConfig {
id: TabId
label: string
viewIds: number[]
}
interface AccountGroup {
name: string
accounts: { email: string; pwd: string }[]
}
interface RotationStatus {
enabled: boolean
currentActiveGroup: string
modeStartTime: number
totalStartTime?: number // 总运行开始时间(可选)
instanceModes: { viewId: number; email: string; group: string; mode: 'active' | 'background' }[]
}
interface AutomationLog {
viewId: number
level: 'info' | 'warn' | 'error'
message: string
timestamp?: number
}
interface SidebarProps {
tabs: TabConfig[]
currentTab: TabId
onTabSwitch: (tab: TabId) => void
onGoBack: () => void
onStopAll: () => void
isLoading: boolean
accountGroups: AccountGroup[]
rotationStatus?: RotationStatus
greetingStats: { greetingCount: number; inviteCount: number }
automationLogs?: AutomationLog[]
}
function Sidebar({
tabs,
currentTab,
onTabSwitch,
onGoBack,
onStopAll,
isLoading,
accountGroups,
rotationStatus,
greetingStats = { greetingCount: 0, inviteCount: 0 },
automationLogs = []
}: SidebarProps) {
// 检查组是否是当前活跃组
const isActiveGroup = (groupName: string): boolean => {
if (!rotationStatus?.enabled) return false
return rotationStatus.currentActiveGroup === groupName
}
// 当前活跃组运行时间(账号组旁显示)
const [elapsedTime, setElapsedTime] = useState('00:00')
// 总运行时间(底部显示)
const [totalElapsedTime, setTotalElapsedTime] = useState('00:00')
// 定时更新当前活跃组运行时间
useEffect(() => {
if (!rotationStatus?.modeStartTime) {
setElapsedTime('00:00')
return
}
const updateTime = () => {
const elapsed = Math.floor((Date.now() - rotationStatus.modeStartTime) / 1000)
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
setElapsedTime(`${minutes}:${seconds}`)
}
updateTime()
const timer = setInterval(updateTime, 1000)
return () => clearInterval(timer)
}, [rotationStatus?.modeStartTime])
// 定时更新总运行时间
useEffect(() => {
if (!rotationStatus?.totalStartTime) {
setTotalElapsedTime('00:00')
return
}
const updateTime = () => {
const elapsed = Math.floor((Date.now() - (rotationStatus.totalStartTime || 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')
// 如果超过1小时显示时:分:秒
if (hours > 0) {
setTotalElapsedTime(`${hours}:${minutes}:${seconds}`)
} else {
setTotalElapsedTime(`${minutes}:${seconds}`)
}
}
updateTime()
const timer = setInterval(updateTime, 1000)
return () => clearInterval(timer)
}, [rotationStatus?.totalStartTime])
return (
<aside className="w-[200px] h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm">
{/* 返回和停止按钮 */}
<div className="m-3 mb-0 flex gap-2">
<button
onClick={onGoBack}
className="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
onClick={onStopAll}
className="px-3 py-2 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="停止所有任务并清空缓存"
>
</button>
</div>
{/* Logo / 标题 */}
<div className="p-4 border-b border-gray-200">
<h1 className="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
</h1>
<p className="text-xs text-gray-500 mt-1">9 </p>
</div>
{/* 标签页列表 */}
<nav className="flex-1 p-3 space-y-2 overflow-auto">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
</div>
{tabs.map((tab) => {
// 获取该标签页对应的组信息
const tabIndex = tabs.indexOf(tab)
const group = accountGroups[tabIndex]
const groupName = group?.name || tab.label
const isActive = isActiveGroup(groupName)
// 计算该组运行中的账号数量
const runningAccounts = rotationStatus?.instanceModes.filter(
i => i.group === groupName
).length || 0
const totalAccounts = group?.accounts?.filter(a => a.email && a.pwd).length || 0
return (
<button
key={tab.id}
onClick={() => onTabSwitch(tab.id)}
disabled={isLoading}
className={`
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 className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{groupName}</span>
{rotationStatus?.enabled && (
<span className={`px-1.5 py-0.5 text-[10px] font-bold rounded ${isActive
? 'bg-emerald-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{isActive ? '全功能' : '仅回复'}
</span>
)}
{/* 活跃组显示运行时间 */}
{isActive && rotationStatus?.enabled && (
<span className="text-[10px] text-white font-mono bg-blue-500 px-1.5 py-0.5 rounded shadow-sm">
{elapsedTime}
</span>
)}
</div>
</div>
{/* 第二行:运行账号数 / 视图ID */}
<div className="flex items-center justify-between w-full mt-1.5 text-xs">
<div className="flex items-center gap-1.5">
{runningAccounts > 0 ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-emerald-600">{runningAccounts} </span>
</>
) : (
<span className="text-gray-500">{totalAccounts} </span>
)}
</div>
<span className="text-gray-400 text-[10px]">
{tabIndex * 3 + 1},{tabIndex * 3 + 2},{tabIndex * 3 + 3}
</span>
</div>
</button>
)
})}
</nav>
{/* 运行记录 */}
<div className="flex-1 min-h-0 border-t border-gray-200 flex flex-col">
<div className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50">
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1 text-xs font-mono bg-gray-50/50">
{automationLogs.length === 0 ? (
<div className="text-gray-400 text-center py-4"></div>
) : (
automationLogs.slice(-50).reverse().map((log, i) => {
const time = log.timestamp
? new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false })
: ''
return (
<div
key={i}
className={`break-all leading-relaxed ${log.level === 'error' ? 'text-red-600' :
log.level === 'warn' ? 'text-amber-600' :
'text-gray-600'
}`}
>
{time && <span className="text-gray-400 mr-1.5">[{time}]</span>}
{log.message}
</div>
)
})
)}
</div>
</div>
{/* 底部运行状态 */}
<div className="p-3 border-t border-gray-200 bg-gray-50">
{rotationStatus?.enabled ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-emerald-600 font-medium">{rotationStatus.currentActiveGroup}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-blue-600 font-mono">{totalElapsedTime}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-gray-700">{rotationStatus.instanceModes.length} </span>
</div>
</div>
) : (
<div className="text-center text-xs text-gray-400">
</div>
)}
{/* 统计数据 */}
<div className="mt-2 pt-2 border-t border-gray-200 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-blue-600 font-medium">{greetingStats.greetingCount} </span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-purple-600 font-medium">{greetingStats.inviteCount} </span>
</div>
</div>
</div>
</aside>
)
}
export default memo(Sidebar)

249
src/components/Sidebar.vue Normal file
View File

@@ -0,0 +1,249 @@
<template>
<aside class="w-[200px] h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm">
<!-- 返回和停止按钮 -->
<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">
<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>
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已回复</span>
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
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: () => [] }
})
const emit = defineEmits(['tabSwitch', 'goBack', 'stopAll'])
// 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>

View File

@@ -1,157 +0,0 @@
import { useUpdate } from '../hooks/useUpdate'
import { isElectron } from '../utils/electronBridge'
/**
* 更新通知组件
* 显示在右下角的更新提示,支持检查、下载、安装更新
* 注意:仅在 Electron 环境中有效
*/
export default function UpdateNotification() {
const {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate,
dismissUpdate
} = useUpdate()
// 非 Electron 环境或空闲状态不显示
if (!isElectron() || status === 'idle') {
return null
}
return (
<div className="fixed bottom-4 right-4 z-50 animate-slideUp">
<div className="bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden w-80">
{/* 头部 */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-white font-medium"></span>
</div>
<button
onClick={dismissUpdate}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 内容 */}
<div className="p-4">
{/* 检查中 */}
{status === 'checking' && (
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-gray-600">...</span>
</div>
)}
{/* 发现新版本 */}
{status === 'available' && updateInfo && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-500 text-sm"></span>
<span className="text-gray-700 font-mono text-sm">{currentVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500 text-sm"></span>
<span className="text-green-600 font-mono text-sm font-medium">{updateInfo.version}</span>
</div>
{updateInfo.releaseNotes && (
<p className="text-gray-500 text-xs bg-gray-50 p-2 rounded-lg line-clamp-3">
{updateInfo.releaseNotes}
</p>
)}
<button
onClick={downloadUpdate}
className="w-full py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg"
>
</button>
</div>
)}
{/* 下载中 */}
{status === 'downloading' && progress && (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">...</span>
<span className="text-blue-600 font-medium">{progress.percent.toFixed(1)}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{formatBytes(progress.transferred)} / {formatBytes(progress.total)}</span>
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
</div>
</div>
)}
{/* 下载完成 */}
{status === 'downloaded' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-green-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium"></span>
</div>
<p className="text-gray-500 text-sm"></p>
<button
onClick={installUpdate}
className="w-full py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-medium hover:from-green-600 hover:to-green-700 transition-all shadow-md hover:shadow-lg"
>
🚀
</button>
</div>
)}
{/* 错误 */}
{status === 'error' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-red-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium"></span>
</div>
<p className="text-gray-500 text-sm">{error}</p>
<button
onClick={checkForUpdates}
className="w-full py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-all"
>
</button>
</div>
)}
</div>
</div>
</div>
)
}
/**
* 格式化字节数
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}

View File

@@ -0,0 +1,123 @@
<template>
<div v-if="isElectronEnv && status !== 'idle'" class="fixed bottom-4 right-4 z-50 animate-slideUp">
<div class="bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden w-80">
<!-- 头部 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="text-white font-medium">应用更新</span>
</div>
<button @click="dismissUpdate" class="text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 内容 -->
<div class="p-4">
<!-- 检查中 -->
<div v-if="status === 'checking'" class="flex items-center gap-3">
<div class="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span class="text-gray-600">正在检查更新...</span>
</div>
<!-- 发现新版本 -->
<div v-if="status === 'available' && updateInfo" class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-gray-500 text-sm">当前版本</span>
<span class="text-gray-700 font-mono text-sm">{{ currentVersion }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 text-sm">最新版本</span>
<span class="text-green-600 font-mono text-sm font-medium">{{ updateInfo.version }}</span>
</div>
<p v-if="updateInfo.releaseNotes" class="text-gray-500 text-xs bg-gray-50 p-2 rounded-lg line-clamp-3">
{{ updateInfo.releaseNotes }}
</p>
<button @click="downloadUpdate"
class="w-full py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg">
下载更新
</button>
</div>
<!-- 下载中 -->
<div v-if="status === 'downloading' && progress" class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">下载中...</span>
<span class="text-blue-600 font-medium">{{ progress.percent.toFixed(1) }}%</span>
</div>
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300"
:style="{ width: `${progress.percent}%` }" />
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{{ formatBytes(progress.transferred) }} / {{ formatBytes(progress.total) }}</span>
<span>{{ formatBytes(progress.bytesPerSecond) }}/s</span>
</div>
</div>
<!-- 下载完成 -->
<div v-if="status === 'downloaded'" class="space-y-3">
<div class="flex items-center gap-2 text-green-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<span class="font-medium">下载完成</span>
</div>
<p class="text-gray-500 text-sm">点击下方按钮重启应用以完成更新</p>
<button @click="installUpdate"
class="w-full py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-medium hover:from-green-600 hover:to-green-700 transition-all shadow-md hover:shadow-lg">
🚀 立即重启并安装
</button>
</div>
<!-- 错误 -->
<div v-if="status === 'error'" class="space-y-3">
<div class="flex items-center gap-2 text-red-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium">更新失败</span>
</div>
<p class="text-gray-500 text-sm">{{ error }}</p>
<button @click="checkForUpdates"
class="w-full py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-all">
重试
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useUpdate } from '../hooks/useUpdate'
import { isElectron } from '../utils/electronBridge'
const isElectronEnv = isElectron()
const {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate,
dismissUpdate
} = useUpdate()
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
</script>

View File

@@ -1,28 +0,0 @@
import { memo } from 'react'
interface ViewPlaceholderProps {
className?: string
}
function ViewPlaceholder({ className = '' }: ViewPlaceholderProps) {
return (
<div className={`relative bg-slate-900/50 ${className}`}>
{/* 占位提示 - BrowserView 会覆盖在上层 */}
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 pointer-events-none">
<div className="text-center pointer-events-auto">
<p className="text-sm text-slate-500 mb-4">
BrowserView
</p>
</div>
</div>
{/* 边框装饰 */}
<div className="absolute inset-2 rounded-xl border border-dashed border-slate-700/30 pointer-events-none" />
</div>
)
}
export default memo(ViewPlaceholder)

View File

@@ -0,0 +1,24 @@
<template>
<div :class="`relative bg-slate-900/50 ${className}`">
<!-- 占位提示 - BrowserView 会覆盖在上层 -->
<div class="absolute inset-0 flex flex-col items-center justify-center p-6 pointer-events-none">
<div class="text-center pointer-events-auto">
<p class="text-sm text-slate-500 mb-4">
BrowserView 将显示在此处
</p>
</div>
</div>
<!-- 边框装饰 -->
<div class="absolute inset-2 rounded-xl border border-dashed border-slate-700/30 pointer-events-none" />
</div>
</template>
<script setup>
defineProps({
className: {
type: String,
default: ''
}
})
</script>