初始化

This commit is contained in:
2026-01-22 15:18:09 +08:00
commit 85e5d1ccb7
28 changed files with 7664 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,474 @@
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

283
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,283 @@
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)

View File

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

View File

@@ -0,0 +1,28 @@
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)