初始化
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
12
index.html
Normal 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>Yolo(AI助手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
2663
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
567
src/App.tsx
Normal file
567
src/App.tsx
Normal 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 = `Yolo(AI助手Web版)v${version}`
|
||||
} catch {
|
||||
document.title = 'Yolo(AI助手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
BIN
src/assets/illustration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
102
src/components/AIConfigDialog.tsx
Normal file
102
src/components/AIConfigDialog.tsx
Normal 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
|
||||
150
src/components/AutomationPanel.tsx
Normal file
150
src/components/AutomationPanel.tsx
Normal 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
|
||||
500
src/components/GreetingDialog.tsx
Normal file
500
src/components/GreetingDialog.tsx
Normal 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
|
||||
474
src/components/HostListDialog.tsx
Normal file
474
src/components/HostListDialog.tsx
Normal 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
283
src/components/Sidebar.tsx
Normal 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)
|
||||
157
src/components/UpdateNotification.tsx
Normal file
157
src/components/UpdateNotification.tsx
Normal 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]}`
|
||||
}
|
||||
28
src/components/ViewPlaceholder.tsx
Normal file
28
src/components/ViewPlaceholder.tsx
Normal 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
147
src/hooks/useUpdate.ts
Normal 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
10
src/main.tsx
Normal 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
1023
src/pages/ConfigPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
269
src/pages/LoginPage.tsx
Normal file
269
src/pages/LoginPage.tsx
Normal 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
365
src/pages/UpdateChecker.tsx
Normal 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
150
src/styles/index.css
Normal 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
147
src/types/electron.d.ts
vendored
Normal 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 { }
|
||||
54
src/utils/electronBridge.ts
Normal file
54
src/utils/electronBridge.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
379
src/utils/regionLanguageMapper.ts
Normal file
379
src/utils/regionLanguageMapper.ts
Normal 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
37
src/vite-env.d.ts
vendored
Normal 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
40
tailwind.config.js
Normal 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
32
tsconfig.json
Normal 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
21
vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user