大哥 主播 即时消息 三合一
This commit is contained in:
@@ -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
|
||||
75
src/components/AIConfigDialog.vue
Normal file
75
src/components/AIConfigDialog.vue
Normal 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>
|
||||
@@ -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
|
||||
117
src/components/AutomationPanel.vue
Normal file
117
src/components/AutomationPanel.vue
Normal 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>
|
||||
@@ -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
|
||||
465
src/components/GreetingDialog.vue
Normal file
465
src/components/GreetingDialog.vue
Normal 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>
|
||||
@@ -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
|
||||
402
src/components/HostListDialog.vue
Normal file
402
src/components/HostListDialog.vue
Normal 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>
|
||||
157
src/components/LiveRecordDialog.vue
Normal file
157
src/components/LiveRecordDialog.vue
Normal 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>
|
||||
@@ -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
249
src/components/Sidebar.vue
Normal 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>
|
||||
@@ -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]}`
|
||||
}
|
||||
123
src/components/UpdateNotification.vue
Normal file
123
src/components/UpdateNotification.vue
Normal 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>
|
||||
@@ -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)
|
||||
24
src/components/ViewPlaceholder.vue
Normal file
24
src/components/ViewPlaceholder.vue
Normal 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>
|
||||
Reference in New Issue
Block a user