初始化

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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YoloAI助手Web版</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2663
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "yolo-web-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^5.4.11"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

567
src/App.tsx Normal file
View File

@@ -0,0 +1,567 @@
import { useState, useCallback, useEffect } from 'react'
import { isElectron, getAppVersion } from './utils/electronBridge'
import LoginPage from './pages/LoginPage'
import ConfigPage from './pages/ConfigPage'
import UpdateChecker from './pages/UpdateChecker'
import Sidebar from './components/Sidebar'
import ViewPlaceholder from './components/ViewPlaceholder'
import UpdateNotification from './components/UpdateNotification'
type TabId = 'A' | 'B' | 'C'
type PageType = 'login' | 'config' | 'browser'
interface TabConfig {
id: TabId
label: string
viewIds: number[]
}
const USER_KEY = 'user_data'
const CONFIG_KEY = 'autoDm_runConfig'
// 账号组接口(与 ConfigPage 共享)
interface Account {
email: string
pwd: string
group?: string
}
interface AccountGroup {
name: string
accounts: Account[]
}
interface RunConfig {
rotateEnabled: boolean
groupCount: number
accountGroups: AccountGroup[]
aiReply: boolean
sendInviteFirst: boolean
sleepTime: number
inviteThreshold: number
switchMinutes: number
}
interface RotationStatus {
enabled: boolean
currentActiveGroup: string
modeStartTime: number
totalStartTime?: number // 可选,与 electron.d.ts 兼容
instanceModes: { viewId: number; email: string; group: string; mode: 'active' | 'background' }[]
}
interface AutomationLog {
viewId: number
level: 'info' | 'warn' | 'error'
message: string
timestamp?: number
}
function App() {
const [updateReady, setUpdateReady] = useState(false) // 更新检查是否完成
const [currentPage, setCurrentPage] = useState<PageType>('login')
const [currentTab, setCurrentTab] = useState<TabId>('A')
const [isLoading, _setIsLoading] = useState(false)
const [automationStatus, setAutomationStatus] = useState<Record<number, string>>({})
const [selectedViewId, setSelectedViewId] = useState<number | null>(null)
const [accountGroups, setAccountGroups] = useState<AccountGroup[]>([])
const [viewAccountMap, setViewAccountMap] = useState<Record<number, Account | null>>({})
const [rotationStatus, setRotationStatus] = useState<RotationStatus | null>(null)
const [greetingStats, setGreetingStats] = useState({ greetingCount: 0, inviteCount: 0 })
const [automationLogs, setAutomationLogs] = useState<AutomationLog[]>([])
// 动态生成 tabs 配置
const tabs = [
{ id: 'A', label: accountGroups[0]?.name || 'Tab A', viewIds: [1, 2, 3] },
{ id: 'B', label: accountGroups[1]?.name || 'Tab B', viewIds: [4, 5, 6] },
{ id: 'C', label: accountGroups[2]?.name || 'Tab C', viewIds: [7, 8, 9] }
] as TabConfig[]
// 初始化时设置窗口标题
useEffect(() => {
const setTitle = async () => {
try {
const version = await getAppVersion()
document.title = `YoloAI助手Web版v${version}`
} catch {
document.title = 'YoloAI助手Web版'
}
}
setTitle()
}, [])
useEffect(() => {
const userData = localStorage.getItem(USER_KEY)
if (userData) {
try {
const user = JSON.parse(userData)
if (user && user.tokenValue) {
setCurrentPage('config')
}
} catch { }
}
}, [])
// 关闭窗口时清除登录状态,确保每次启动都需要重新登录
useEffect(() => {
const handleBeforeUnload = () => {
localStorage.removeItem(USER_KEY)
}
window.addEventListener('beforeunload', handleBeforeUnload)
// 监听主进程发来的清除登录请求
let unsubscribe = () => { }
if (isElectron()) {
unsubscribe = window.electronAPI!.onRequestClearLogin(() => {
console.log('[App] 收到清除登录状态请求')
localStorage.removeItem(USER_KEY)
})
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
unsubscribe()
}
}, [])
// 健康检查每5秒检测账号是否在其他地方登录
useEffect(() => {
// 只有登录后且在 Electron 环境才进行健康检查
if (currentPage === 'login' || !isElectron()) return
const checkHealth = async () => {
try {
const result = await window.electronAPI!.checkHealth()
if (result.success && result.code === 40400) {
// 账号在其他地方登录
alert('当前账号已在其他地方登录,请重新登录')
localStorage.removeItem(USER_KEY)
setCurrentPage('login')
}
} catch (error) {
console.error('[App] 健康检查失败:', error)
}
}
// 立即检查一次
checkHealth()
// 每5秒检查一次
const intervalId = setInterval(checkHealth, 5000)
return () => clearInterval(intervalId)
}, [currentPage])
// 加载账号配置并分配到视图
const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(CONFIG_KEY)
if (savedConfig) {
const config: RunConfig = JSON.parse(savedConfig)
setAccountGroups(config.accountGroups || [])
// 自动分配账号到视图每组3个视图
const map: Record<number, Account | null> = {}
config.accountGroups.forEach((group, groupIndex) => {
const viewsPerGroup = 3
group.accounts.forEach((account, accIndex) => {
const viewId = groupIndex * viewsPerGroup + accIndex + 1
if (viewId <= 9 && account.email && account.pwd) {
// 添加组名到账号信息
map[viewId] = { ...account, group: group.name }
}
})
})
setViewAccountMap(map)
}
} catch { }
}
// 初始加载和监听 storage 变化
useEffect(() => {
loadConfig()
// 监听 localStorage 变化(当 ConfigPage 更新时同步)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === CONFIG_KEY) {
loadConfig()
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [])
// 当切换到浏览器页面时重新加载配置
useEffect(() => {
if (currentPage === 'browser') {
loadConfig()
}
}, [currentPage])
// 获取和监听轮换状态
useEffect(() => {
if (!isElectron()) return
// 获取初始状态
const fetchRotationStatus = async () => {
try {
const status = await window.electronAPI!.getRotationStatus()
setRotationStatus(status)
} catch (e) {
console.error('获取轮换状态失败:', e)
}
}
fetchRotationStatus()
// 监听轮换状态变化
const unsubscribe = window.electronAPI!.onRotationStatusChanged((status: RotationStatus) => {
setRotationStatus(status)
})
return unsubscribe
}, [])
// 获取和监听打招呼统计
useEffect(() => {
if (!isElectron()) return
// 获取初始统计
window.electronAPI!.getGreetingStats().then((stats: { greetingCount: number; inviteCount: number }) => {
setGreetingStats(stats)
}).catch(console.error)
// 监听统计变化
const unsubscribe = window.electronAPI!.onGreetingStatsChanged((stats: { greetingCount: number; inviteCount: number }) => {
setGreetingStats(stats)
})
return unsubscribe
}, [])
// 监听自动化日志
useEffect(() => {
if (!isElectron()) return
const unsubscribe = window.electronAPI!.onAutomationLog((log: AutomationLog) => {
setAutomationLogs(prev => [...prev.slice(-99), { ...log, timestamp: Date.now() }])
})
return unsubscribe
}, [])
// 切换标签页
const handleTabSwitch = useCallback(async (tab: TabId) => {
if (tab === currentTab) return
if (isElectron()) {
try {
const result = await window.electronAPI!.switchTab(tab)
if (result.success) {
setCurrentTab(tab)
setSelectedViewId(null)
}
} catch (error) {
console.error('切换标签失败:', error)
}
} else {
setCurrentTab(tab)
setSelectedViewId(null)
}
}, [currentTab])
// 切换 TikTok 自动化状态(保留用于未来使用)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleToggleTikTokAutomation = useCallback(async (viewId: number) => {
if (!isElectron()) return
const isRunning = automationStatus[viewId] === 'TikTok 运行中'
if (isRunning) {
// 停止 (不清除缓存)
setAutomationStatus(prev => ({ ...prev, [viewId]: '正在停止...' }))
try {
await window.electronAPI!.stopTikTokAutomation(viewId)
} catch (e) {
console.error(e)
}
setAutomationStatus(prev => {
const next = { ...prev }
delete next[viewId]
return next
})
} else {
// 启动
const account = viewAccountMap[viewId]
if (!account) {
alert('该视图未配置账号')
return
}
// 检查是否开启了轮换模式
const config = localStorage.getItem('autoDm_runConfig')
const rotateEnabled = config ? JSON.parse(config).rotateEnabled : false
if (rotateEnabled) {
// 轮换模式:并行启动所有配置了账号的视图
const allViewIds = Object.keys(viewAccountMap)
.map(Number)
.filter(id => viewAccountMap[id]?.email && viewAccountMap[id]?.pwd)
.filter(id => automationStatus[id] !== 'TikTok 运行中') // 跳过已运行的
// 先设置所有为"正在启动"状态
setAutomationStatus(prev => {
const next = { ...prev }
allViewIds.forEach(vid => { next[vid] = '正在启动...' })
return next
})
// 并行启动,使用 Promise.allSettled 确保错误隔离
const results = await Promise.allSettled(
allViewIds.map(async (vid) => {
const acc = viewAccountMap[vid]
if (!acc) throw new Error('账号不存在')
// 随机延迟 500ms ~ 3000ms 防止风控
const delay = Math.random() * 2500 + 500
await new Promise(resolve => setTimeout(resolve, delay))
const result = await window.electronAPI!.startTikTokAutomation(vid, acc)
return { vid, result }
})
)
// 更新状态
setAutomationStatus(prev => {
const next = { ...prev }
results.forEach((r, i) => {
const vid = allViewIds[i]
if (r.status === 'fulfilled' && r.value.result.success) {
next[vid] = 'TikTok 运行中'
} else {
const error = r.status === 'rejected' ? r.reason : r.value.result.error
next[vid] = `错误: ${error}`
}
})
return next
})
} else {
// 非轮换模式:只启动单个视图
setAutomationStatus(prev => ({ ...prev, [viewId]: '正在启动...' }))
try {
const result = await window.electronAPI!.startTikTokAutomation(viewId, account)
if (result.success) {
setAutomationStatus(prev => ({ ...prev, [viewId]: 'TikTok 运行中' }))
} else {
setAutomationStatus(prev => ({ ...prev, [viewId]: `错误: ${result.error}` }))
setTimeout(() => {
setAutomationStatus(prev => {
const next = { ...prev }
delete next[viewId]
return next
})
}, 3000)
}
} catch (e) {
setAutomationStatus(prev => ({ ...prev, [viewId]: '启动失败' }))
}
}
}
}, [automationStatus, viewAccountMap])
// 打开 TikTok 自动化面板(保留用于未来使用)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleOpenTikTokPanel = useCallback((viewId: number) => {
setSelectedViewId(selectedViewId === viewId ? null : viewId)
}, [selectedViewId])
// 切换到浏览器视图页面
const handleGoToBrowser = useCallback(async () => {
if (isElectron()) {
await window.electronAPI!.showViews()
}
setCurrentPage('browser')
}, [])
// 切换到配置页面
const handleGoToConfig = useCallback(async () => {
if (isElectron()) {
await window.electronAPI!.hideViews()
}
setCurrentPage('config')
}, [])
// 停止所有任务并清空缓存
const handleStopAll = useCallback(async () => {
if (!isElectron()) return
console.log('[App] 开始并行停止所有任务...')
// 并行停止所有视图的自动化,使用 Promise.allSettled 确保错误隔离
await Promise.allSettled(
Array.from({ length: 9 }, (_, i) => i + 1).map(viewId =>
window.electronAPI!.stopTikTokAutomation(viewId).catch((e: unknown) => {
console.warn(`[App] 停止视图 ${viewId} 失败:`, e)
})
)
)
// 停止轮换服务
try {
await window.electronAPI!.updateAutomationConfig({
rotationEnabled: false
} as any)
} catch (e) {
console.warn('[App] 停止轮换服务失败:', e)
}
// 清空自动化状态
setAutomationStatus({})
// 重置轮换状态
setRotationStatus(null)
// 清空视图缓存(始终执行)
try {
console.log('[App] 正在清空缓存...')
await window.electronAPI!.clearAllCache?.()
console.log('[App] 缓存清空完成')
} catch (e) {
console.warn('[App] 清空缓存失败:', e)
}
console.log('[App] 已并行停止所有任务并清空缓存')
}, [])
const currentTabConfig = tabs.find(t => t.id === currentTab)!
// 强制更新检查(仅 Electron 生产环境)
// 开发环境(端口 5173或非 Electron 环境跳过更新检查
const isDev = window.location.port === '5173'
if (!isDev && isElectron() && !updateReady) {
return <UpdateChecker onReady={() => setUpdateReady(true)} />
}
// 登录页面
if (currentPage === 'login') {
return (
<div className="animate-fadeIn">
<LoginPage
onLoginSuccess={() => setCurrentPage('config')}
/>
</div>
)
}
// 配置页面和浏览器页面都保持挂载,使用 CSS 隐藏切换
// 这样 ConfigPage 的状态不会丢失
return (
<>
{/* 配置页面 - 使用 CSS 隐藏而不是卸载,保持状态 */}
<div
className="h-full w-full animate-fadeIn"
style={{ display: currentPage === 'config' ? 'block' : 'none' }}
>
<ConfigPage
onGoToBrowser={handleGoToBrowser}
onLogout={async () => {
if (isElectron()) {
await window.electronAPI!.logout()
}
localStorage.removeItem(USER_KEY)
setCurrentPage('login')
}}
/>
<UpdateNotification />
</div>
{/* 浏览器页面 */}
<div
className="flex h-full w-full bg-gradient-to-br from-gray-50 to-gray-100 animate-fadeIn"
style={{ display: currentPage === 'browser' ? 'flex' : 'none' }}
>
{/* 侧边栏 */}
<Sidebar
tabs={tabs}
currentTab={currentTab}
onTabSwitch={handleTabSwitch}
onGoBack={handleGoToConfig}
onStopAll={handleStopAll}
isLoading={isLoading}
accountGroups={accountGroups}
rotationStatus={rotationStatus || undefined}
greetingStats={greetingStats}
automationLogs={automationLogs}
/>
{/* 内容区域 */}
<main className="flex-1 flex flex-col relative">
{/* 顶部视图切换栏 */}
<div className="h-12 bg-white border-b border-gray-200 flex items-center px-4 gap-2 shadow-sm">
<span className="text-gray-500 text-sm mr-2">:</span>
{currentTabConfig.viewIds.map((viewId, _index) => {
const account = viewAccountMap[viewId]
const activeViewId = selectedViewId || currentTabConfig.viewIds[0]
return (
<button
key={viewId}
onClick={async () => {
setSelectedViewId(viewId)
if (isElectron()) {
await window.electronAPI!.switchToView(viewId)
}
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${activeViewId === viewId
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'
}`}
>
{viewId}
{account && (
<span className="ml-1.5 text-xs opacity-70">
({account.email.split('@')[0]})
</span>
)}
</button>
)
})}
<div className="flex-1" />
{/* 状态指示 */}
{automationStatus[selectedViewId || currentTabConfig.viewIds[0]] && (
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded border border-gray-200">
{automationStatus[selectedViewId || currentTabConfig.viewIds[0]]}
</span>
)}
</div>
{/* 单个视图显示区域 */}
<div className="flex-1 relative">
<ViewPlaceholder
className="absolute inset-0"
/>
</div>
{isLoading && (
<div className="absolute inset-0 bg-slate-900/80 flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-3 border-t-primary-400 border-slate-600 rounded-full animate-spin" />
<span className="text-slate-400 text-sm">...</span>
</div>
</div>
)}
</main>
{/* 更新通知 */}
<UpdateNotification />
</div>
</>
)
}
export default App

BIN
src/assets/illustration.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

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)

147
src/hooks/useUpdate.ts Normal file
View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback } from 'react'
import { isElectron } from '../utils/electronBridge'
interface UpdateInfo {
version: string
releaseDate?: string
releaseNotes?: string
}
interface UpdateProgress {
percent: number
bytesPerSecond: number
transferred: number
total: number
}
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error'
interface UseUpdateReturn {
status: UpdateStatus
updateInfo: UpdateInfo | null
progress: UpdateProgress | null
error: string | null
currentVersion: string
checkForUpdates: () => void
downloadUpdate: () => void
installUpdate: () => void
dismissUpdate: () => void
}
/**
* 应用更新 Hook
* 管理更新状态、进度和操作
* 注意:此 Hook 仅在 Electron 环境中有效
*/
export function useUpdate(): UseUpdateReturn {
const [status, setStatus] = useState<UpdateStatus>('idle')
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [progress, setProgress] = useState<UpdateProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [currentVersion, setCurrentVersion] = useState<string>('')
// 获取当前版本
useEffect(() => {
if (!isElectron()) {
setCurrentVersion('web')
return
}
window.electronAPI!.getAppVersion().then(setCurrentVersion).catch(console.error)
}, [])
// 监听更新事件
useEffect(() => {
if (!isElectron()) return
const api = window.electronAPI!
// 正在检查
const unsubChecking = api.onUpdateChecking?.(() => {
setStatus('checking')
})
// 发现新版本
const unsubAvailable = api.onUpdateAvailable((info: UpdateInfo) => {
setUpdateInfo(info)
setStatus('available')
setError(null)
})
// 无可用更新
const unsubNotAvailable = api.onUpdateNotAvailable?.(() => {
setStatus('idle')
})
const unsubProgress = api.onUpdateProgress((prog: UpdateProgress) => {
setProgress(prog)
setStatus('downloading')
})
const unsubDownloaded = api.onUpdateDownloaded(() => {
setStatus('downloaded')
setProgress(null)
})
const unsubError = api.onUpdateError((err: { message: string }) => {
setError(err.message)
setStatus('error')
})
return () => {
unsubChecking?.()
unsubAvailable()
unsubNotAvailable?.()
unsubProgress()
unsubDownloaded()
unsubError()
}
}, [])
const checkForUpdates = useCallback(() => {
if (!isElectron()) return
setStatus('checking')
setError(null)
window.electronAPI!.checkForUpdates().then((hasUpdate: boolean) => {
if (!hasUpdate) {
setStatus('idle')
}
}).catch((e: Error) => {
setError(e.message)
setStatus('error')
})
}, [])
const downloadUpdate = useCallback(() => {
if (!isElectron()) return
setStatus('downloading')
setProgress({ percent: 0, bytesPerSecond: 0, transferred: 0, total: 0 })
window.electronAPI!.downloadUpdate().catch((e: Error) => {
setError(e.message)
setStatus('error')
})
}, [])
const installUpdate = useCallback(() => {
if (!isElectron()) return
window.electronAPI!.installUpdate()
}, [])
const dismissUpdate = useCallback(() => {
setStatus('idle')
setUpdateInfo(null)
}, [])
return {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate,
dismissUpdate
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

1023
src/pages/ConfigPage.tsx Normal file

File diff suppressed because it is too large Load Diff

269
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,269 @@
import { useState, useEffect } from 'react'
import { isElectron, getAppVersion } from '../utils/electronBridge'
import logo from '../assets/logo.png'
import illustration from '../assets/illustration.png'
const STORAGE_KEY = 'login_credentials'
const USER_KEY = 'user_data'
interface LoginCredentials {
tenantName: string
username: string
password: string
}
interface LoginPageProps {
onLoginSuccess: () => void
}
function LoginPage({ onLoginSuccess }: LoginPageProps) {
const [credentials, setCredentials] = useState<LoginCredentials>({
tenantName: '',
username: '',
password: '',
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [version, setVersion] = useState('')
// 获取应用版本号
useEffect(() => {
const fetchVersion = async () => {
const v = await getAppVersion()
setVersion(v)
}
fetchVersion()
}, [])
// 加载保存的凭据
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const data = JSON.parse(saved)
// 兼容旧数据格式userId → username
setCredentials({
tenantName: data.tenantName || '',
username: data.username || data.userId || '',
password: data.password || '',
})
}
} catch { }
}, [])
const handleChange = (field: keyof LoginCredentials, value: string) => {
setCredentials(prev => ({ ...prev, [field]: value }))
setError('')
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!credentials.tenantName || !credentials.username || !credentials.password) {
setError('请填写所有字段')
return
}
setIsLoading(true)
setError('')
try {
// 保存凭据
localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials))
console.log('[LoginPage] 开始登录...', credentials)
if (!isElectron()) {
// 非 Electron 环境,模拟登录成功
setError('非 Electron 环境,无法进行真实登录')
setIsLoading(false)
return
}
// 调用登录 API
const result = await window.electronAPI!.login(credentials)
console.log('[LoginPage] 登录结果:', result)
if (result.success && result.user) {
// 保存用户信息
localStorage.setItem(USER_KEY, JSON.stringify(result.user))
onLoginSuccess()
} else {
setError(result.error || '登录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit()
}
}
return (
<div className="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
{/* Background Shapes */}
<div
className="absolute top-[-200px] right-[-200px] w-[800px] h-[800px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
style={{ background: 'radial-gradient(circle, rgba(79, 129, 230, 0.2) 0%, rgba(79, 129, 230, 0) 70%)' }}
/>
<div
className="absolute bottom-[-100px] left-[-100px] w-[600px] h-[600px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
style={{
background: 'radial-gradient(circle, rgba(236, 72, 153, 0.15) 0%, rgba(236, 72, 153, 0) 70%)',
animationDuration: '4s'
}}
/>
<div className="container mx-auto px-4 z-10 relative flex justify-center items-center h-full">
<div className="bg-white/70 backdrop-blur-xl w-full max-w-5xl rounded-[2rem] overflow-hidden flex flex-col md:flex-row shadow-2xl border border-white/20">
{/* Left Side: Form */}
<div className="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
{/* Header / Logo */}
<div className="flex justify-center">
<img src={logo} alt="Logo" className="w-[200px] h-auto" />
</div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-800 mb-2"></h1>
<p className="text-gray-500 text-sm"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* 租户号 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input
type="text"
value={credentials.tenantName}
onChange={(e) => handleChange('tenantName', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="请输入租户号"
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* 账号 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input
type="text"
value={credentials.username}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="请输入账号"
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* 密码 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type="password"
value={credentials.password}
onChange={(e) => handleChange('password', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="请输入密码"
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
{/* 登录按钮 */}
<div className="pt-2">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed"
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
) : '登 录'}
</button>
</div>
</form>
<div className="mt-8 text-center">
<span className="text-gray-300 text-xs font-mono">v{version}</span>
</div>
</div>
{/* Right Side: Illustration */}
<div className="hidden md:flex w-1/2 bg-blue-50/50 relative items-center justify-center p-12 overflow-hidden">
{/* Decorative Circle matches login.html style */}
<div className="absolute w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-30 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"></div>
<div className="relative z-10 w-full max-w-sm">
<img
src={illustration}
alt="Illustration"
className="w-full h-auto drop-shadow-xl animate-float"
style={{ animation: 'float 6s ease-in-out infinite' }}
/>
<div className="text-center mt-8">
<h3 className="text-xl font-bold text-gray-800 mb-2"></h3>
<p className="text-gray-500 text-sm">TikTok矩阵</p>
</div>
</div>
</div>
</div>
</div>
{/* Floating Animation Keyframe Style */}
<style>{`
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-15px); }
100% { transform: translateY(0px); }
}
`}</style>
</div>
)
}
export default LoginPage

365
src/pages/UpdateChecker.tsx Normal file
View File

@@ -0,0 +1,365 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useUpdate } from '../hooks/useUpdate'
import { isElectron } from '../utils/electronBridge'
interface UpdateCheckerProps {
onReady: () => void // 无更新或更新完成后调用
}
const CHECK_TIMEOUT = 15000 // 15秒超时
const MAX_RETRIES = 3 // 最大重试次数
const AUTO_INSTALL_DELAY = 3 // 自动安装倒计时秒数
/**
* 自动安装倒计时组件
*/
function AutoInstallCountdown({ installUpdate }: { installUpdate: () => void }) {
const [countdown, setCountdown] = useState(AUTO_INSTALL_DELAY)
useEffect(() => {
if (countdown <= 0) {
installUpdate()
return
}
const timer = setTimeout(() => {
setCountdown(prev => prev - 1)
}, 1000)
return () => clearTimeout(timer)
}, [countdown, installUpdate])
return (
<div className="space-y-6 py-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-green-600 text-sm mt-2">
{countdown > 0 ? `${countdown} 秒后自动重启安装...` : '正在重启安装...'}
</p>
</div>
<button
onClick={installUpdate}
className="w-full py-3 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-sm"
>
🚀
</button>
</div>
)
}
/**
* 强制更新检查页面
* 程序启动时显示,必须完成更新才能进入主程序
* 注意:仅在 Electron 环境中有效
*/
export default function UpdateChecker({ onReady }: UpdateCheckerProps) {
const {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate
} = useUpdate()
const [checkComplete, setCheckComplete] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [isTimeout, setIsTimeout] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasStartedRef = useRef(false)
// 非 Electron 环境直接跳过更新检查
useEffect(() => {
if (!isElectron()) {
onReady()
}
}, [onReady])
// 启动检查更新(带超时)
const startCheck = useCallback(() => {
if (!isElectron()) return
setIsTimeout(false)
checkForUpdates()
// 设置超时
timeoutRef.current = setTimeout(() => {
if (status === 'checking') {
setIsTimeout(true)
// 超时后自动重试
if (retryCount < MAX_RETRIES) {
setRetryCount(prev => prev + 1)
}
}
}, CHECK_TIMEOUT)
}, [checkForUpdates, status, retryCount])
// 清理超时定时器
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
// 启动时自动检查更新(只执行一次)
useEffect(() => {
if (!hasStartedRef.current && isElectron()) {
hasStartedRef.current = true
startCheck()
}
}, [startCheck])
// 超时处理状态
const [showTimeoutError, setShowTimeoutError] = useState(false)
// 超时后自动重试,重试次数用完后显示错误
useEffect(() => {
if (isTimeout) {
if (retryCount >= MAX_RETRIES) {
// 重试次数用完,显示超时错误
console.log('[UpdateChecker] 更新检查超时,显示错误')
setShowTimeoutError(true)
} else if (retryCount > 0) {
const timer = setTimeout(() => {
startCheck()
}, 2000) // 2秒后重试
return () => clearTimeout(timer)
}
}
}, [isTimeout, retryCount, startCheck])
// 监听状态变化
useEffect(() => {
// 状态不再是 checking清除超时
if (status !== 'checking' && timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
setIsTimeout(false)
}
// 检查完成且无更新,直接进入程序
if (status === 'idle' && checkComplete) {
onReady()
}
}, [status, checkComplete, onReady])
// 标记检查已完成(从 checking 变为其他状态时)
useEffect(() => {
if (status !== 'checking' && status !== 'idle') {
setCheckComplete(true)
}
// 如果检查后直接变成 idle无更新也标记完成
if (status === 'idle') {
const timer = setTimeout(() => {
setCheckComplete(true)
}, 500) // 等待0.5秒确认无更新
return () => clearTimeout(timer)
}
}, [status])
// 自动开始下载(发现更新后)
const handleDownload = useCallback(() => {
downloadUpdate()
}, [downloadUpdate])
// 非 Electron 环境不渲染
if (!isElectron()) {
return null
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center p-6">
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-50" />
<div className="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-100 rounded-full blur-3xl opacity-50" />
</div>
<div className="relative z-10 w-full max-w-md">
{/* Logo 和标题 */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg">
<svg className="w-8 h-8 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>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-500 text-sm">当前版本: v{currentVersion || '...'}</p>
</div>
{/* 更新卡片 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-xl p-6">
{/* 检查中 */}
{status === 'checking' && !showTimeoutError && (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-4 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-900 font-medium">...</p>
<p className="text-gray-500 text-sm mt-2">
{retryCount > 0 ? `${retryCount}/${MAX_RETRIES} 次重试...` : '请稍候'}
</p>
</div>
)}
{/* 超时错误 */}
{showTimeoutError && (
<div className="space-y-6 py-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-orange-50 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-orange-500" 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>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-orange-600 text-sm mt-2"></p>
</div>
<button
onClick={() => {
setShowTimeoutError(false)
setRetryCount(0)
hasStartedRef.current = false
startCheck()
}}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
>
🔄
</button>
<p className="text-center text-gray-400 text-xs">
</p>
</div>
)}
{/* 发现新版本 */}
{status === 'available' && updateInfo && (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-green-600 font-mono mt-2">v{updateInfo.version}</p>
</div>
{updateInfo.releaseNotes && (
<div className="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
<p className="text-gray-600 text-sm whitespace-pre-wrap">{updateInfo.releaseNotes}</p>
</div>
)}
<button
onClick={handleDownload}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
>
</button>
<p className="text-center text-gray-400 text-xs">
使
</p>
</div>
)}
{/* 下载中 */}
{status === 'downloading' && progress && (
<div className="space-y-6 py-4">
<div className="text-center">
<p className="text-gray-900 font-medium mb-1"></p>
<p className="text-4xl font-bold text-blue-600">{progress.percent.toFixed(0)}%</p>
</div>
<div className="space-y-2">
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 rounded-full"
style={{ width: `${progress.percent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{formatBytes(progress.transferred)} / {formatBytes(progress.total)}</span>
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
</div>
</div>
<p className="text-center text-gray-400 text-sm">
...
</p>
</div>
)}
{/* 下载完成 - 自动重启安装 */}
{status === 'downloaded' && (
<AutoInstallCountdown installUpdate={installUpdate} />
)}
{/* 错误 */}
{status === 'error' && (
<div className="space-y-6 py-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" 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>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-red-500 text-sm mt-2">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={startCheck}
className="flex-1 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
>
🔄
</button>
<button
onClick={onReady}
className="flex-1 py-3 bg-gray-100 text-gray-600 rounded-lg font-medium hover:bg-gray-200 transition-all border border-gray-200"
>
</button>
</div>
<p className="text-center text-gray-400 text-xs">
使
</p>
</div>
)}
</div>
{/* 底部版权 */}
<p className="text-center text-gray-400 text-xs mt-6">
© 2025 Yolo
</p>
</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]}`
}

150
src/styles/index.css Normal file
View File

@@ -0,0 +1,150 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 自定义样式 */
:root {
--sidebar-width: 200px;
--color-bg-dark: #0f172a;
--color-bg-sidebar: #1e293b;
--color-accent: #38bdf8;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: auto;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(to bottom right, #f8fafc, #f1f5f9);
color: #1e293b;
}
/* 禁止选中(桌面应用体验) */
body {
user-select: none;
-webkit-user-select: none;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 按钮基础样式 */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-slate-900;
}
.btn-primary {
@apply bg-gradient-to-r from-primary-500 to-primary-600 text-white;
@apply hover:from-primary-400 hover:to-primary-500;
@apply active:from-primary-600 active:to-primary-700;
}
.btn-secondary {
@apply bg-slate-700 text-slate-200;
@apply hover:bg-slate-600;
@apply active:bg-slate-800;
}
.btn-ghost {
@apply bg-transparent text-slate-300;
@apply hover:bg-slate-700/50;
}
/* 卡片样式 */
.card {
@apply bg-slate-800/50 backdrop-blur-sm rounded-xl border border-slate-700/50;
@apply shadow-lg shadow-black/20;
}
/* 动画 */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.4);
}
50% {
box-shadow: 0 0 20px 4px rgba(56, 189, 248, 0.2);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* 页面切换动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slideInLeft {
animation: slideInLeft 0.3s ease-out forwards;
}
/* 更新通知弹出动画 */
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slideUp {
animation: slideUp 0.3s ease-out forwards;
}

147
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,147 @@
/**
* Electron API 类型定义
* 从 preload/index.ts 提取的类型,用于独立前端项目
*/
export type TabId = 'A' | 'B' | 'C'
export interface Account {
email: string
pwd: string
group?: string
}
export interface AutomationConfig {
aiReplyEnabled: boolean
isGreetFirst: boolean
prologueList: Record<string, string[]>
sleepTime: number
needTranslate: boolean
inviteThreshold: number
accounts: Account[]
tenantId: string
token: string
filters: {
minFans: number
maxFans: number
minCoins: number
maxCoins: number
minOnlineFans: number
maxOnlineFans: number
hostsLevelList: number[]
gold: boolean
ordinary: boolean
}
}
export interface AutomationLog {
viewId: number
level: 'info' | 'warn' | 'error'
message: string
}
export interface RotationStatus {
enabled: boolean
currentActiveGroup: string
modeStartTime: number
instanceModes: { viewId: number; email: string; group: string; mode: 'active' | 'background' }[]
}
export interface UpdateInfo {
version: string
releaseDate?: string
releaseNotes?: string
}
export interface UpdateProgress {
percent: number
bytesPerSecond: number
transferred: number
total: number
}
export interface GreetingStats {
greetingCount: number
inviteCount: number
}
export interface ElectronAPI {
// 基础视图控制
hideViews: () => Promise<{ success: boolean }>
showViews: () => Promise<{ success: boolean }>
switchTab: (tab: TabId) => Promise<{ success: boolean; currentTab?: TabId; error?: string }>
switchToView: (viewId: number) => Promise<{ success: boolean; currentViewId?: number; error?: string }>
getCurrentTab: () => Promise<TabId>
getCurrentViewId: () => Promise<number>
runAutomation: (viewId: number) => Promise<{ success: boolean; message: string }>
loadUrl: (viewId: number, url: string) => Promise<{ success: boolean; error?: string }>
getViewsInfo: () => Promise<{ id: number; url: string }[]>
// 登录
login: (credentials: { tenantName: string; username: string; password: string }) =>
Promise<{ success: boolean; user?: unknown; error?: string }>
logout: () => Promise<{ success: boolean; error?: string }>
// TikTok 自动化
startTikTokAutomation: (viewId: number, account: Account) => Promise<{ success: boolean; message?: string; error?: string }>
stopTikTokAutomation: (viewId: number) => Promise<{ success: boolean; message?: string; error?: string }>
updateAutomationConfig: (config: Partial<AutomationConfig>) => Promise<{ success: boolean }>
getAutomationConfig: () => Promise<AutomationConfig>
// 清空缓存
clearAllCache: () => Promise<void>
// 调试工具
openDevtools: (viewId: number) => Promise<{ success: boolean; error?: string }>
// 数据存储
saveAIConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
loadAIConfig: () => Promise<Record<string, unknown>>
loadAnchorData: () => Promise<unknown[]>
saveAnchorData: (data: unknown[]) => Promise<{ success: boolean }>
saveRunConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
loadRunConfig: () => Promise<Record<string, unknown> | null>
// 翻译
translate: (text: string, targetLang: string) => Promise<{ success: boolean; result: string; error?: string }>
// 轮换状态
getRotationStatus: () => Promise<RotationStatus>
// 打招呼统计
getGreetingStats: () => Promise<GreetingStats>
// 获取打招呼内容
fetchPrologue: () => Promise<{ success: boolean; data?: string[]; error?: string }>
// 健康检查
checkHealth: () => Promise<{ success: boolean; code?: number; message?: string; error?: string }>
// 更新相关
checkForUpdates: () => Promise<boolean>
downloadUpdate: () => Promise<void>
installUpdate: () => void
getAppVersion: () => Promise<string>
// 事件监听
onAutomationLog: (callback: (log: AutomationLog) => void) => () => void
onRotationStatusChanged: (callback: (status: RotationStatus) => void) => () => void
onRequestSaveConfig: (callback: () => void) => () => void
onRequestClearLogin: (callback: () => void) => () => void
onUpdateChecking: (callback: () => void) => () => void
onUpdateAvailable: (callback: (info: UpdateInfo) => void) => () => void
onUpdateNotAvailable: (callback: () => void) => () => void
onUpdateProgress: (callback: (progress: UpdateProgress) => void) => () => void
onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void
onUpdateError: (callback: (error: { message: string }) => void) => () => void
onGreetingStatsChanged: (callback: (stats: GreetingStats) => void) => () => void
}
// 声明全局类型
declare global {
interface Window {
electronAPI?: ElectronAPI
}
}
export { }

View File

@@ -0,0 +1,54 @@
/**
* Electron API 桥接层
* 用于检测运行环境并提供安全的 API 访问
*/
import type { ElectronAPI } from '../types/electron'
/**
* 检测是否在 Electron 环境中运行
*/
export const isElectron = (): boolean => {
return typeof window !== 'undefined' && !!window.electronAPI
}
/**
* 获取 Electron API非 Electron 环境返回 null
*/
export const getElectronAPI = (): ElectronAPI | null => {
return isElectron() ? window.electronAPI! : null
}
/**
* 安全调用 Electron API 方法
* 如果不在 Electron 环境中,返回 undefined 或默认值
*/
export async function safeElectronCall<T>(
apiCall: (api: ElectronAPI) => Promise<T>,
defaultValue?: T
): Promise<T | undefined> {
const api = getElectronAPI()
if (!api) {
console.warn('[ElectronBridge] Not running in Electron environment')
return defaultValue
}
try {
return await apiCall(api)
} catch (error) {
console.error('[ElectronBridge] API call failed:', error)
return defaultValue
}
}
/**
* 获取应用版本(非 Electron 环境返回 'web'
*/
export async function getAppVersion(): Promise<string> {
const api = getElectronAPI()
if (!api) return 'web'
try {
return await api.getAppVersion()
} catch {
return 'unknown'
}
}

View File

@@ -0,0 +1,379 @@
/**
* 大区语言映射器
* 根据大区数据获取语言代码
*/
// 完整的大区数据(来自大区.json
const REGIONS_DATA: Record<string, { code: string; name: string }[]> = {
"中东及北非": [
{ "code": "AE", "name": "阿拉伯联合酋长国" },
{ "code": "BH", "name": "巴林" },
{ "code": "DJ", "name": "吉布提" },
{ "code": "DZ", "name": "阿尔及利亚" },
{ "code": "EG", "name": "埃及" },
{ "code": "IQ", "name": "伊拉克" },
{ "code": "JO", "name": "约旦" },
{ "code": "KM", "name": "科摩罗" },
{ "code": "KW", "name": "科威特" },
{ "code": "LB", "name": "黎巴嫩" },
{ "code": "LY", "name": "利比亚" },
{ "code": "MA", "name": "摩洛哥" },
{ "code": "MR", "name": "毛里塔尼亚" },
{ "code": "OM", "name": "阿曼" },
{ "code": "PS", "name": "巴勒斯坦" },
{ "code": "QA", "name": "卡塔尔" },
{ "code": "SA", "name": "沙特阿拉伯" },
{ "code": "SD", "name": "苏丹" },
{ "code": "SO", "name": "索马里" },
{ "code": "TN", "name": "突尼斯" },
{ "code": "YE", "name": "也门" }
],
"拉美": [
{ "code": "AR", "name": "阿根廷" },
{ "code": "BO", "name": "玻利维亚" },
{ "code": "CL", "name": "智利" },
{ "code": "CO", "name": "哥伦比亚" },
{ "code": "CR", "name": "哥斯达黎加" },
{ "code": "DO", "name": "多米尼加共和国" },
{ "code": "EC", "name": "厄瓜多尔" },
{ "code": "GN", "name": "几内亚" },
{ "code": "GT", "name": "危地马拉" },
{ "code": "HN", "name": "洪都拉斯" },
{ "code": "MX", "name": "墨西哥" },
{ "code": "NI", "name": "尼加拉瓜" },
{ "code": "PA", "name": "巴拿马" },
{ "code": "PE", "name": "秘鲁" },
{ "code": "PY", "name": "巴拉圭" },
{ "code": "SV", "name": "萨尔瓦多" },
{ "code": "UY", "name": "乌拉圭" },
{ "code": "VE", "name": "委内瑞拉" }
],
"中亚及高加索": [
{ "code": "AM", "name": "亚美尼亚" },
{ "code": "AZ", "name": "阿塞拜疆" },
{ "code": "BY", "name": "白俄罗斯" },
{ "code": "GE", "name": "格鲁吉亚" },
{ "code": "KG", "name": "吉尔吉斯斯坦" },
{ "code": "KZ", "name": "哈萨克斯坦" },
{ "code": "MD", "name": "摩尔多瓦" },
{ "code": "TJ", "name": "塔吉克斯坦" },
{ "code": "TM", "name": "土库曼斯坦" },
{ "code": "UA", "name": "乌克兰" },
{ "code": "UZ", "name": "乌兹别克斯坦" }
],
"澳新及大洋洲": [
{ "code": "AU", "name": "澳大利亚" },
{ "code": "CC", "name": "科科斯(基林)群岛" },
{ "code": "CK", "name": "库克群岛" },
{ "code": "CX", "name": "圣诞岛" },
{ "code": "FJ", "name": "斐济" },
{ "code": "FM", "name": "密克罗尼西亚" },
{ "code": "KI", "name": "基里巴斯" },
{ "code": "MH", "name": "马绍尔群岛" },
{ "code": "NF", "name": "诺福克岛" },
{ "code": "NR", "name": "瑙鲁" },
{ "code": "NU", "name": "纽埃" },
{ "code": "NZ", "name": "新西兰" },
{ "code": "PG", "name": "巴布亚新几内亚" },
{ "code": "PN", "name": "皮特凯恩群岛" },
{ "code": "PW", "name": "帕劳" },
{ "code": "SB", "name": "索罗门群岛" },
{ "code": "TK", "name": "托克劳群岛" },
{ "code": "TO", "name": "汤加" },
{ "code": "TV", "name": "图瓦卢" },
{ "code": "VU", "name": "瓦努阿图" },
{ "code": "WF", "name": "瓦利斯" },
{ "code": "WS", "name": "萨摩亚" }
],
"英国及周边": [
{ "code": "CD", "name": "刚果民主共和国" },
{ "code": "GB", "name": "英国" },
{ "code": "GG", "name": "根西岛" },
{ "code": "GI", "name": "直布罗陀" },
{ "code": "IE", "name": "爱尔兰" },
{ "code": "IM", "name": "马恩岛" },
{ "code": "JE", "name": "泽西岛" },
{ "code": "MT", "name": "马耳他" },
{ "code": "SJ", "name": "斯瓦尔巴和扬马延" }
],
"法国及周边": [
{ "code": "FR", "name": "法国" },
{ "code": "GF", "name": "法属圭亚那" },
{ "code": "MC", "name": "摩纳哥" },
{ "code": "NC", "name": "新喀里多尼亚" },
{ "code": "PF", "name": "法属玻利尼西亚" },
{ "code": "RE", "name": "留尼汪" },
{ "code": "TF", "name": "法属南部领地" }
],
"葡语区": [
{ "code": "AO", "name": "安哥拉" },
{ "code": "CV", "name": "佛得角" },
{ "code": "GQ", "name": "赤道几内亚" },
{ "code": "GW", "name": "几内亚比绍" },
{ "code": "MZ", "name": "莫桑比克" },
{ "code": "PT", "name": "葡萄牙" },
{ "code": "ST", "name": "圣多美和普林西比" }
],
"德国及周边": [
{ "code": "AT", "name": "奥地利" },
{ "code": "CH", "name": "瑞士" },
{ "code": "DE", "name": "德国" },
{ "code": "LI", "name": "列支敦士登" }
],
"意大利及周边": [
{ "code": "IT", "name": "意大利" },
{ "code": "SM", "name": "圣马利诺" }
],
"北欧": [
{ "code": "DK", "name": "丹麦" },
{ "code": "FI", "name": "芬兰" },
{ "code": "FO", "name": "法罗群岛" },
{ "code": "GL", "name": "格陵兰" },
{ "code": "IS", "name": "冰岛" },
{ "code": "NO", "name": "挪威" },
{ "code": "SE", "name": "瑞典" }
],
"美国": [
{ "code": "AS", "name": "美属萨摩亚" },
{ "code": "GU", "name": "关岛" },
{ "code": "MP", "name": "北马里亚纳群岛" },
{ "code": "UM", "name": "美国本土外小岛屿" },
{ "code": "US", "name": "美国" },
{ "code": "PR", "name": "波多黎各" },
{ "code": "VI", "name": "美属维尔京群岛" }
],
"西班牙及周边": [
{ "code": "AD", "name": "安道尔" },
{ "code": "ES", "name": "西班牙" }
],
"中欧(捷匈斯)": [
{ "code": "CZ", "name": "捷克共和国" },
{ "code": "HU", "name": "匈牙利" },
{ "code": "SK", "name": "斯洛伐克" }
],
"波罗的海三国": [
{ "code": "EE", "name": "爱沙尼亚" },
{ "code": "LT", "name": "立陶宛" },
{ "code": "LV", "name": "拉脱维亚" }
],
"巴尔干地区": [
{ "code": "AL", "name": "阿尔巴尼亚" },
{ "code": "BA", "name": "波斯尼亚和黑塞哥维那" },
{ "code": "BG", "name": "保加利亚" },
{ "code": "GR", "name": "希腊" },
{ "code": "HR", "name": "克罗地亚" },
{ "code": "ME", "name": "黑山" },
{ "code": "MK", "name": "北马其顿" },
{ "code": "RS", "name": "塞尔维亚" },
{ "code": "SI", "name": "斯洛文尼亚" }
],
"巴西": [{ "code": "BR", "name": "巴西" }],
"日本": [{ "code": "JP", "name": "日本" }],
"韩国": [{ "code": "KR", "name": "韩国" }],
"中国台湾": [{ "code": "TW", "name": "台湾" }],
"印度尼西亚": [{ "code": "ID", "name": "印度尼西亚" }],
"马来西亚": [{ "code": "MY", "name": "马来西亚" }],
"菲律宾": [{ "code": "PH", "name": "菲律宾" }],
"泰国": [{ "code": "TH", "name": "泰国" }],
"越南": [{ "code": "VN", "name": "越南" }],
"土耳其": [{ "code": "TR", "name": "土耳其" }],
"罗马尼亚": [{ "code": "RO", "name": "罗马尼亚" }],
"波兰": [{ "code": "PL", "name": "波兰" }],
"荷兰": [{ "code": "NL", "name": "荷兰" }]
}
// ISO 国家代码 → 语言代码映射(完整版)
const COUNTRY_CODE_TO_LANGUAGE: Record<string, string> = {
// 阿拉伯语区
AE: 'ar', BH: 'ar', DJ: 'ar', DZ: 'ar', EG: 'ar', IQ: 'ar', JO: 'ar',
KM: 'ar', KW: 'ar', LB: 'ar', LY: 'ar', MA: 'ar', MR: 'ar', OM: 'ar',
PS: 'ar', QA: 'ar', SA: 'ar', SD: 'ar', TN: 'ar', YE: 'ar',
SO: 'so', // 索马里语
// 西班牙语区(拉美)
AR: 'es', BO: 'es', CL: 'es', CO: 'es', CR: 'es', DO: 'es', EC: 'es',
GT: 'es', HN: 'es', MX: 'es', NI: 'es', PA: 'es', PE: 'es', PY: 'es',
SV: 'es', UY: 'es', VE: 'es', ES: 'es',
GQ: 'es', // 赤道几内亚
// 中亚及高加索
AM: 'hy', AZ: 'az', BY: 'be', GE: 'ka', KG: 'ky', KZ: 'kk',
MD: 'ro', TJ: 'tg', TM: 'tk', UA: 'uk', UZ: 'uz',
// 澳新及大洋洲(主要英语区)
AU: 'en', NZ: 'en', FJ: 'en', PG: 'en',
CC: 'en', CK: 'en', CX: 'en', FM: 'en', KI: 'en', MH: 'en', NF: 'en',
NR: 'en', NU: 'en', PN: 'en', PW: 'en', SB: 'en', TK: 'en', TV: 'en',
WS: 'sm', TO: 'to', VU: 'bi', WF: 'fr',
// 英国及周边
GB: 'en', IE: 'en', GG: 'en', GI: 'en', IM: 'en', JE: 'en',
CD: 'fr', // 刚果民主共和国(法语)
SJ: 'no', // 斯瓦尔巴和扬马延(挪威语)
MT: 'mt', // 马耳他语
// 法语区
FR: 'fr', MC: 'fr', GF: 'fr', NC: 'fr', PF: 'fr', RE: 'fr', TF: 'fr',
GN: 'fr', // 几内亚
// 葡语区
AO: 'pt', CV: 'pt', GW: 'pt', MZ: 'pt', PT: 'pt', ST: 'pt', BR: 'pt',
// 德语区
AT: 'de', CH: 'de', DE: 'de', LI: 'de',
// 意大利语
IT: 'it', SM: 'it',
// 北欧
DK: 'da', FI: 'fi', IS: 'is', NO: 'no', SE: 'sv',
GL: 'kl', // 格陵兰语
// 美国
US: 'en', AS: 'en', GU: 'en', MP: 'en', UM: 'en', VI: 'en',
PR: 'es', // 波多黎各(西班牙语)
// 西班牙及周边
AD: 'ca', // 安道尔(加泰罗尼亚语)
// 中欧
CZ: 'cs', HU: 'hu', SK: 'sk',
// 波罗的海
EE: 'et', LT: 'lt', LV: 'lv',
// 巴尔干
AL: 'sq', BA: 'bs', BG: 'bg', GR: 'el', HR: 'hr',
ME: 'sr', MK: 'mk', RS: 'sr', SI: 'sl',
// 亚洲
JP: 'ja', KR: 'ko', TW: 'zh-TW', ID: 'id', MY: 'ms',
PH: 'tl', TH: 'th', VN: 'vi', TR: 'tr',
// 其他
RO: 'ro', PL: 'pl', NL: 'nl',
}
// 语言代码 → 语言名称(用于显示)
const LANGUAGE_NAMES: 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': '格陵兰语',
}
/**
* 获取所有大区名称列表
*/
export function getRegions(): string[] {
return Object.keys(REGIONS_DATA)
}
/**
* 获取某个大区的所有国家
*/
export function getCountriesForRegion(region: string): { code: string; name: string }[] {
return REGIONS_DATA[region] || []
}
/**
* 获取某个大区的所有语言代码(去重)
*/
export function getLanguagesForRegion(region: string): string[] {
const countries = REGIONS_DATA[region] || []
const languages = new Set<string>()
for (const country of countries) {
const lang = COUNTRY_CODE_TO_LANGUAGE[country.code]
if (lang) {
languages.add(lang)
}
}
return Array.from(languages)
}
/**
* 获取多个大区的所有语言代码(去重)
*/
export function getLanguagesForRegions(regions: string[]): string[] {
const languages = new Set<string>()
for (const region of regions) {
const regionLangs = getLanguagesForRegion(region)
for (const lang of regionLangs) {
languages.add(lang)
}
}
return Array.from(languages)
}
/**
* 获取语言名称
*/
export function getLanguageName(langCode: string): string {
return LANGUAGE_NAMES[langCode] || langCode
}
/**
* 获取大区内的语言信息(用于展示)
*/
export function getLanguageInfoForRegion(region: string): { code: string; name: string }[] {
const languages = getLanguagesForRegion(region)
return languages.map(code => ({
code,
name: LANGUAGE_NAMES[code] || code
}))
}

37
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/// <reference types="vite/client" />
// 图片模块声明
declare module '*.png' {
const src: string
export default src
}
declare module '*.jpg' {
const src: string
export default src
}
declare module '*.jpeg' {
const src: string
export default src
}
declare module '*.gif' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare module '*.ico' {
const src: string
export default src
}
declare module '*.webp' {
const src: string
export default src
}

40
tailwind.config.js Normal file
View File

@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}
},
animation: {
'fadeIn': 'fadeIn 0.3s ease-in-out',
'slideIn': 'slideIn 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src"
]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
host: true
},
build: {
outDir: 'dist',
sourcemap: false
}
})