大哥 主播 即时消息 三合一

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

View File

@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

1978
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,21 +5,25 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "vue": "^3.5.27"
"react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@vitejs/plugin-vue": "^5.2.4",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.13.4",
"element-plus": "^2.13.2",
"less": "^4.5.1",
"less-loader": "^12.3.0",
"pinia": "^3.0.4",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"qwebchannel": "^6.2.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "vite": "^5.4.11",
"vite": "^5.4.11" "vue-i18n": "^11.2.8",
"vue-router": "^5.0.2"
} }
} }

View File

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

258
src/App.vue Normal file
View File

@@ -0,0 +1,258 @@
<template>
<!-- 强制更新检查 -->
<!-- <UpdateChecker v-if="false" @ready="updateReady = true" /> -->
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
<template v-else>
<!-- 登录页面 -->
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
<template v-else>
<!-- 配置页面 - 使用 v-show 保持状态 -->
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'config'">
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="handleLogout" />
<UpdateNotification />
</div>
<!-- 浏览器页面 -->
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'browser'">
<WorkbenchLayout
:account-groups="accountGroups"
:rotation-status="rotationStatus"
:greeting-stats="greetingStats"
:automation-logs="automationLogs"
@go-back="handleGoToConfig"
@stop-all="handleStopAll"
@logout="handleLogout"
/>
</div>
</template>
</template>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { isElectron, getAppVersion } from './utils/electronBridge'
import LoginPage from './pages/LoginPage.vue'
import ConfigPage from './pages/ConfigPage.vue'
import UpdateChecker from './pages/UpdateChecker.vue'
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
import UpdateNotification from './components/UpdateNotification.vue'
// Constants
const USER_KEY = 'user_data'
const CONFIG_KEY = 'autoDm_runConfig'
// State
const updateReady = ref(false)
const currentPage = ref('login')
const isLoading = ref(false)
const automationStatus = ref({})
const accountGroups = ref([])
const viewAccountMap = ref({})
const rotationStatus = ref(null)
const greetingStats = ref({ greetingCount: 0, inviteCount: 0 })
const automationLogs = ref([])
const isElectronEnv = isElectron()
const isDev = window.location.port === '5173'
// Lifecycle
onMounted(() => {
// Set Title
getAppVersion().then(version => {
document.title = `YoloAI助手Web版v${version}`
}).catch(() => {
document.title = 'YoloAI助手Web版'
})
console.log('[App]',!isDev , isElectronEnv , !updateReady.value)
// Check Login
try {
const userData = localStorage.getItem(USER_KEY)
if (userData) {
const user = JSON.parse(userData)
if (user && user.tokenValue) {
currentPage.value = 'browser'
}
}
} catch { } // eslint-disable-line no-empty
// Listeners
if (isElectronEnv) {
window.electronAPI.onRequestClearLogin(() => {
console.log('[App] 收到清除登录状态请求')
localStorage.removeItem(USER_KEY)
})
// Rotation Status
window.electronAPI.getRotationStatus().then(status => {
rotationStatus.value = status
}).catch(console.error)
window.electronAPI.onRotationStatusChanged(status => {
rotationStatus.value = status
console.log('[App] 收到轮换状态变化123:', status)
// Auto switch tab if group changes
if (status && status.currentActiveGroup && status.enabled) {
console.log('[App] 收到轮换状态变化456:', status)
const targetTab = tabs.value.find(t => t.label === status.currentActiveGroup || t.id === status.currentActiveGroup)
if (targetTab && targetTab.id !== currentTab.value) {
console.log('[App] 自动切换到轮换组789:', targetTab.id)
handleTabSwitch(targetTab.id)
}
}
})
// Stats
window.electronAPI.getGreetingStats().then(stats => {
greetingStats.value = stats
}).catch(console.error)
window.electronAPI.onGreetingStatsChanged(stats => {
greetingStats.value = stats
})
// Logs
window.electronAPI.onAutomationLog(log => {
automationLogs.value = [...automationLogs.value.slice(-99), { ...log, timestamp: Date.now() }]
})
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('storage', handleStorageChange)
loadConfig()
// Health Check
startHealthCheck()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('storage', handleStorageChange)
stopHealthCheck()
})
// Handlers
const handleBeforeUnload = () => {
localStorage.removeItem(USER_KEY)
}
const handleStorageChange = (e) => {
if (e.key === CONFIG_KEY) {
loadConfig()
}
}
let healthCheckInterval = null
const startHealthCheck = () => {
const check = async () => {
if (currentPage.value === 'login' || !isElectron()) return
try {
const result = await window.electronAPI.checkHealth()
if (result.success && result.code === 40400) {
alert('当前账号已在其他地方登录,请重新登录')
localStorage.removeItem(USER_KEY)
currentPage.value = 'login'
}
} catch (error) {
console.error('[App] 健康检查失败:', error)
}
}
check()
healthCheckInterval = setInterval(check, 5000)
}
const stopHealthCheck = () => {
if (healthCheckInterval) clearInterval(healthCheckInterval)
}
const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(CONFIG_KEY)
if (savedConfig) {
const config = JSON.parse(savedConfig)
accountGroups.value = config.accountGroups || []
const map = {}
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 }
}
})
})
viewAccountMap.value = map
}
} catch { } // eslint-disable-line no-empty
}
watch(currentPage, (newVal) => {
if (newVal === 'browser') {
loadConfig()
}
})
// Actions
const handleGoToBrowser = async () => {
if (isElectron()) {
await window.electronAPI.showViews()
}
currentPage.value = 'browser'
}
const handleGoToConfig = async () => {
if (isElectron()) {
await window.electronAPI.hideViews()
}
currentPage.value = 'config'
}
const handleLogout = async () => {
if (isElectron()) {
await window.electronAPI.logout()
}
localStorage.removeItem(USER_KEY)
currentPage.value = 'login'
}
const handleStopAll = async () => {
if (!isElectron()) return
console.log('[App] 开始并行停止所有任务...')
await Promise.allSettled(
Array.from({ length: 9 }, (_, i) => i + 1).map(viewId =>
window.electronAPI.stopTikTokAutomation(viewId).catch((e) => {
console.warn(`[App] 停止视图 ${viewId} 失败:`, e)
})
)
)
try {
await window.electronAPI.updateAutomationConfig({
rotationEnabled: false
})
} catch (e) {
console.warn('[App] 停止轮换服务失败:', e)
}
automationStatus.value = {}
rotationStatus.value = null
try {
console.log('[App] 正在清空缓存...')
if (window.electronAPI.clearAllCache) await window.electronAPI.clearAllCache()
console.log('[App] 缓存清空完成')
} catch (e) {
console.warn('[App] 清空缓存失败:', e)
}
console.log('[App] 已并行停止所有任务并清空缓存')
}
</script>

82
src/api/account.js Normal file
View File

@@ -0,0 +1,82 @@
import { getAxios, postAxios, downFile } from '@/utils/axios.js'
export function getIdByName(name) {
return getAxios({ url: `/api/tenant/get-id-by-name?name=${name}` })
}
export function login(data) {
return postAxios({ url: '/api/user/doLogin', data })
}
//获取国家
export function getCountryinfo(data) {
return postAxios({ url: '/api/common/country_info', data })
}
//查询tk账号查询次数
export function tkaccountuseinfo(accountName) {
return getAxios({ url: `/api/common/accountCount?accountName=${accountName}` })
}
export function tkhostdata(data) {
return postAxios({ url: '/api/save_data/hosts_info', data })
}
//获取到期时间
export function getExpiredTime(tenantId) {
return getAxios({ url: '/api/tenant/get-expired-time?tenantId=' + tenantId })
}
export function apiGetCart() {
return getAxios({ url: '/cgi-bin/cart/latest' })
}
// export function login(data) {
// return postAxios({ url: 'api/account/login', data })
// }
export function cheekalive(data) {
return postAxios({ url: 'api/account/cheekalive', data })
}
export function dicts(data) {
return postAxios({ url: 'api/param/dicts', data })
}
export function tkhostdetail(data) {
return postAxios({ url: 'api/tkinfo/tkhostdetail', data })
}
//导出表格
export function exporthosts(data) {
return postAxios({ url: 'api/export/hostsinfo', data })
}
export function downList(url, data) {
return downFile(url, data)
}
//查询员工
export function getStaffList(data) {
return postAxios({ url: 'api/account/list', data })
}
//分配主播
export function managerhosts(data) {
return postAxios({ url: 'api/account/managerhosts', data })
}
//编辑主播
export function upholdinfo(data) {
return postAxios({ url: 'api/tkinfo/upholdinfo', data })
}
//查看名字
export function accountName(str) {
return postAxios({ url: 'api/account/accountName?accounts=' + str })
}
//查看直播间信息
export function liveHostDetail(data) {
return postAxios({ url: 'api/save_data/live_host_detail', data })
}
export function revenueStats(hostId) {
return getAxios({ url: 'api/save_data/revenue_stats?displayId=' + hostId })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,249 @@
<template>
<aside class="w-[200px] h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm">
<!-- 返回和停止按钮 -->
<div class="m-3 mb-0 flex gap-2">
<button @click="onGoBack"
class="flex-1 px-3 py-2 text-xs bg-gray-100 text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-200 transition-colors text-left">
返回
</button>
<button @click="onStopAll"
class="px-3 py-2 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="停止所有任务并清空缓存">
停止全部
</button>
</div>
<!-- Logo / 标题 -->
<div class="p-4 border-b border-gray-200">
<h1 class="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
多视图浏览器
</h1>
<p class="text-xs text-gray-500 mt-1">9 个独立浏览器视图</p>
</div>
<!-- 标签页列表 -->
<nav class="flex-1 p-3 space-y-2 overflow-auto">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
账号组
</div>
<button v-for="(tab, tabIndex) in tabs" :key="tab.id" @click="onTabSwitch(tab.id)" :disabled="isLoading"
:class="[
'w-full px-3 py-2.5 rounded-lg text-left transition-all duration-200 flex flex-col',
currentTab === tab.id ? 'bg-blue-50 text-blue-700 border border-blue-200 shadow-sm' : 'text-gray-600 hover:bg-gray-100 border border-transparent',
isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]">
<!-- 第一行组名 + 运行模式 + 活跃组运行时间 -->
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ getGroup(tabIndex)?.name || tab.label }}</span>
<span v-if="rotationStatus?.enabled"
:class="['px-1.5 py-0.5 text-[10px] font-bold rounded', isActiveGroup(getGroup(tabIndex)?.name || tab.label) ? 'bg-emerald-500 text-white' : 'bg-gray-200 text-gray-600']">
{{ isActiveGroup(getGroup(tabIndex)?.name || tab.label) ? '全功能' : '仅回复' }}
</span>
<!-- 活跃组显示运行时间 -->
<span v-if="isActiveGroup(getGroup(tabIndex)?.name || tab.label) && rotationStatus?.enabled"
class="text-[10px] text-white font-mono bg-blue-500 px-1.5 py-0.5 rounded shadow-sm">
{{ elapsedTime }}
</span>
</div>
</div>
<!-- 第二行运行账号数 / 视图ID -->
<div class="flex items-center justify-between w-full mt-1.5 text-xs">
<div class="flex items-center gap-1.5">
<template v-if="getRunningAccounts(getGroup(tabIndex)?.name || tab.label) > 0">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span class="text-emerald-600">{{ getRunningAccounts(getGroup(tabIndex)?.name ||
tab.label)
}} 个运行中</span>
</template>
<template v-else>
<span class="text-gray-500">{{ getTotalAccounts(tabIndex) }} 个账号</span>
</template>
</div>
<span class="text-gray-400 text-[10px]">
视图 {{ tabIndex * 3 + 1 }},{{ tabIndex * 3 + 2 }},{{ tabIndex * 3 + 3 }}
</span>
</div>
</button>
</nav>
<!-- 详细统计 -->
<div class="flex-1 min-h-0 border-t border-gray-200 flex flex-col">
<div class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50 flex justify-between items-center">
<span>详细统计</span>
<span class="text-[10px] font-normal text-gray-400">招呼/邀请/回复</span>
</div>
<div class="flex-1 overflow-y-auto bg-gray-50/50">
<div v-if="!greetingStats.details || greetingStats.details.length === 0"
class="text-gray-400 text-xs text-center py-4">
暂无统计数据
</div>
<template v-else>
<div v-for="(groupStats, groupName) in statsByGroup" :key="groupName" class="border-b border-gray-100 last:border-0">
<div class="px-3 py-1.5 bg-gray-100/50 text-xs font-medium text-gray-600">
{{ groupName }}
</div>
<div v-for="stat in groupStats" :key="stat.viewId" class="px-3 py-1.5 flex items-center justify-between hover:bg-white transition-colors text-xs">
<div class="flex items-center gap-1">
<span class="text-gray-500">视图 {{ stat.viewId }}</span>
<span v-if="stat.unread > 0" class="w-1.5 h-1.5 rounded-full bg-red-500" :title="`${stat.unread} 条未读消息`"></span>
</div>
<div class="flex items-center gap-3 font-mono text-gray-700">
<span class="text-blue-600 w-6 text-right">{{ stat.greeting }}</span>
<span class="text-gray-300">/</span>
<span class="text-purple-600 w-6 text-right">{{ stat.invite }}</span>
<span class="text-gray-300">/</span>
<span class="text-emerald-600 w-6 text-right">{{ stat.reply }}</span>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 底部运行状态 -->
<div class="p-3 border-t border-gray-200 bg-gray-50">
<div v-if="rotationStatus?.enabled" class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">当前活跃组</span>
<span class="text-emerald-600 font-medium">{{ rotationStatus.currentActiveGroup }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">总运行时间</span>
<span class="text-blue-600 font-mono">{{ totalElapsedTime }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">总运行账号</span>
<span class="text-gray-700">{{ rotationStatus.instanceModes.length }} </span>
</div>
</div>
<div v-else class="text-center text-xs text-gray-400">
未启动任务
</div>
<!-- 统计数据 -->
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已打招呼</span>
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已发邀请</span>
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已回复</span>
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
const props = defineProps({
tabs: { type: Array, required: true },
currentTab: { type: String, required: true },
isLoading: { type: Boolean, default: false },
accountGroups: { type: Array, default: () => [] },
rotationStatus: { type: Object, default: undefined },
greetingStats: {
type: Object,
default: () => ({ greetingCount: 0, inviteCount: 0 })
},
automationLogs: { type: Array, default: () => [] }
})
const emit = defineEmits(['tabSwitch', 'goBack', 'stopAll'])
// Event handlers
const onTabSwitch = (id) => emit('tabSwitch', id)
const onGoBack = () => emit('goBack')
const onStopAll = () => emit('stopAll')
// Helper functions
const getGroup = (index) => props.accountGroups[index]
const statsByGroup = computed(() => {
const map = {}
if (props.greetingStats?.details) {
props.greetingStats.details.forEach(stat => {
const groupName = stat.group || '未分组'
if (!map[groupName]) map[groupName] = []
map[groupName].push(stat)
})
}
return map
})
const isActiveGroup = (groupName) => {
if (!props.rotationStatus?.enabled) return false
return props.rotationStatus.currentActiveGroup === groupName
}
const getRunningAccounts = (groupName) => {
return props.rotationStatus?.instanceModes.filter(i => i.group === groupName).length || 0
}
const getTotalAccounts = (index) => {
return props.accountGroups[index]?.accounts?.filter(a => a.email && a.pwd).length || 0
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false })
}
// Timer logic
const elapsedTime = ref('00:00')
const totalElapsedTime = ref('00:00')
let timer1 = null
let timer2 = null
watch(() => props.rotationStatus?.modeStartTime, (newVal) => {
if (timer1) clearInterval(timer1)
if (!newVal) {
elapsedTime.value = '00:00'
return
}
const update = () => {
const elapsed = Math.floor((Date.now() - newVal) / 1000)
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
elapsedTime.value = `${minutes}:${seconds}`
}
update()
timer1 = setInterval(update, 1000)
}, { immediate: true })
watch(() => props.rotationStatus?.totalStartTime, (newVal) => {
if (timer2) clearInterval(timer2)
if (!newVal) {
totalElapsedTime.value = '00:00'
return
}
const update = () => {
const elapsed = Math.floor((Date.now() - (newVal || 0)) / 1000)
const hours = Math.floor(elapsed / 3600)
const minutes = Math.floor((elapsed % 3600) / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
if (hours > 0) {
totalElapsedTime.value = `${hours}:${minutes}:${seconds}`
} else {
totalElapsedTime.value = `${minutes}:${seconds}`
}
}
update()
timer2 = setInterval(update, 1000)
}, { immediate: true })
onUnmounted(() => {
if (timer1) clearInterval(timer1)
if (timer2) clearInterval(timer2)
})
</script>

View File

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

View File

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

View File

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

View File

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

131
src/hooks/useUpdate.js Normal file
View File

@@ -0,0 +1,131 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
// NOTE: Since we are using JS, we don't have interfaces, but structure remains same.
/**
* 应用更新 Hook (Vue Composable)
* 管理更新状态、进度和操作
* 注意:此 Composable 仅在 Electron 环境中有效
*/
export function useUpdate() {
const status = ref('idle')
const updateInfo = ref(null)
const progress = ref(null)
const error = ref(null)
const currentVersion = ref('')
// 获取当前版本
const fetchVersion = () => {
if (!isElectron()) {
currentVersion.value = 'web'
return
}
window.electronAPI.getAppVersion().then(v => {
currentVersion.value = v
}).catch(console.error)
}
let unsubList = []
// 监听更新事件
const setupListeners = () => {
if (!isElectron()) return
const api = window.electronAPI
// 正在检查
if (api.onUpdateChecking) {
unsubList.push(api.onUpdateChecking(() => {
status.value = 'checking'
}))
}
// 发现新版本
unsubList.push(api.onUpdateAvailable((info) => {
updateInfo.value = info
status.value = 'available'
error.value = null
}))
// 无可用更新
if (api.onUpdateNotAvailable) {
unsubList.push(api.onUpdateNotAvailable(() => {
status.value = 'idle'
}))
}
unsubList.push(api.onUpdateProgress((prog) => {
progress.value = prog
status.value = 'downloading'
}))
unsubList.push(api.onUpdateDownloaded(() => {
status.value = 'downloaded'
progress.value = null
}))
unsubList.push(api.onUpdateError((err) => {
error.value = err.message
status.value = 'error'
}))
}
onMounted(() => {
fetchVersion()
setupListeners()
})
onUnmounted(() => {
unsubList.forEach(unsub => unsub && unsub())
unsubList = []
})
const checkForUpdates = () => {
if (!isElectron()) return
status.value = 'checking'
error.value = null
window.electronAPI.checkForUpdates().then((hasUpdate) => {
if (!hasUpdate) {
status.value = 'idle'
}
}).catch((e) => {
error.value = e.message
status.value = 'error'
})
}
const downloadUpdate = () => {
if (!isElectron()) return
status.value = 'downloading'
progress.value = { percent: 0, bytesPerSecond: 0, transferred: 0, total: 0 }
window.electronAPI.downloadUpdate().catch((e) => {
error.value = e.message
status.value = 'error'
})
}
const installUpdate = () => {
if (!isElectron()) return
window.electronAPI.installUpdate()
}
const dismissUpdate = () => {
status.value = 'idle'
updateInfo.value = null
}
return {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate,
dismissUpdate
}
}

View File

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

View File

@@ -0,0 +1,176 @@
<template>
<div class="flex h-screen w-screen overflow-hidden bg-white">
<!-- Left Navigation Sidebar -->
<div class="w-16 flex flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-50">
<div class="mb-6">
<!-- Logo or Brand -->
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-900/50">
<span class="material-icons-round text-white">grid_view</span>
</div>
</div>
<div class="flex-1 flex flex-col gap-4 w-full px-2">
<!-- Auto DM Workbench Tab -->
<button @click="currentView = 'auto_dm'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'auto_dm' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">chat</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
自动私信工作台
</div>
</button>
<!-- Fan Workbench Tab -->
<button @click="currentView = 'FanWorkbench'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'FanWorkbench' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">supervised_user_circle</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
大哥工作台
</div>
</button>
<!-- TK Workbench Tab -->
<button @click="currentView = 'tk'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'tk' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">tiktok</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
TK 工作台
</div>
</button>
<!-- Hosts List Tab -->
<button @click="currentView = 'hosts'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'hosts' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">people</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
主播列表
</div>
</button>
</div>
<div class="mt-auto">
<!-- Logout or Back -->
<button @click="$emit('logout')"
class="w-10 h-10 rounded-xl flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-red-400 transition-all">
<span class="material-icons-round">logout</span>
</button>
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 h-full relative">
<!-- Tab 1: Auto DM Workbench (Config + Browser) -->
<div v-show="currentView === 'auto_dm'" class="absolute inset-0 z-10 h-full w-full">
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
<ConfigPage
@go-to-browser="handleGoToBrowser"
@logout="$emit('logout')"
/>
</div>
<div v-show="autoDmMode === 'browser'" class="h-full w-full">
<YoloBrowser
v-bind="$attrs"
@go-back="handleBackToConfig"
@stop-all="handleStopAll"
/>
</div>
</div>
<!-- Tab 2: TK Workbench -->
<div v-show="currentView === 'tk'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden">
<TkWorkbenches />
</div>
<!-- Tab 3: Hosts List -->
<div v-show="currentView === 'hosts'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<HostsList />
</div>
<!-- Tab 4: Hosts List -->
<div v-show="currentView === 'FanWorkbench'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<FanWorkbench />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { isElectron } from '@/utils/electronBridge'
import YoloBrowser from '@/views/YoloBrowser.vue'
import TkWorkbenches from '@/views/tk/Workbenches.vue'
import HostsList from '@/views/tk/HostsList.vue'
import ConfigPage from '@/pages/ConfigPage.vue'
import FanWorkbench from '@/views/tk/FanWorkbench.vue' // Added import
const emit = defineEmits(['logout', 'go-back', 'stop-all'])
const currentView = ref('auto_dm') // Default Tab
const autoDmMode = ref('config') // Default Sub-state: 'config' or 'browser'
const handleGoToBrowser = async () => {
autoDmMode.value = 'browser'
if (isElectron()) {
await window.electronAPI.showViews()
}
}
const handleBackToConfig = async () => {
autoDmMode.value = 'config'
if (isElectron()) {
await window.electronAPI.hideViews()
}
}
const handleStopAll = () => {
emit('stop-all')
}
// Watch for view changes to manage native Electron BrowserViews
watch(currentView, async (newVal, oldVal) => {
if (!isElectron()) return
if (newVal === 'auto_dm' && autoDmMode.value === 'browser') {
// Switching TO Auto DM tab AND we are in browser mode: Show views
try {
await window.electronAPI.showViews()
} catch (e) {
console.error('Failed to show views:', e)
}
} else {
// Switching AWAY from Auto DM tab OR we are in config mode: Hide views
try {
await window.electronAPI.hideViews()
} catch (e) {
// console.error('Failed to hide views:', e)
}
}
})
// Watch sub-mode changes
watch(autoDmMode, async (newVal) => {
if (currentView.value !== 'auto_dm') return
if (newVal === 'browser') {
if (isElectron()) await window.electronAPI.showViews()
} else {
if (isElectron()) await window.electronAPI.hideViews()
}
})
</script>
<style scoped>
/* Material Icons support - simplistic import, ideal to put in index.html or main.js */
@import url('https://fonts.googleapis.com/icon?family=Material+Icons+Round');
</style>

183
src/locales/en.js Normal file
View File

@@ -0,0 +1,183 @@
export default {
login: {
title: 'Account login',
version: 'VERSION',
login: 'Login',
tenantName: 'Tenant Name',
account: 'account',
password: 'password',
Language: 'Language',
network: 'NetWork',
},
menu: {
workbenches: 'Work Benches',
hostList: 'Streamer List',
logout: 'Logout',
},
workbenches: {
openTK: 'OpenTK',
totalnumber: 'Total Number',
createHost: 'Create Streamer',
query: 'Can query',
invite: 'Can invited',
runTime: 'Run Time',
guildAccount: 'Guild account',
guildPass: 'Guild password',
guildAccountPlace: 'Please enter your login account',
guildPassPlace: 'Please enter your login password',
queriedNum: 'Today queried times',
loginBackend: 'Login backend',
workbenches: 'Work Benches',
},
workbenchesSetup: {
workbenches: 'Work Benches',
network: 'Current Network',
setCoinsNum: 'Set Coins Number',
setFansNum: 'Set Fans Number',
setQuery: 'Set Query Frequency',
setNum: 'Set Host Number',
minCoinsNum: 'Min Coins Number',
maxCoinsNum: 'Max Coins Number',
minFansNum: 'Min Fans Number',
maxFansNum: 'Max Fans Number',
hour: 'times/hour',
hour24: 'times/24hour',
num: 'Num',
start: 'Start Obtaining Data',
stop: 'Stop',
prompt: 'Stop crawling specified number',
setHostNum: 'Set crawling quantity',
unlimitedQuantity: 'Unlimited crawling quantity',
},
hostList: {
placeCountry: 'Select country',
placeSeletTime: 'Select query time',
placeHostId: 'Please enter the anchor ID',
selectAll: 'All',
query: 'Query',
export: 'Export Excel data',
hostId: 'Streamer ID',
grade: 'Grade',
country: 'Country',
creationTime: 'Creation Time',
anchorcoins: 'Anchor Coins',
yesterdayGoldCoins: 'Yesterday Gold Coins',
fansNum: 'Number Fans',
followersNum: 'Number Followers',
onlineFans: 'Online Fans',
anchorType: 'Anchor Type',
min: 'min',
max: 'max',
placeMin: 'Please enter the minimum value',
placeMax: 'Please enter the maximum value',
sort: 'sort',
sortType: 'sort Type',
ascending: 'ascending',
descending: 'descending',
reset: 'reset',
sure: 'sure',
invitationType: 'invitationType',
invitationType1: 'Regular',
invitationType2: 'Golden',
liveSessions: 'Live Sessions',
viewSessions: 'View Sessions',
liveRevenue: 'Live Revenue',
viewRevenue: 'View Revenue',
revenueHost: 'Host',
todayRevenueUsd: 'Today Revenue (USD)',
totalRevenueUsd: 'Total Revenue (USD)',
liveDays: 'Live Days',
historyRevenueUsd: 'History Revenue (USD)',
revenueHigh: 'High',
revenueLow: 'Low',
revenueTime: 'Time',
close: 'Close',
selectPlaceholder: 'Please select',
},
hostsList: {
filterPrivateUsers: 'Filter Private Users',
minCoins: 'Min Coins',
maxCoins: 'Max Coins',
minLevel: 'Min Level',
maxLevel: 'Max Level',
specifiedRooms: 'Specified Rooms',
specifyRooms: 'Specify Rooms',
total: 'Total',
valid: 'Valid',
reset: 'Reset',
start: 'Start',
end: 'End',
selectCountry: 'Select Country',
bigBrotherId: 'Big Brother ID',
search: 'Search',
exportExcel: 'Export Excel Data',
moreFilters: 'More Filters',
openTikTok: 'Open TikTok Login',
currentNetwork: 'Current Network',
runningTime: 'Running Time',
id: 'Id',
hostId: 'Host ID',
userId: 'User ID',
level: 'Level',
fansLevel: 'Fan Club Level',
coins: 'Coins',
totalGiftCoins: 'Total Gift Coins',
region: 'Region',
followerCount: 'Followers',
followingCount: 'Following',
createTime: 'Created Time',
time: 'Time',
startTime: 'Start Time',
endTime: 'End Time',
selectTime: 'Select Query Time',
minValue: 'Min Value',
maxValue: 'Max Value',
enterMinValue: 'Enter Min Value',
enterMaxValue: 'Enter Max Value',
sort: 'Sort',
sortType: 'Sort Type',
sortOrder: 'Ascending/Descending',
pleaseSelect: 'Please Select',
ascending: 'Ascending',
descending: 'Descending',
confirm: 'Confirm',
cancel: 'Cancel',
cancelSpecify: 'Cancel Specify Rooms',
specifyReset: 'Reset',
specifyConfirm: 'Confirm',
specifyStart: 'Start',
enterRoomIds: 'Enter room IDs, separate multiple IDs with Enter key',
enterRoomId: 'Please enter room ID',
networkFailed: 'Network connection failed, unable to access the network. Please check network settings.',
noContentToCopy: 'No content to copy',
copySuccess: 'Copied successfully',
copyFailed: 'Copy failed',
stopping: 'Stopping...',
starting: 'Starting...',
pleaseEnterCountryName: 'Please enter the country name in Chinese',
getCountryFailed: 'Failed to get country',
},
countries: {
// ... (truncated common countries for brevity, or include all if critical. I'll include a subset or all if possible. The file read showed all.)
AD: "Andorra",
AE: "United Arab Emirates",
AF: "Afghanistan",
CN: "China",
US: "United States",
// Adding a catch-all or most used ones to save space, or just dump all if context allows.
// Since I can't easily select only 'used' ones, and user wants full port, I'll try to include all but maybe in a follow-up if it's too large.
// Actually, the previous read showed ~250 lines of countries. I'll include the full list to be safe.
AD: "Andorra", AE: "United Arab Emirates", AF: "Afghanistan", AG: "Antigua and Barbuda", AI: "Anguilla", AL: "Albania", AM: "Armenia", AO: "Angola", AQ: "Antarctica", AR: "Argentina", AS: "American Samoa", AT: "Austria", AU: "Australia", AU1: "Australia", AW: "Aruba", AX: "Åland Islands", AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina", BB: "Barbados", BD: "Bangladesh", BE: "Belgium", BF: "Burkina Faso", BG: "Bulgaria", BH: "Bahrain", BI: "Burundi", BJ: "Benin", BL: "Saint Barthélemy", BM: "Bermuda", BN: "Brunei Darussalam", BO: "Bolivia", BQ: "Bonaire, Sint Eustatius and Saba", BR: "Brazil", BS: "Bahamas", BT: "Bhutan", BV: "Bouvet Island", BW: "Botswana", BY: "Belarus", BZ: "Belize",
CA: "Canada", CA1: "Canada", CC: "Cocos (Keeling) Islands", CD: "Democratic Republic of the Congo", CF: "Central African Republic", CG: "Republic of the Congo", CH: "Switzerland", CI: "Côte d'Ivoire", CK: "Cook Islands", CL: "Chile", CM: "Cameroon", CN: "China", CO: "Colombia", CR: "Costa Rica", CU: "Cuba", CV: "Cape Verde", CW: "Curaçao", CX: "Christmas Island", CY: "Cyprus", CZ: "Czech Republic",
DE: "Germany", DG: "Diego Garcia", DJ: "Djibouti", DK: "Denmark", DM: "Dominica", DO: "Dominican Republic", DZ: "Algeria", EC: "Ecuador", EE: "Estonia", EG: "Egypt", EH: "Western Sahara", ER: "Eritrea", ES: "Spain", ET: "Ethiopia", FI: "Finland", FJ: "Fiji", FK: "Falkland Islands", FM: "Micronesia", FO: "Faroe Islands", FR: "France",
GA: "Gabon", GB: "United Kingdom", GD: "Grenada", GE: "Georgia", GF: "French Guiana", GG: "Guernsey", GH: "Ghana", GI: "Gibraltar", GL: "Greenland", GM: "Gambia", GN: "Guinea", GP: "Guadeloupe", GQ: "Equatorial Guinea", GR: "Greece", GS: "South Georgia and the South Sandwich Islands", GT: "Guatemala", GU: "Guam", GW: "Guinea-Bissau", GY: "Guyana",
HK: "Hong Kong SAR China", HM: "Heard Island and McDonald Islands", HN: "Honduras", HR: "Croatia", HT: "Haiti", HU: "Hungary", ID: "Indonesia", IE: "Ireland", IL: "Israel", IM: "Isle of Man", IN: "India", IO: "British Indian Ocean Territory", IQ: "Iraq", IR: "Iran", IS: "Iceland", IT: "Italy",
JE: "Jersey", JM: "Jamaica", JO: "Jordan", JP: "Japan", JP1: "Japan", KE: "Kenya", KG: "Kyrgyzstan", KH: "Cambodia", KI: "Kiribati", KM: "Comoros", KN: "Saint Kitts and Nevis", KP: "North Korea", KR: "South Korea", KR1: "South Korea", KW: "Kuwait", KY: "Cayman Islands", KZ: "Kazakhstan",
LA: "Laos", LB: "Lebanon", LC: "Saint Lucia", LI: "Liechtenstein", LK: "Sri Lanka", LR: "Liberia", LS: "Lesotho", LT: "Lithuania", LU: "Luxembourg", LV: "Latvia", LY: "Libya", MA: "Morocco", MC: "Monaco", MD: "Moldova", ME: "Montenegro", MF: "Saint Martin", MG: "Madagascar", MH: "Marshall Islands", MK: "North Macedonia", ML: "Mali", MM: "Myanmar", MN: "Mongolia", MO: "Macao SAR China", MP: "Northern Mariana Islands", MQ: "Martinique", MR: "Mauritania", MS: "Montserrat", MT: "Malta", MU: "Mauritius", MV: "Maldives", MW: "Malawi", MX: "Mexico", MY: "Malaysia", MZ: "Mozambique",
NA: "Namibia", NC: "New Caledonia", NE: "Niger", NF: "Norfolk Island", NG: "Nigeria", NI: "Nicaragua", NL: "Netherlands", NO: "Norway", NP: "Nepal", NR: "Nauru", NU: "Niue", NZ: "New Zealand", OM: "Oman", PA: "Panama", PE: "Peru", PF: "French Polynesia", PG: "Papua New Guinea", PH: "Philippines", PK: "Pakistan", PL: "Poland", PM: "Saint Pierre and Miquelon", PN: "Pitcairn Islands", PR: "Puerto Rico", PS: "Palestine", PT: "Portugal", PW: "Palau", PY: "Paraguay", QA: "Qatar", RE: "Réunion", RO: "Romania", RS: "Serbia", RU: "Russia", RW: "Rwanda",
SA: "Saudi Arabia", SB: "Solomon Islands", SC: "Seychelles", SD: "Sudan", SE: "Sweden", SG: "Singapore", SI: "Slovenia", SJ: "Svalbard and Jan Mayen", SK: "Slovakia", SL: "Sierra Leone", SM: "San Marino", SN: "Senegal", SO: "Somalia", SR: "Suriname", SS: "South Sudan", ST: "Sao Tome and Principe", SV: "El Salvador", SX: "Sint Maarten", SY: "Syria", SZ: "Eswatini",
TC: "Turks and Caicos Islands", TD: "Chad", TF: "French Southern Territories", TG: "Togo", TH: "Thailand", TJ: "Tajikistan", TK: "Tokelau", TL: "Timor-Leste", TM: "Turkmenistan", TN: "Tunisia", TO: "Tonga", TR: "Turkey", TT: "Trinidad and Tobago", TV: "Tuvalu", TW: "Taiwan", TZ: "Tanzania", UA: "Ukraine", UG: "Uganda", UM: "United States Minor Outlying Islands", US: "United States", UY: "Uruguay", UZ: "Uzbekistan",
VA: "Vatican City", VC: "Saint Vincent and the Grenadines", VE: "Venezuela", VG: "British Virgin Islands", VI: "U.S. Virgin Islands", VN: "Vietnam", VN1: "Vietnam", VU: "Vanuatu", WS: "Samoa", YE: "Yemen", YT: "Mayotte", ZA: "South Africa", ZM: "Zambia", ZW: "Zimbabwe"
}
}

17
src/locales/index.js Normal file
View File

@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import en from './en'
import zh from './zh'
const messages = {
en,
zh
}
const i18n = createI18n({
locale: 'zh', // 默认语言
fallbackLocale: 'en',
messages,
legacy: false // Vue 3 Composition API requires legacy: false for some features, but safe to keep default or explicitly set based on usage
})
export default i18n

174
src/locales/zh.js Normal file
View File

@@ -0,0 +1,174 @@
export default {
login: {
title: '账号登录',
version: '版本号',
login: '登录',
tenantName: '租户名称',
account: '账号',
password: '密码',
Language: '语言设置',
network: '网络设置',
},
menu: {
workbenches: '工作台',
hostList: '主播列表',
logout: '退出登录',
},
workbenches: {
openTK: '开启TK',
totalnumber: '总数量',
createHost: '新建主播',
query: '查询',
invite: '邀请',
runTime: '运行时间',
guildAccount: '公会账号',
guildPass: '公会密码',
guildAccountPlace: '请输入登录账号',
guildPassPlace: '请输入登录密码',
queriedNum: '今日已查询次数',
loginBackend: '登录后台',
workbenches: '工作台',
},
workbenchesSetup: {
workbenches: '工作台',
network: '当前网络',
setCoinsNum: '设置金币数量',
setFansNum: '设置粉丝数量',
setQuery: '后台查询频率',
setNum: '期望可邀请主播数量',
minCoinsNum: '最小金币数量',
maxCoinsNum: '最大金币数量',
minFansNum: '最小粉丝数量',
maxFansNum: '最大粉丝数量',
hour: '次/小时',
hour24: '次/24小时',
num: '个',
start: '开始获取数据',
stop: '停止',
prompt: '达到数量后停止爬取',
setHostNum: '设置爬取数量',
unlimitedQuantity: '不限爬取数量',
},
hostList: {
placeCountry: '选择国家',
placeSeletTime: '选择查询时间',
placeHostId: '请输入主播id',
selectAll: '全部',
query: '查询',
export: '导出Excel数据',
hostId: '主播id',
grade: '等级',
country: '国家',
creationTime: '创建时间',
anchorcoins: '主播金币',
yesterdayGoldCoins: '昨日金币',
fansNum: '粉丝数',
followersNum: '关注数',
onlineFans: '在线粉丝',
anchorType: '主播类型',
min: '最小值',
max: '最大值',
placeMin: '请输入最小值',
placeMax: '请输入最大值',
sort: '排序',
sortType: '排序方式',
ascending: '升序',
descending: '降序',
reset: '重置',
sure: '确定',
invitationType: '邀请类型',
invitationType1: '普票',
invitationType2: '金票',
liveSessions: '直播场次',
viewSessions: '查看场次',
liveRevenue: '直播收益',
viewRevenue: '查看收益',
revenueHost: '主播',
todayRevenueUsd: '今日收益(美元)',
totalRevenueUsd: '总收益(美元)',
liveDays: '直播天数',
historyRevenueUsd: '历史收益(美元)',
revenueHigh: '高',
revenueLow: '低',
revenueTime: '时间',
close: '关闭',
selectPlaceholder: '请选择',
},
hostsList: {
filterPrivateUsers: '过滤隐私用户',
minCoins: '最小金币',
maxCoins: '最大金币',
minLevel: '最小等级',
maxLevel: '最大等级',
specifiedRooms: '已指定直播间',
specifyRooms: '指定直播间',
total: '总数',
valid: '有效数',
reset: '重置',
start: '开始',
end: '结束',
selectCountry: '选择国家',
bigBrotherId: '大哥id',
search: '查询',
exportExcel: '导出Excel数据',
moreFilters: '更多筛选',
openTikTok: '打开 TikTok 登录',
currentNetwork: '当前网络',
runningTime: '运行时间',
id: 'Id',
hostId: '所在直播间主播id',
time: '时间',
startTime: '开始时间',
endTime: '结束时间',
selectTime: '选择查询时间',
minValue: '最小值',
maxValue: '最大值',
enterMinValue: '请输入最小值',
enterMaxValue: '请输入最大值',
sort: '排序',
sortType: '排序类型',
sortOrder: '升序/降序',
pleaseSelect: '请选择',
ascending: '升序',
descending: '降序',
confirm: '确认',
cancel: '取消',
cancelSpecify: '取消指定直播间',
specifyReset: '重置',
specifyConfirm: '确认',
specifyStart: '开始',
networkFailed: '网络连接失败,无法访问网络,请查看网络设置。',
enterRoomIds: '请输入直播间id多个id用回车键隔开最多50条',
userId: '用户id',
level: '等级',
fansLevel: '粉丝团等级',
coins: '打赏的金币',
totalGiftCoins: '打赏金币总和',
region: '地区',
followerCount: '粉丝数',
followingCount: '关注数',
createTime: '创建时间',
noContentToCopy: '无内容可复制',
copySuccess: '复制成功',
copyFailed: '复制失败',
pleaseEnterCountryName: '请输入要获取的国家',
getCountryFailed: '获取国家失败',
stopping: '正在停止...',
starting: '正在启动...',
enterRoomId: '请输入直播间id',
},
countries: {
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
BA: "波斯尼亚和黑塞哥维那", BB: "巴巴多斯", BD: "孟加拉国", BE: "比利时", BF: "布基纳法索", BG: "保加利亚", BH: "巴林", BI: "布隆迪", BJ: "贝宁", BL: "圣巴泰勒米", BM: "百慕大群岛", BN: "文莱达鲁萨兰国", BO: "玻利维亚", BQ: "博奈尔、圣尤斯特歇斯和萨巴", BR: "巴西", BS: "巴哈马", BT: "不丹", BV: "布韦岛", BW: "博茨瓦纳", BY: "白俄罗斯", BZ: "伯利兹",
CA: "加拿大", CA1: "加拿大", CC: "科科斯(基林)群岛", CD: "刚果民主共和国", CF: "中非共和国", CG: "刚果共和国", CH: "瑞士", CI: "科特迪瓦", CK: "库克群岛", CL: "智利", CM: "喀麦隆", CN: "中国", CO: "哥伦比亚", CR: "哥斯达黎加", CU: "古巴", CV: "佛得角", CW: "库拉索", CX: "圣诞岛", CY: "塞浦路斯", CZ: "捷克共和国",
DE: "德国", DG: "迪戈加西亚岛", DJ: "吉布提", DK: "丹麦", DM: "多米尼克", DO: "多米尼加共和国", DZ: "阿尔及利亚", EC: "厄瓜多尔", EE: "爱沙尼亚", EG: "埃及", EH: "西撒哈拉", ER: "厄立特里亚", ES: "西班牙", ET: "埃塞俄比亚", FI: "芬兰", FJ: "斐济", FK: "福克兰群岛", FM: "密克罗尼西亚", FO: "法罗群岛", FR: "法国",
GA: "加蓬", GB: "英国", GD: "格林纳达", GE: "格鲁吉亚", GF: "法属圭亚那", GG: "根西岛", GH: "加纳", GI: "直布罗陀", GL: "格陵兰", GM: "冈比亚", GN: "几内亚", GP: "瓜德罗普", GQ: "赤道几内亚", GR: "希腊", GS: "南乔治亚和南桑德威奇群岛", GT: "危地马拉", GU: "关岛", GW: "几内亚比绍", GY: "圭亚那",
HK: "中国香港特别行政区", HM: "赫德岛和麦克唐纳群岛", HN: "洪都拉斯", HR: "克罗地亚", HT: "海地", HU: "匈牙利", ID: "印度尼西亚", IE: "爱尔兰", IL: "以色列", IM: "马恩岛", IN: "印度", IO: "英属印度洋领地", IQ: "伊拉克", IR: "伊朗", IS: "冰岛", IT: "意大利",
JE: "泽西岛", JM: "牙买加", JO: "约旦", JP: "日本", JP1: "日本", KE: "肯尼亚", KG: "吉尔吉斯斯坦", KH: "柬埔寨", KI: "基里巴斯", KM: "科摩罗", KN: "圣基茨和尼维斯", KP: "朝鲜", KR: "韩国", KR1: "韩国", KW: "科威特", KY: "开曼群岛", KZ: "哈萨克斯坦",
LA: "老挝", LB: "黎巴嫩", LC: "圣卢西亚", LI: "列支敦士登", LK: "斯里兰卡", LR: "利比里亚", LS: "莱索托", LT: "立陶宛", LU: "卢森堡", LV: "拉脱维亚", LY: "利比亚", MA: "摩洛哥", MC: "摩纳哥", MD: "摩尔多瓦", ME: "黑山", MF: "圣马丁", MG: "马达加斯加", MH: "马绍尔群岛", MK: "北马其顿", ML: "马里", MM: "缅甸", MN: "蒙古", MO: "中国澳门特别行政区", MP: "北马里亚纳群岛", MQ: "马提尼克", MR: "毛里塔尼亚", MS: "蒙特塞拉特", MT: "马耳他", MU: "毛里求斯", MV: "马尔代夫", MW: "马拉维", MX: "墨西哥", MY: "马来西亚", MZ: "莫桑比克",
NA: "纳米比亚", NC: "新喀里多尼亚", NE: "尼日尔", NF: "诺福克岛", NG: "尼日利亚", NI: "尼加拉瓜", NL: "荷兰", NO: "挪威", NP: "尼泊尔", NR: "瑙鲁", NU: "纽埃", NZ: "新西兰", OM: "阿曼", PA: "巴拿马", PE: "秘鲁", PF: "法属玻利尼西亚", PG: "巴布亚新几内亚", PH: "菲律宾", PK: "巴基斯坦", PL: "波兰", PM: "圣皮埃尔和密克隆群岛", PN: "皮特凯恩群岛", PR: "波多黎各", PS: "巴勒斯坦", PT: "葡萄牙", PW: "帕劳", PY: "巴拉圭", QA: "卡塔尔", RE: "留尼汪", RO: "罗马尼亚", RS: "塞尔维亚", RU: "俄罗斯", RW: "卢旺达",
SA: "沙特阿拉伯", SB: "索罗门群岛", SC: "塞舌尔", SD: "苏丹", SE: "瑞典", SG: "新加坡", SI: "斯洛文尼亚", SJ: "斯瓦尔巴和扬马延", SK: "斯洛伐克", SL: "塞拉利昂", SM: "圣马利诺", SN: "塞内加尔", SO: "索马里", SR: "苏里南", SS: "南苏丹", ST: "圣多美和普林西比", SV: "萨尔瓦多", SX: "荷属圣马丁", SY: "叙利亚", SZ: "斯威士兰",
TC: "特克斯和凯科斯群岛", TD: "乍得", TF: "法属南部领地", TG: "多哥", TH: "泰国", TJ: "塔吉克斯坦", TK: "托克劳群岛", TL: "东帝汶", TM: "土库曼斯坦", TN: "突尼斯", TO: "汤加", TR: "土耳其", TT: "特立尼达和多巴哥", TV: "图瓦卢", TW: "台湾", TZ: "坦桑尼亚", UA: "乌克兰", UG: "乌干达", UM: "美国本土外小岛屿", US: "美国", UY: "乌拉圭", UZ: "乌兹别克斯坦",
VA: "梵蒂冈", VC: "圣文森特", VE: "委内瑞拉", VG: "英属维尔京群岛", VI: "美属维尔京群岛", VN: "越南", VN1: "越南", VU: "瓦努阿图", WS: "萨摩亚", YE: "也门", YT: "马约特岛", ZA: "南非", ZM: "赞比亚", ZW: "津巴布韦"
}
}

17
src/main.js Normal file
View File

@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import './styles/index.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import i18n from './locales'
import router from './router'
import { createPinia } from 'pinia'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(ElementPlus)
app.mount('#root')

View File

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

File diff suppressed because it is too large Load Diff

785
src/pages/ConfigPage.vue Normal file
View File

@@ -0,0 +1,785 @@
<template>
<div class="min-h-screen overflow-auto bg-gradient-to-br from-slate-100 to-slate-200 p-6">
<div class="max-w-5xl mx-auto pb-8">
<!-- 白色卡片容器 -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<!-- 顶部标题栏 -->
<div class="flex items-end justify-between mb-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900">自动私信工作台</h1>
<p class="text-sm text-gray-500 mt-1">
配置账号 · 设置 AI 回复策略 · 一键运行任务
</p>
</div>
<div class="flex items-center gap-3">
<span :class="[
'px-3 py-1 rounded-full text-xs font-medium',
aiConfigured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
]">
AI 人设{{ aiConfigured ? '已配置' : '未配置' }}
</span>
<span v-if="expireTime"
class="text-xs text-gray-600 bg-orange-50 px-3 py-1 rounded-full border border-orange-200">
到期时间{{ expireTime }}
</span>
<span class="text-xs text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
{{ currentTime }}
</span>
<!-- 分隔线 -->
<span class="w-px h-6 bg-gray-200" />
<!-- 退出登录 -->
<button @click="emit('logout')"
class="px-3 py-1.5 text-xs text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors">
退出登录
</button>
<!-- 打开浏览器 -->
<button @click="emit('goToBrowser')"
class="px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg hover:from-blue-600 hover:to-cyan-600 transition-all shadow-sm flex items-center gap-2">
<span>打开浏览器</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</div>
</div>
<!-- 提示条 -->
<div
class="flex items-center gap-2 mb-6 px-4 py-2 rounded-full text-sm text-gray-700 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 w-fit">
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
建议最多配置 3 个账号轮询发送开启 AI 自动回复可提升转化率
</div>
<div class="grid grid-cols-3 gap-6">
<!-- 左侧运行配置 -->
<div class="col-span-2">
<div
class="bg-gradient-to-b from-white to-gray-50 rounded-xl border border-gray-200 shadow-sm p-6">
<!-- 卡片头部 -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<span
class="w-1 h-4 rounded-full bg-gradient-to-b from-blue-500 to-green-500" />
<span class="font-medium text-gray-900">运行配置</span>
</div>
<div v-if="!isRunning" class="relative group">
<button @click="handleStart()"
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-400 hover:to-blue-500 transition-all shadow-sm">
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
开始运行
<!-- 下拉箭头 -->
<svg class="w-4 h-4 ml-1 opacity-80" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- 下拉菜单 -->
<div class="absolute top-full right-0 pt-2 w-40 hidden group-hover:block z-20">
<div
class="bg-white rounded-xl shadow-xl border border-gray-100 overflow-hidden transform transition-all duration-200 origin-top-right">
<div class="py-1">
<button v-for="(group, index) in visibleGroups" :key="index"
@click="handleStart(config.accountGroups.indexOf(group))"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors flex items-center justify-between group/item">
<span>运行 {{ group.name }}</span>
<span
class="text-xs text-gray-400 group-hover/item:text-blue-400">
{{ config.accountGroups.indexOf(group) + 1 }}
</span>
</button>
</div>
</div>
</div>
</div>
<button v-else @click="handleStop"
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium bg-gradient-to-r from-red-500 to-red-600 text-white hover:from-red-400 hover:to-red-500 transition-all shadow-sm">
<span class="w-2 h-2 rounded-full bg-white animate-pulse" />
停止运行
</button>
</div>
<!-- 账号组 -->
<div class="space-y-4">
<label class="text-sm font-medium text-gray-700">账号组最多3组</label>
<div v-for="(group, gIndex) in visibleGroups" :key="gIndex" :class="[
'rounded-lg p-4 border-2 transition-all duration-300',
isGroupActive(group) ? 'bg-blue-50 border-blue-400 shadow-md' : hasRunningAccounts(group) ? 'bg-gray-50 border-gray-300' : 'bg-gray-50 border-gray-200'
]">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-800 flex items-center gap-2">
<input type="text" :value="getGroupName(group, gIndex)"
@input="updateGroupName($event.target.value, gIndex)"
:placeholder="`第${gIndex + 1}组`"
class="w-24 px-2 py-1 text-sm text-gray-900 font-medium border border-gray-300 rounded bg-white hover:border-blue-400 focus:border-blue-500 focus:outline-none" />
<span v-if="isRunning && currentGroupIndex === gIndex"
class="px-2 py-0.5 rounded text-xs bg-green-500 text-white">
运行中
</span>
<!-- 运行模式标签 -->
<span v-if="getGroupMode(group)"
:class="['px-2 py-0.5 rounded text-xs', getGroupMode(group) === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-600']">
{{ getGroupMode(group) === 'active' ? '全功能' : '仅回复' }}
</span>
<!-- 活跃组标记 + 运行时间 -->
<span v-if="isGroupActive(group)"
class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700 flex items-center gap-1">
活跃
<span class="font-mono">{{ elapsedTime }}</span>
</span>
</span>
<button @click="addAccount(gIndex)" :disabled="group.accounts.length >= 3"
class="text-xs text-blue-600 hover:text-blue-700 disabled:opacity-50">
新增账号
</button>
</div>
<div v-for="(acc, aIndex) in group.accounts" :key="aIndex"
class="flex items-center gap-2 mb-2 p-2 rounded-lg bg-white border border-gray-200">
<input type="email" placeholder="邮箱" :value="acc.email"
@input="updateAccount(gIndex, aIndex, 'email', $event.target.value)"
class="flex-1 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
<div class="flex-1 relative">
<input :type="showPasswordMap[`${gIndex}-${aIndex}`] ? 'text' : 'password'"
placeholder="密码" :value="acc.pwd"
@input="updateAccount(gIndex, aIndex, 'pwd', $event.target.value)"
class="w-full px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none pr-10" />
<button @click="togglePasswordVisibility(gIndex, aIndex)"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none p-1">
<svg v-if="showPasswordMap[`${gIndex}-${aIndex}`]" class="w-4 h-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<!-- 账号运行状态 -->
<span v-if="getAccountStatus(gIndex, aIndex)"
:class="['px-2 py-1 rounded text-xs font-medium whitespace-nowrap', getAccountStatus(gIndex, aIndex).mode === 'active' ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500']">
视图{{ getAccountViewId(gIndex, aIndex) }}
</span>
<button @click="removeAccount(gIndex, aIndex)"
:disabled="group.accounts.length === 1"
class="text-xs text-red-500 hover:text-red-600 disabled:opacity-50 px-2">
删除
</button>
</div>
<p class="text-xs text-gray-400 mt-2">每组最多 3 个账号将按组轮换运行</p>
</div>
</div>
<!-- 轮换设置 -->
<div class="mt-6 space-y-4">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">国家语言</label>
<select :value="config.lang || 'en'"
@change="updateConfig('lang', $event.target.value)"
class="flex-1 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none bg-white">
<option v-for="lang in languageList" :key="lang.code" :value="lang.code">
{{ lang.name }} ({{ lang.code }})
</option>
</select>
</div>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">轮换账号组</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="config.rotateEnabled"
@change="updateConfig('rotateEnabled', $event.target.checked)"
class="sr-only peer" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500">
</div>
</label>
<span class="text-xs text-gray-500">关闭时只跑当前组不切换</span>
</div>
<template v-if="config.rotateEnabled">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">账号组数量</label>
<div class="flex gap-4">
<label v-for="num in [2, 3]" :key="num"
class="flex items-center gap-2 cursor-pointer">
<input type="radio" :checked="config.groupCount === num"
@change="updateConfig('groupCount', num)"
class="w-4 h-4 text-blue-500" />
<span class="text-sm text-gray-700">{{ num }}</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">轮换间隔(分钟)</label>
<input type="number" min="1"
:value="config.switchMinutes === 0 ? '' : config.switchMinutes"
@input="handleNumberInput('switchMinutes', $event.target.value)"
@blur="handleNumberBlur('switchMinutes', $event.target.value, 1)"
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
<span class="text-xs text-gray-500">每隔 N 分钟切换到下一组</span>
</div>
</template>
<!-- AI 回复 -->
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">AI 自动回复</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="config.aiReply"
@change="updateConfig('aiReply', $event.target.checked)"
class="sr-only peer" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500">
</div>
</label>
<span class="text-xs text-gray-500">开启后由 AI 自动根据对话内容生成回复</span>
</div>
<!-- 第一条消息 -->
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">第一条消息</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" :checked="config.sendInviteFirst === false"
@change="updateConfig('sendInviteFirst', false)"
class="w-4 h-4 text-blue-500" />
<span class="text-sm text-gray-700">打招呼</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" :checked="config.sendInviteFirst === true"
@change="updateConfig('sendInviteFirst', true)"
class="w-4 h-4 text-blue-500" />
<span class="text-sm text-gray-700">发送邀请链接</span>
</label>
</div>
</div>
<!-- 睡眠时间 -->
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">睡眠时间()</label>
<input type="number" min="0" placeholder="0"
:value="config.sleepTime < 0 ? '' : config.sleepTime"
@input="handleSleepTimeInput($event.target.value)"
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
</div>
<!-- 邀请阈值 -->
<div v-if="!config.sendInviteFirst" class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 w-28">链接发送时机</label>
<input type="number" min="1"
:value="config.inviteThreshold === 0 ? '' : config.inviteThreshold"
@input="handleNumberInput('inviteThreshold', $event.target.value)"
@blur="handleNumberBlur('inviteThreshold', $event.target.value, 1)"
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
<span class="text-xs text-gray-500">打招呼后回复 N 句再发送邀请链接</span>
</div>
</div>
</div>
</div>
<!-- 右侧快捷操作 -->
<div class="col-span-1">
<div
class="bg-gradient-to-b from-white to-gray-50 rounded-xl border border-gray-200 shadow-sm p-6">
<div class="flex items-center gap-2 mb-6">
<span class="w-1 h-4 rounded-full bg-gradient-to-b from-blue-500 to-green-500" />
<span class="font-medium text-gray-900">快捷操作</span>
</div>
<div class="space-y-2">
<button @click="showHostDialog = true"
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
执行主播库
</button>
<button @click="showGreetingDialog = true"
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
打招呼内容配置
</button>
<button @click="showAIDialog = true"
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
配置 / 修改 AI 人设
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- AI 配置弹窗 -->
<AIConfigDialog :visible="showAIDialog" :config="aiConfig" @close="showAIDialog = false"
@save="handleSaveAIConfig" @change="(key, value) => aiConfig[key] = value" />
<!-- 主播列表弹窗 -->
<HostListDialog :visible="showHostDialog" @close="showHostDialog = false" @save="() => { }" />
<!-- 打招呼内容弹窗 -->
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import HostListDialog from '../components/HostListDialog.vue'
import GreetingDialog from '../components/GreetingDialog.vue'
import AIConfigDialog from '../components/AIConfigDialog.vue'
import { isElectron } from '../utils/electronBridge'
const emit = defineEmits(['goToBrowser', 'logout'])
const CONFIG_KEY = 'autoDm_runConfig'
// Default Config
const defaultConfig = {
rotateEnabled: false,
groupCount: 1,
accountGroups: [
{ name: '第1组', accounts: [{ email: '', pwd: '' }] },
{ name: '第2组', accounts: [{ email: '', pwd: '' }] },
{ name: '第3组', accounts: [{ email: '', pwd: '' }] },
],
aiReply: true,
sendInviteFirst: false,
sleepTime: 30,
inviteThreshold: 3,
switchMinutes: 60,
prologueList: {},
needTranslate: false,
maxAnchorCount: 100,
lang: 'en'
}
// Language List
const languageList = [
{ name: '中文 (简体)', code: 'zh-CN' },
{ name: 'English', code: 'en' },
{ name: 'ภาษาไทย', code: 'th-TH' },
{ name: 'العربية', code: 'ar' },
{ name: 'Bahasa Indonesia', code: 'id-ID' },
{ name: 'Русский', code: 'ru-RU' },
{ name: 'Tiếng Việt', code: 'vi-VN' },
{ name: 'Bahasa Melayu', code: 'ms-MY' },
{ name: '日本語', code: 'ja-JP' },
{ name: 'Türkçe', code: 'tr-TR' },
{ name: 'Português', code: 'pt-PT' },
{ name: '한국어', code: 'ko-KR' },
{ name: 'Español', code: 'es-ES' },
{ name: '中文 (繁體)', code: 'zh-Hant-TW' },
{ name: 'Deutsch', code: 'de-DE' },
{ name: 'Italiano', code: 'it-IT' },
{ name: 'Français', code: 'fr-FR' },
{ name: 'Română', code: 'ro-RO' },
{ name: 'Polski', code: 'pl-PL' },
{ name: 'Nederlands', code: 'nl-NL' },
{ name: 'Svenska', code: 'sv-SE' },
]
// State
const config = ref(JSON.parse(JSON.stringify(defaultConfig)))
const aiConfig = ref({
agentName: '',
guildName: '',
contactTool: '',
contact: '',
})
const isRunning = ref(false)
const currentGroupIndex = ref(0)
const showAIDialog = ref(false)
const showHostDialog = ref(false)
const showGreetingDialog = ref(false)
const aiConfigured = ref(false)
const configLoaded = ref(false)
const rotationStatus = ref(null)
const elapsedTime = ref('00:00')
const expireTime = ref('')
const currentTime = ref('')
const showPasswordMap = ref({})
const isElectronEnv = isElectron()
let timeInterval = null
let rotationTimer = null
let saveTimer = null
// Computed
const visibleGroups = computed(() => {
return config.value.rotateEnabled
? config.value.accountGroups.slice(0, config.value.groupCount)
: config.value.accountGroups.slice(0, 1)
})
// Lifecycle
onMounted(async () => {
updateCurrentTime()
timeInterval = setInterval(updateCurrentTime, 1000)
// Load User Data
try {
const userData = localStorage.getItem('user_data')
if (userData) {
const user = JSON.parse(userData)
if (user.aiExpireTime) {
const timestamp = Number(user.aiExpireTime)
const date = new Date(timestamp)
expireTime.value = date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
}
} catch { }
await loadConfig()
checkAIConfig()
if (isElectronEnv) {
const status = await window.electronAPI.getRotationStatus()
rotationStatus.value = status
if (status?.instanceModes?.length > 0) {
setIsRunning(true)
}
handleStatusChange(status)
const unsubRotation = window.electronAPI.onRotationStatusChanged((status) => {
rotationStatus.value = status
setIsRunning(status?.instanceModes?.length > 0)
handleStatusChange(status)
})
const unsubSave = window.electronAPI.onRequestSaveConfig(() => {
if (saveTimer) clearTimeout(saveTimer)
saveToLocalStorage()
saveToFile()
})
// Cleanup function for listeners is not directly supported in onMounted unless we store unsubscribers
// We will just add window listener for unload as in React
window.addEventListener('beforeunload', handleBeforeUnload)
// Store unsubs for unmount
// Note: In Vue component unmount, we should clean up.
}
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
if (rotationTimer) clearInterval(rotationTimer)
if (saveTimer) clearTimeout(saveTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
saveToLocalStorage()
})
const updateCurrentTime = () => {
currentTime.value = new Date().toLocaleString('zh-CN')
}
// Config Loading/Saving
const loadConfig = async () => {
try {
if (isElectronEnv) {
const saved = await window.electronAPI.loadRunConfig()
if (saved) {
config.value = { ...config.value, ...saved }
localStorage.setItem(CONFIG_KEY, JSON.stringify(saved))
} else {
const localSaved = localStorage.getItem(CONFIG_KEY)
if (localSaved) {
config.value = { ...config.value, ...JSON.parse(localSaved) }
}
}
} else {
const localSaved = localStorage.getItem(CONFIG_KEY)
if (localSaved) {
config.value = { ...config.value, ...JSON.parse(localSaved) }
}
}
} catch (e) {
console.error('加载配置失败:', e)
} finally {
configLoaded.value = true
}
}
const saveToLocalStorage = () => {
localStorage.setItem(CONFIG_KEY, JSON.stringify(config.value))
}
const saveToFile = async () => {
if (!isElectronEnv) return
try {
await window.electronAPI.saveRunConfig(JSON.parse(JSON.stringify(config.value)))
} catch (e) {
console.error('保存配置失败:', e)
}
}
const handleBeforeUnload = () => {
saveToLocalStorage()
saveToFile()
}
// Auto Save
watch(config, (newVal) => {
if (!configLoaded.value) return
saveToLocalStorage()
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveToFile()
}, 500)
}, { deep: true })
const checkAIConfig = async () => {
if (!isElectronEnv) return
try {
const saved = await window.electronAPI.loadAIConfig()
if (saved && (saved.agentName || saved.guildName || saved.contactTool || saved.contact)) {
aiConfig.value = saved
aiConfigured.value = true
} else {
aiConfigured.value = false
}
} catch {
aiConfigured.value = false
}
}
const handleSaveAIConfig = async () => {
if (isElectronEnv) {
await window.electronAPI.saveAIConfig(JSON.parse(JSON.stringify(aiConfig.value)))
}
aiConfigured.value = true
showAIDialog.value = false
}
// Rotation Timer Logic
const handleStatusChange = (status) => {
if (rotationTimer) {
clearInterval(rotationTimer)
rotationTimer = null
}
if (status?.modeStartTime) {
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - status.modeStartTime) / 1000)
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0')
const seconds = (elapsed % 60).toString().padStart(2, '0')
elapsedTime.value = `${minutes}:${seconds}`
}
updateTimer()
rotationTimer = setInterval(updateTimer, 1000)
} else {
elapsedTime.value = '00:00'
}
}
// Actions
const updateConfig = (key, value) => {
config.value[key] = value
}
const updateGroupName = (val, index) => {
config.value.accountGroups[index].name = val || `${index + 1}`
}
const addAccount = (groupIndex) => {
if (config.value.accountGroups[groupIndex].accounts.length < 3) {
config.value.accountGroups[groupIndex].accounts.push({ email: '', pwd: '' })
}
}
const removeAccount = (groupIndex, accountIndex) => {
if (config.value.accountGroups[groupIndex].accounts.length > 1) {
config.value.accountGroups[groupIndex].accounts.splice(accountIndex, 1)
}
}
const updateAccount = (groupIndex, accountIndex, field, value) => {
config.value.accountGroups[groupIndex].accounts[accountIndex][field] = value
}
// Input Handlers
const handleNumberInput = (key, val) => {
if (val === '') {
config.value[key] = 0
} else {
const num = parseInt(val)
if (!isNaN(num)) config.value[key] = num
}
}
const handleNumberBlur = (key, val, min) => {
if (!val || parseInt(val) < min) {
config.value[key] = min
}
}
const handleSleepTimeInput = (val) => {
if (val === '') {
config.value.sleepTime = -1
} else {
config.value.sleepTime = parseInt(val) || 0
}
}
// Start/Stop
const handleStart = async (specificGroupIndex) => {
const activeGroupIndex = specificGroupIndex ?? 0
const activeGroup = config.value.accountGroups[activeGroupIndex]
if (!activeGroup) return alert('请选择要运行的账号组')
const hasValidAccount = activeGroup.accounts.some(a => a.email && a.pwd)
if (!hasValidAccount) return alert('请至少填写一组有效的账号(邮箱和密码)')
const errors = []
if (config.value.rotateEnabled && (!config.value.switchMinutes || config.value.switchMinutes < 1)) {
errors.push('轮换间隔必须大于 0 分钟')
}
if (config.value.sleepTime < 0) {
errors.push('请填写睡眠时间')
}
if (!config.value.sendInviteFirst && (!config.value.inviteThreshold || config.value.inviteThreshold < 1)) {
errors.push('链接发送时机必须大于 0')
}
if (errors.length > 0) {
return alert('配置检查失败:\n\n' + errors.map((e, i) => `${i + 1}. ${e}`).join('\n'))
}
if (!isElectronEnv) return alert('非 Electron 环境无法运行')
// Deep clone to remove Vue reactivity (Proxy)
const prologueList = JSON.parse(JSON.stringify(config.value.prologueList || {}))
const activeGroupName = activeGroup.name
await window.electronAPI.updateAutomationConfig({
aiReplyEnabled: config.value.aiReply,
isGreetFirst: config.value.sendInviteFirst,
sleepTime: config.value.sleepTime,
inviteThreshold: config.value.inviteThreshold,
prologueList,
needTranslate: config.value.needTranslate, // 添加翻译开关配置
maxAnchorCount: config.value.maxAnchorCount,
rotationEnabled: config.value.rotateEnabled,
rotationIntervalMinutes: config.value.switchMinutes,
currentActiveGroup: activeGroupName,
})
const groupsToStart = config.value.rotateEnabled
? visibleGroups.value.map(g => ({
group: g,
index: config.value.accountGroups.indexOf(g),
isActive: g.name === activeGroupName
}))
: [{ group: activeGroup, index: activeGroupIndex, isActive: true }]
const startTasks = []
for (const { group, index } of groupsToStart) {
const startViewId = index * 3 + 1
let currentViewId = startViewId
for (const acc of group.accounts) {
if (acc.email && acc.pwd && currentViewId < startViewId + 3 && currentViewId <= 9) {
// Deep clone account object to remove Vue reactivity
const cleanAccount = JSON.parse(JSON.stringify(acc))
startTasks.push({
viewId: currentViewId,
account: {
...cleanAccount,
group: group.name,
lang: config.value.lang || 'en' // 传递语言配置
},
delay: Math.random() * 2500 + 500
})
currentViewId++
}
}
}
await Promise.allSettled(
startTasks.map(async ({ viewId, account, delay }) => {
await new Promise(r => setTimeout(r, delay))
return window.electronAPI.startTikTokAutomation(viewId, account)
})
)
setIsRunning(true)
currentGroupIndex.value = activeGroupIndex
const status = await window.electronAPI.getRotationStatus()
rotationStatus.value = status
handleStatusChange(status)
emit('goToBrowser')
}
const handleStop = async () => {
if (!isElectronEnv) return
await Promise.allSettled(
Array.from({ length: 9 }, (_, i) => i + 1).map(viewId =>
window.electronAPI.stopTikTokAutomation(viewId).catch(() => { })
)
)
await window.electronAPI.updateAutomationConfig({ rotationEnabled: false })
if (window.electronAPI.clearAllCache) await window.electronAPI.clearAllCache()
setIsRunning(false)
rotationStatus.value = null
elapsedTime.value = '00:00'
}
const handleGreetingConfirm = (data) => {
const newPrologueList = {
yolo: data.sentences,
...data.translations
}
config.value.prologueList = newPrologueList
config.value.needTranslate = data.needTranslate
}
// Helpers for Template
const getGroupName = (group, index) => {
if (group.name.startsWith('第') && group.name.endsWith('组')) return ''
return group.name
}
const getGroupMode = (group) => {
return rotationStatus.value?.instanceModes.find(i => i.group === group.name)?.mode
}
const isGroupActive = (group) => {
return rotationStatus.value?.enabled && rotationStatus.value?.currentActiveGroup === group.name
}
const hasRunningAccounts = (group) => {
return rotationStatus.value?.instanceModes.some(i => i.group === group.name)
}
const getAccountViewId = (groupIndex, accountIndex) => {
return groupIndex * 3 + accountIndex + 1
}
const getAccountStatus = (groupIndex, accountIndex) => {
const viewId = getAccountViewId(groupIndex, accountIndex)
return rotationStatus.value?.instanceModes.find(i => i.viewId === viewId)
}
function setIsRunning(val) {
isRunning.value = val
}
const togglePasswordVisibility = (gIndex, aIndex) => {
const key = `${gIndex}-${aIndex}`
showPasswordMap.value[key] = !showPasswordMap.value[key]
}
</script>

View File

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

262
src/pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,262 @@
```
<template>
<div
class="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
<div class="absolute top-8 right-8 flex gap-4 z-20">
<!-- Network Settings (Placeholder/Mock) -->
<!-- <div class="bg-white/95 border border-slate-200 rounded-2xl px-3 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
<span class="text-sm font-semibold text-slate-700">{{ $t('login.network') }}</span>
</div> -->
<!-- Language Selector -->
<el-dropdown>
<div
class="bg-white/95 border border-slate-200 rounded-2xl px-4 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
<span class="text-sm font-semibold text-slate-700">{{ locale === 'zh' ? '中文' : 'English' }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="switchLanguage('zh')">中文</el-dropdown-item>
<el-dropdown-item @click="switchLanguage('en')">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- Background Shapes -->
<div class="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 class="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%); animation-duration: 4s" />
<div class="container mx-auto px-4 z-10 relative flex justify-center items-center h-full">
<div
class="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 class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
<!-- Header / Logo -->
<div class="flex justify-center">
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
</div>
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- 租户号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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" v-model="credentials.tenantName" placeholder="请输入租户号"
class="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 class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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" v-model="credentials.username" placeholder="请输入账号"
class="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 class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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" v-model="credentials.password" placeholder="请输入密码"
class="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 v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="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">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="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>
</template>
<template v-else>
登 录
</template>
</button>
</div>
</form>
<div class="mt-8 text-center">
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
</div>
</div>
<!-- Right Side: Illustration -->
<div
class="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
class="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 class="relative z-10 w-full max-w-sm">
<img :src="illustration" alt="Illustration" class="w-full h-auto drop-shadow-xl animate-float"
style="animation: float 6s ease-in-out infinite" />
<div class="text-center mt-8">
<h3 class="text-xl font-bold text-gray-800 mb-2">连接全球创意</h3>
<p class="text-gray-500 text-sm">高效管理您的TikTok矩阵释放无限潜能</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { isElectron, getAppVersion } from '../utils/electronBridge'
import { setUser, setToken, setUserPass, getUserPass } from '@/utils/storage'
import logo from '../assets/logo.png'
import illustration from '../assets/illustration.png'
const emit = defineEmits(['loginSuccess'])
const { locale } = useI18n()
// Language Switcher
const switchLanguage = (lang) => {
locale.value = lang
localStorage.setItem('lang', lang)
}
// const STORAGE_KEY = 'login_credentials' // Deprecated in favor of getUserPass
// const USER_KEY = 'user_data' // Deprecated in favor of setUser
const credentials = ref({
tenantName: '',
username: '',
password: '',
})
const isLoading = ref(false)
const error = ref('')
const version = ref('')
onMounted(() => {
// 获取应用版本
getAppVersion().then(v => {
version.value = v
})
// 加载保存的凭据
try {
const saved = getUserPass()
if (saved) {
credentials.value = {
tenantName: saved.tenantName || '',
username: saved.username || saved.userId || '',
password: saved.password || '',
}
}
} catch { } // eslint-disable-line no-empty
})
const handleSubmit = async () => {
if (!credentials.value.tenantName || !credentials.value.username || !credentials.value.password) {
error.value = '请填写所有字段'
return
}
isLoading.value = true
error.value = ''
try {
// 保存凭据 (Using compatible storage helper)
setUserPass(credentials.value)
console.log('[LoginPage] 开始登录...', credentials.value)
if (!isElectron()) {
error.value = '非 Electron 环境,无法进行真实登录'
isLoading.value = false
return
}
const result = await window.electronAPI.login({ ...credentials.value })
console.log('[LoginPage] 登录结果:', result)
if (result.success && result.user) {
// Save token and user info to localStorage using legacy keys to support ported views
setToken(result.user.tokenValue);
setUser(result.user);
emit('loginSuccess')
} else {
error.value = result.error || '登录失败'
}
} catch (err) {
error.value = err.message || '登录失败'
} finally {
isLoading.value = false
}
}
</script>
<style>
@keyframes float {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-15px);
}
100% {
transform: translateY(0px);
}
}
</style>

View File

@@ -1,365 +0,0 @@
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]}`
}

311
src/pages/UpdateChecker.vue Normal file
View File

@@ -0,0 +1,311 @@
<template>
<!-- Electron 环境不渲染 -->
<div v-if="isElectronEnv"
class="min-h-screen bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center p-6">
<!-- 背景装饰 -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-50" />
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-100 rounded-full blur-3xl opacity-50" />
</div>
<div class="relative z-10 w-full max-w-md">
<!-- Logo 和标题 -->
<div class="text-center mb-8">
<div
class="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 class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">应用更新检查</h1>
<p class="text-gray-500 text-sm">当前版本: v{{ currentVersion || '...' }}</p>
</div>
<!-- 更新卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-xl p-6">
<!-- 检查中 -->
<div v-if="status === 'checking' && !showTimeoutError" class="text-center py-8">
<div
class="w-12 h-12 mx-auto mb-4 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p class="text-gray-900 font-medium">正在检查更新...</p>
<p class="text-gray-500 text-sm mt-2">
{{ retryCount > 0 ? `${retryCount}/${MAX_RETRIES} 次重试...` : '请稍候' }}
</p>
</div>
<!-- 超时错误 -->
<div v-if="showTimeoutError" class="space-y-6 py-4">
<div class="text-center">
<div
class="w-16 h-16 mx-auto mb-4 bg-orange-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900">检查更新超时</h2>
<p class="text-orange-600 text-sm mt-2">无法连接到更新服务器</p>
</div>
<button @click="handleRetry"
class="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 class="text-center text-gray-400 text-xs">
请检查网络连接后重试
</p>
</div>
<!-- 发现新版本 -->
<div v-if="status === 'available' && updateInfo" class="space-y-6">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900">发现新版本</h2>
<p class="text-green-600 font-mono mt-2">v{{ updateInfo.version }}</p>
</div>
<div v-if="updateInfo.releaseNotes" class="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
</div>
<button @click="downloadUpdate"
class="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 class="text-center text-gray-400 text-xs">
必须更新后才能使用程序
</p>
</div>
<!-- 下载中 -->
<div v-if="status === 'downloading' && progress" class="space-y-6 py-4">
<div class="text-center">
<p class="text-gray-900 font-medium mb-1">正在下载更新</p>
<p class="text-4xl font-bold text-blue-600">{{ progress.percent.toFixed(0) }}%</p>
</div>
<div class="space-y-2">
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 rounded-full"
:style="{ width: `${progress.percent}%` }" />
</div>
<div class="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 class="text-center text-gray-400 text-sm">
请勿关闭程序...
</p>
</div>
<!-- 下载完成 - 自动重启安装 -->
<div v-if="status === 'downloaded'" class="space-y-6 py-4">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900">下载完成</h2>
<p class="text-green-600 text-sm mt-2">
{{ countdown > 0 ? `${countdown} 秒后自动重启安装...` : '正在重启安装...' }}
</p>
</div>
<button @click="installUpdate"
class="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>
<!-- 错误 -->
<div v-if="status === 'error'" class="space-y-6 py-4">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900">检查更新失败</h2>
<p class="text-red-500 text-sm mt-2">{{ error }}</p>
</div>
<div class="flex gap-3">
<button @click="startCheck"
class="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 @click="emit('ready')"
class="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 class="text-center text-gray-400 text-xs">
更新检查失败不影响正常使用
</p>
</div>
</div>
<!-- 底部版权 -->
<p class="text-center text-gray-400 text-xs mt-6">
© 2025 Yolo
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useUpdate } from '../hooks/useUpdate'
import { isElectron } from '../utils/electronBridge'
const emit = defineEmits(['ready'])
const CHECK_TIMEOUT = 15000 // 15秒超时
const MAX_RETRIES = 3 // 最大重试次数
const AUTO_INSTALL_DELAY = 3 // 自动安装倒计时秒数
const {
status,
updateInfo,
progress,
error,
currentVersion,
checkForUpdates,
downloadUpdate,
installUpdate
} = useUpdate()
const isElectronEnv = isElectron()
const checkComplete = ref(false)
const retryCount = ref(0)
const isTimeout = ref(false)
const showTimeoutError = ref(false)
const countdown = ref(AUTO_INSTALL_DELAY)
let timeoutTimer = null
let hasStarted = false
let countdownTimer = null
// 非 Electron 环境直接 ready
onMounted(() => {
if (!isElectronEnv) {
emit('ready')
return
}
// 启动检查
if (!hasStarted) {
hasStarted = true
startCheck()
}
})
// 监听状态
watch(status, (newStatus) => {
if (newStatus !== 'checking' && timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
isTimeout.value = false
}
if (newStatus === 'idle' && checkComplete.value) {
emit('ready')
}
if (newStatus !== 'checking' && newStatus !== 'idle') {
checkComplete.value = true
}
if (newStatus === 'idle') {
setTimeout(() => {
checkComplete.value = true
}, 500)
}
// Auto install timer
if (newStatus === 'downloaded') {
startCountdown()
}
})
watch(isTimeout, (val) => {
if (val) {
if (retryCount.value >= MAX_RETRIES) {
console.log('[UpdateChecker] 更新检查超时,显示错误')
showTimeoutError.value = true
} else if (retryCount.value > 0) {
setTimeout(() => {
startCheck()
}, 2000)
}
}
})
watch(checkComplete, (val) => {
if (val && status.value === 'idle') {
emit('ready')
}
})
function startCheck() {
if (!isElectronEnv) return
isTimeout.value = false
checkForUpdates()
timeoutTimer = setTimeout(() => {
if (status.value === 'checking') {
isTimeout.value = true
if (retryCount.value < MAX_RETRIES) {
retryCount.value++
}
}
}, CHECK_TIMEOUT)
}
function handleRetry() {
showTimeoutError.value = false
retryCount.value = 0
hasStarted = false
startCheck()
}
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdown.value = AUTO_INSTALL_DELAY
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer)
installUpdate()
}
}, 1000)
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
if (countdownTimer) clearInterval(countdownTimer)
})
</script>

22
src/router/index.js Normal file
View File

@@ -0,0 +1,22 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'home',
// Redirect will be handled by App.vue logic usually, but here we can define structure
component: () => import('@/views/YoloBrowser.vue')
},
{
path: '/workbenches',
name: 'Workbenches',
component: () => import('@/views/tk/Workbenches.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

View File

@@ -60,9 +60,21 @@ export interface UpdateProgress {
total: number total: number
} }
export interface ViewStats {
viewId: number
group: string
greeting: number
invite: number
reply: number
unread: number
}
export interface GreetingStats { export interface GreetingStats {
greetingCount: number greetingCount: number
inviteCount: number inviteCount: number
replyCount: number
unreadCount: number
details: ViewStats[]
} }
export interface ElectronAPI { export interface ElectronAPI {

164
src/utils/axios.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* axios请求封装
* https://rudon.blog.csdn.net/
*/
import axios from 'axios'
import { getToken } from '@/utils/storage'
import router from '@/router'
import { ElMessage } from 'element-plus';
import { usePythonBridge, } from '@/utils/pythonBridge'
const { stopScript } = usePythonBridge();
// 请求地址前缀
let baseURL = ''
if (process.env.NODE_ENV === 'development') {
// 生产环境
// baseURL = "https://api.tkpage.yolozs.com"
baseURL = "http://192.168.2.22:8101"
// baseURL = "https://crawlclient.api.yolozs.com"
} else {
// 测试环境
// baseURL = "http://120.26.251.180:8085/"
// 开发环境
baseURL = "https://crawlclient.api.yolozs.com"
// baseURL = "http://api.tkpage.vvtiktok.cn"
}
// 请求拦截器
axios.interceptors.request.use((config) => {
// console.log("config", config)
const url = sliceUrl(config.url)
console.log("url", url)
if (!(config.url == 'doLogin' || config.url == 'get-id-by-name')) {
config.headers['vvtoken'] = getToken();
}
// 请求超时时间 - 毫秒
config.timeout = 60000
config.baseURL = baseURL
// 自定义Content-type
config.headers['Content-type'] = 'application/json'
return config;
}, (error) => {
return Promise.reject(error)
})
// 响应拦截器
axios.interceptors.response.use((response) => {
console.log("response", response.data)
if (response.data.code == 0) {
// console.log("response", response.data.data)
return response.data.data
} else if (response.data.code == 40400) {
stopScript();
router.push('/')
ElMessage.error(response.data.code + '' + response.data.message);
} else {
ElMessage.error(response.data.code + '' + response.data.message);
}
}, (error) => {
// 可添加请求失败后的处理逻辑
return Promise.reject(error)
})
// axios的get请求
export function getAxios({ url, params }) {
// 使用axios发送GET请求
return new Promise((resolve, reject) => {
axios.get(url, {
params
// 请求成功将返回的数据传递给resolve函数
}).then(res => {
resolve(res)
// 请求失败将错误信息传递给reject函数
}).catch(err => {
reject(err)
})
})
}
// axios的post请求
export function postAxios({ url, data }) {
return new Promise((resolve, reject) => {
axios.post(
url,
data,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
).then(res => {
resolve(res)
}).catch(err => {
reject(err)
})
})
}
export const downFile = async (urlstr, data) => {
// 发送请求,获取文件流
const response = await axios.post(urlstr, data, { responseType: 'blob' });
// 获取文件名(如果后端设置了 Content-Disposition
const contentDisposition = response.headers['content-disposition'];
let fileName = 'default-file-name'; // 默认文件名
console.log(contentDisposition)
console.log(response)
if (contentDisposition) {
// 从响应头中提取文件名
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch && fileNameMatch.length > 1) {
fileName = fileNameMatch[1];
}
}
// 创建一个临时的下载链接
const blob = new Blob([response.data], { type: response.headers['content-type'] });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName; // 设置下载的文件名
a.click();
// 释放 URL 对象
window.URL.revokeObjectURL(url);
}
//节流函数
function throttle(func, limit) {
let inThrottle;
return function () {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
function sliceUrl(url) {
const lastSlash = url.lastIndexOf('/');
const questionMark = url.indexOf('?');
if (questionMark == -1) {
const result = url.slice(lastSlash + 1, url.length);
return result;
} else {
const result = url.slice(lastSlash + 1, questionMark);
return result;
}
}
export default axios

261
src/utils/countryUtil.js Normal file
View File

@@ -0,0 +1,261 @@
// country-utils.js
export const CountryCode = {
AD: "安道尔",
AE: "阿拉伯联合酋长国",
AF: "阿富汗",
AG: "安提瓜和巴布达",
AI: "安圭拉",
AL: "阿尔巴尼亚",
AM: "亚美尼亚",
AO: "安哥拉",
AQ: "南极洲",
AR: "阿根廷",
AS: "美属萨摩亚",
AT: "奥地利",
AU: "澳大利亚",
AU1: "澳大利亚",
AW: "阿鲁巴",
AX: "奥兰群岛",
AZ: "阿塞拜疆",
BA: "波斯尼亚和黑塞哥维那",
BB: "巴巴多斯",
BD: "孟加拉国",
BE: "比利时",
BF: "布基纳法索",
BG: "保加利亚",
BH: "巴林",
BI: "布隆迪",
BJ: "贝宁",
BL: "圣巴泰勒米",
BM: "百慕大群岛",
BN: "文莱达鲁萨兰国",
BO: "玻利维亚",
BQ: "博奈尔、圣尤斯特歇斯和萨巴",
BR: "巴西",
BS: "巴哈马",
BT: "不丹",
BV: "布韦岛",
BW: "博茨瓦纳",
BY: "白俄罗斯",
BZ: "伯利兹",
CA: "加拿大",
CA1: "加拿大",
CC: "科科斯(基林)群岛",
CD: "刚果民主共和国",
CF: "中非共和国",
CG: "刚果共和国",
CH: "瑞士",
CI: "科特迪瓦",
CK: "库克群岛",
CL: "智利",
CM: "喀麦隆",
CN: "中国",
CO: "哥伦比亚",
CR: "哥斯达黎加",
CU: "古巴",
CV: "佛得角",
CW: "库拉索",
CX: "圣诞岛",
CY: "塞浦路斯",
CZ: "捷克共和国",
DE: "德国",
DG: "迪戈加西亚岛",
DJ: "吉布提",
DK: "丹麦",
DM: "多米尼克",
DO: "多米尼加共和国",
DZ: "阿尔及利亚",
EC: "厄瓜多尔",
EE: "爱沙尼亚",
EG: "埃及",
EH: "西撒哈拉",
ER: "厄立特里亚",
ES: "西班牙",
ET: "埃塞俄比亚",
FI: "芬兰",
FJ: "斐济",
FK: "福克兰群岛",
FM: "密克罗尼西亚",
FO: "法罗群岛",
FR: "法国",
GA: "加蓬",
GB: "英国",
GD: "格林纳达",
GE: "格鲁吉亚",
GF: "法属圭亚那",
GG: "根西岛",
GH: "加纳",
GI: "直布罗陀",
GL: "格陵兰",
GM: "冈比亚",
GN: "几内亚",
GP: "瓜德罗普",
GQ: "赤道几内亚",
GR: "希腊",
GS: "南乔治亚和南桑德威奇群岛",
GT: "危地马拉",
GU: "关岛",
GW: "几内亚比绍",
GY: "圭亚那",
HK: "中国香港特别行政区",
HM: "赫德岛和麦克唐纳群岛",
HN: "洪都拉斯",
HR: "克罗地亚",
HT: "海地",
HU: "匈牙利",
ID: "印度尼西亚",
IE: "爱尔兰",
IL: "以色列",
IM: "马恩岛",
IN: "印度",
IO: "英属印度洋领地",
IQ: "伊拉克",
IR: "伊朗",
IS: "冰岛",
IT: "意大利",
JE: "泽西岛",
JM: "牙买加",
JO: "约旦",
JP: "日本",
JP1: "日本",
KE: "肯尼亚",
KG: "吉尔吉斯斯坦",
KH: "柬埔寨",
KI: "基里巴斯",
KM: "科摩罗",
KN: "圣基茨和尼维斯",
KP: "朝鲜",
KR: "韩国",
KR1: "韩国",
KR1_UXWAUDIT: "韩国",
KW: "科威特",
KY: "开曼群岛",
KZ: "哈萨克斯坦",
LA: "老挝",
LB: "黎巴嫩",
LC: "圣卢西亚",
LI: "列支敦士登",
LK: "斯里兰卡",
LR: "利比里亚",
LS: "莱索托",
LT: "立陶宛",
LU: "卢森堡",
LV: "拉脱维亚",
LY: "利比亚",
MA: "摩洛哥",
MC: "摩纳哥",
MD: "摩尔多瓦",
ME: "黑山",
MF: "圣马丁",
MG: "马达加斯加",
MH: "马绍尔群岛",
MK: "北马其顿",
ML: "马里",
MM: "缅甸",
MN: "蒙古",
MO: "中国澳门特别行政区",
MP: "北马里亚纳群岛",
MQ: "马提尼克",
MR: "毛里塔尼亚",
MS: "蒙特塞拉特",
MT: "马耳他",
MU: "毛里求斯",
MV: "马尔代夫",
MW: "马拉维",
MX: "墨西哥",
MY: "马来西亚",
MZ: "莫桑比克",
NA: "纳米比亚",
NC: "新喀里多尼亚",
NE: "尼日尔",
NF: "诺福克岛",
NG: "尼日利亚",
NI: "尼加拉瓜",
NL: "荷兰",
NO: "挪威",
NP: "尼泊尔",
NR: "瑙鲁",
NU: "纽埃",
NZ: "新西兰",
OM: "阿曼",
PA: "巴拿马",
PE: "秘鲁",
PF: "法属玻利尼西亚",
PG: "巴布亚新几内亚",
PH: "菲律宾",
PK: "巴基斯坦",
PL: "波兰",
PM: "圣皮埃尔和密克隆群岛",
PN: "皮特凯恩群岛",
PR: "波多黎各",
PS: "巴勒斯坦",
PT: "葡萄牙",
PW: "帕劳",
PY: "巴拉圭",
QA: "卡塔尔",
RE: "留尼汪",
RO: "罗马尼亚",
RS: "塞尔维亚",
RU: "俄罗斯",
RW: "卢旺达",
SA: "沙特阿拉伯",
SB: "索罗门群岛",
SC: "塞舌尔",
SD: "苏丹",
SE: "瑞典",
SG: "新加坡",
SI: "斯洛文尼亚",
SJ: "斯瓦尔巴和扬马延",
SK: "斯洛伐克",
SL: "塞拉利昂",
SM: "圣马利诺",
SN: "塞内加尔",
SO: "索马里",
SR: "苏里南",
SS: "南苏丹",
ST: "圣多美和普林西比",
SV: "萨尔瓦多",
SX: "荷属圣马丁",
SY: "叙利亚",
SZ: "斯威士兰",
TC: "特克斯和凯科斯群岛",
TD: "乍得",
TF: "法属南部领地",
TG: "多哥",
TH: "泰国",
TJ: "塔吉克斯坦",
TK: "托克劳群岛",
TL: "东帝汶",
TM: "土库曼斯坦",
TN: "突尼斯",
TO: "汤加",
TR: "土耳其",
TT: "特立尼达和多巴哥",
TV: "图瓦卢",
TW: "台湾",
TZ: "坦桑尼亚",
UA: "乌克兰",
UG: "乌干达",
UM: "美国本土外小岛屿",
US: "美国",
UY: "乌拉圭",
UZ: "乌兹别克斯坦",
VA: "梵蒂冈",
VC: "圣文森特",
VE: "委内瑞拉",
VG: "英属维尔京群岛",
VI: "美属维尔京群岛",
VN: "越南",
VN1: "越南",
VU: "瓦努阿图",
WS: "萨摩亚",
YE: "也门",
YT: "马约特岛",
ZA: "南非",
ZM: "赞比亚",
ZW: "津巴布韦"
};
export function getCountryName(code) {
return CountryCode[code] || null;
}

View File

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

161
src/utils/pythonBridge.js Normal file
View File

@@ -0,0 +1,161 @@
// pythonBridge.js (Refactored to Electron IPC)
import { ref, onMounted } from 'vue';
import { isElectron, safeElectronCall } from '@/utils/electronBridge';
// Check if we are in Electron environment
const inElectron = isElectron();
export function usePythonBridge() {
// ========== tk爬虫的接口 ==========
// fetchDataConfig (maps to updateStartConfig)
const fetchDataConfig = async (data) => {
if (!inElectron) return null;
try {
return await window.electronAPI.tk.updateStartConfig(data);
} catch (e) {
console.error('fetchDataConfig error:', e);
return null;
}
};
// fetchDataCount
const fetchDataCount = async () => {
if (!inElectron) return JSON.stringify({ totalCount: 0 });
try {
return await window.electronAPI.tk.getDataCount();
} catch (e) {
console.error('fetchDataCount error:', e);
return JSON.stringify({ totalCount: 0 });
}
};
// loginTikTok
const loginTikTok = async () => {
if (!inElectron) return;
await window.electronAPI.tk.loginTikTok();
};
// loginBackStage
const loginBackStage = async (data) => {
if (!inElectron) return;
if (data.index == 0) {
await window.electronAPI.tk.loginBackStage(JSON.stringify(data));
} else if (data.index == 1) {
await window.electronAPI.tk.loginBackStageCopy(JSON.stringify(data));
}
};
// givePyAnchorId -> visitAnchor
const givePyAnchorId = async (id) => {
if (!inElectron) return;
await window.electronAPI.tk.visitAnchor(id);
};
// backStageloginStatus
const backStageloginStatus = async () => {
if (!inElectron) return null;
return await window.electronAPI.tk.checkBackStageLoginStatus();
};
// backStageloginStatusCopy
const backStageloginStatusCopy = async () => {
if (!inElectron) return null;
return await window.electronAPI.tk.checkBackStageLoginStatusCopy();
};
// exportToExcel
const exportToExcel = async (data) => {
if (!inElectron) return;
await window.electronAPI.tk.exportData(JSON.stringify(data));
};
const stopScript = async () => {
if (!inElectron) return;
await window.electronAPI.tk.stopCrawl();
};
// getVersion
const getVersion = async () => {
if (!inElectron) return 'Web Mode';
return await window.electronAPI.tk.getVersion();
};
// getTkLoginStatus
const getTkLoginStatus = async () => {
if (!inElectron) return "false";
return await window.electronAPI.tk.checkTkLoginStatus();
};
// ========== 粉丝助手的接口 ==========
const controlTask = async (data) => {
if (!inElectron) return;
// data is JSON string in original, ensure consistent
await window.electronAPI.tk.controlTask(data);
};
const getBrotherInfo = async () => {
if (!inElectron) return { total: 0, valid: 0 };
const res = await window.electronAPI.tk.getBrotherInfo();
return JSON.parse(res);
};
const Specifystreaming = async (data) => {
if (!inElectron) return;
await window.electronAPI.tk.findBigBrother(data);
};
const storageSetInfos = async (data) => {
// data is { key, data } object
if (!inElectron) return;
await window.electronAPI.tk.storageSet(JSON.stringify(data));
};
const readSetInfos = async (key) => {
if (!inElectron) return "";
const res = await window.electronAPI.tk.storageRead(JSON.stringify(key));
return JSON.parse(res || '""'); // Handle potential empty string response
};
// Maps to visitAnchor
const openAnchorIdRooms = async (id) => {
if (!inElectron) return;
await window.electronAPI.tk.openRoom(id);
};
// Clipboard helper - maybe use navigator.clipboard directly in Vue component?
// Original used python bridge for clipboard.
const setClipboards = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
console.error('Clipboard failed', e);
return false;
}
}
return {
fetchDataConfig,
fetchDataCount,
loginBackStage,
loginTikTok,
givePyAnchorId,
backStageloginStatus,
backStageloginStatusCopy,
exportToExcel,
stopScript,
getVersion,
getTkLoginStatus,
// New Fan Workbench exports
controlTask,
getBrotherInfo,
Specifystreaming,
storageSetInfos,
readSetInfos,
openAnchorIdRooms,
setClipboards
};
}

View File

@@ -4,7 +4,7 @@
*/ */
// 完整的大区数据(来自大区.json // 完整的大区数据(来自大区.json
const REGIONS_DATA: Record<string, { code: string; name: string }[]> = { const REGIONS_DATA = {
"中东及北非": [ "中东及北非": [
{ "code": "AE", "name": "阿拉伯联合酋长国" }, { "code": "AE", "name": "阿拉伯联合酋长国" },
{ "code": "BH", "name": "巴林" }, { "code": "BH", "name": "巴林" },
@@ -183,7 +183,7 @@ const REGIONS_DATA: Record<string, { code: string; name: string }[]> = {
} }
// ISO 国家代码 → 语言代码映射(完整版) // ISO 国家代码 → 语言代码映射(完整版)
const COUNTRY_CODE_TO_LANGUAGE: Record<string, string> = { const COUNTRY_CODE_TO_LANGUAGE = {
// 阿拉伯语区 // 阿拉伯语区
AE: 'ar', BH: 'ar', DJ: 'ar', DZ: 'ar', EG: 'ar', IQ: 'ar', JO: 'ar', 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', KM: 'ar', KW: 'ar', LB: 'ar', LY: 'ar', MA: 'ar', MR: 'ar', OM: 'ar',
@@ -255,7 +255,7 @@ const COUNTRY_CODE_TO_LANGUAGE: Record<string, string> = {
} }
// 语言代码 → 语言名称(用于显示) // 语言代码 → 语言名称(用于显示)
const LANGUAGE_NAMES: Record<string, string> = { const LANGUAGE_NAMES = {
'ar': '阿拉伯语', 'ar': '阿拉伯语',
'es': '西班牙语', 'es': '西班牙语',
'en': '英语', 'en': '英语',
@@ -316,23 +316,23 @@ const LANGUAGE_NAMES: Record<string, string> = {
/** /**
* 获取所有大区名称列表 * 获取所有大区名称列表
*/ */
export function getRegions(): string[] { export function getRegions() {
return Object.keys(REGIONS_DATA) return Object.keys(REGIONS_DATA)
} }
/** /**
* 获取某个大区的所有国家 * 获取某个大区的所有国家
*/ */
export function getCountriesForRegion(region: string): { code: string; name: string }[] { export function getCountriesForRegion(region) {
return REGIONS_DATA[region] || [] return REGIONS_DATA[region] || []
} }
/** /**
* 获取某个大区的所有语言代码去重 * 获取某个大区的所有语言代码去重
*/ */
export function getLanguagesForRegion(region: string): string[] { export function getLanguagesForRegion(region) {
const countries = REGIONS_DATA[region] || [] const countries = REGIONS_DATA[region] || []
const languages = new Set<string>() const languages = new Set()
for (const country of countries) { for (const country of countries) {
const lang = COUNTRY_CODE_TO_LANGUAGE[country.code] const lang = COUNTRY_CODE_TO_LANGUAGE[country.code]
@@ -347,8 +347,8 @@ export function getLanguagesForRegion(region: string): string[] {
/** /**
* 获取多个大区的所有语言代码去重 * 获取多个大区的所有语言代码去重
*/ */
export function getLanguagesForRegions(regions: string[]): string[] { export function getLanguagesForRegions(regions) {
const languages = new Set<string>() const languages = new Set()
for (const region of regions) { for (const region of regions) {
const regionLangs = getLanguagesForRegion(region) const regionLangs = getLanguagesForRegion(region)
@@ -363,14 +363,14 @@ export function getLanguagesForRegions(regions: string[]): string[] {
/** /**
* 获取语言名称 * 获取语言名称
*/ */
export function getLanguageName(langCode: string): string { export function getLanguageName(langCode) {
return LANGUAGE_NAMES[langCode] || langCode return LANGUAGE_NAMES[langCode] || langCode
} }
/** /**
* 获取大区内的语言信息用于展示 * 获取大区内的语言信息用于展示
*/ */
export function getLanguageInfoForRegion(region: string): { code: string; name: string }[] { export function getLanguageInfoForRegion(region) {
const languages = getLanguagesForRegion(region) const languages = getLanguagesForRegion(region)
return languages.map(code => ({ return languages.map(code => ({
code, code,

53
src/utils/storage.js Normal file
View File

@@ -0,0 +1,53 @@
export function setToken(token) {
localStorage.setItem('token', token);
}
export function getToken() {
return localStorage.getItem('token');
}
export function removeToken() {
localStorage.removeItem('token');
}
export function setUser(user) {
localStorage.setItem('user', JSON.stringify(user));
}
export function getUser() {
return JSON.parse(localStorage.getItem('user'));
}
export function setNumData(numData) {
localStorage.setItem('num', JSON.stringify(numData));
}
export function getNumData() {
return JSON.parse(localStorage.getItem('num'));
}
// 导出一个函数,用于设置用户密码
export function setUserPass(userdata) {
localStorage.setItem('userPass', JSON.stringify(userdata));
}
// 导出一个函数,用于获取用户密码
export function getUserPass() {
return JSON.parse(localStorage.getItem('userPass'));
}
// 用于设置tk账户密码
export function setTkUser(userdata) {
localStorage.setItem('tkuser', JSON.stringify(userdata));
}
// 用于获取tk账户密码
export function getTkUser() {
return JSON.parse(localStorage.getItem('tkuser'));
}
// 用于列表筛选条件
export function setSerch(data) {
localStorage.setItem('Serch', JSON.stringify(data));
}
// 用于获取列表筛选条件
export function getSerch() {
return JSON.parse(localStorage.getItem('Serch'));
}

180
src/views/YoloBrowser.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<div class="flex h-full w-full bg-gradient-to-br from-gray-50 to-gray-100 animate-fadeIn">
<!-- 侧边栏 -->
<Sidebar :tabs="tabs" :current-tab="currentTab" @tab-switch="handleTabSwitch" @go-back="handleGoToConfig"
@stop-all="handleStopAll" :is-loading="isLoading" :account-groups="accountGroups"
:rotation-status="rotationStatus" :greeting-stats="greetingStats" :automation-logs="automationLogs" />
<!-- 内容区域 -->
<main class="flex-1 flex flex-col relative">
<!-- 顶部视图切换栏 -->
<div class="h-12 bg-white border-b border-gray-200 flex items-center px-4 gap-2 shadow-sm">
<span class="text-gray-500 text-sm mr-2">视图:</span>
<button v-for="viewId in currentTabConfig.viewIds" :key="viewId" @click="handleViewSwitch(viewId)"
:class="[
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
(selectedViewId || currentTabConfig.viewIds[0]) === viewId
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'
]">
视图 {{ viewId }}
<span v-if="viewAccountMap[viewId]" class="ml-1.5 text-xs opacity-70">
({{ viewAccountMap[viewId].email.split('@')[0] }})
</span>
</button>
<div class="flex-1" />
<!-- 状态指示 -->
<span v-if="automationStatus[selectedViewId || currentTabConfig.viewIds[0]]"
class="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 class="flex-1 relative">
<ViewPlaceholder class="absolute inset-0" />
</div>
<div v-if="isLoading" class="absolute inset-0 bg-slate-900/80 flex items-center justify-center z-50">
<div class="flex flex-col items-center gap-3">
<div class="w-10 h-10 border-3 border-t-primary-400 border-slate-600 rounded-full animate-spin" />
<span class="text-slate-400 text-sm">切换中...</span>
</div>
</div>
</main>
<!-- 更新通知 -->
<div style="display: none;">
<!-- Hack to keep UpdateNotification if needed, or move it to layout -->
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { isElectron } from '@/utils/electronBridge'
import Sidebar from '@/components/Sidebar.vue'
import ViewPlaceholder from '@/components/ViewPlaceholder.vue'
// Props passed from parent (App.vue or Layout) can replace some local state if we want to lift state up.
// For now, I'll keep local state to minimize refactor risk, assuming this component is mounted once and kept alive.
// BUT, App.vue passed some props like `accountGroups` etc.
// Wait, in App.vue these were local state. I needs to move the logic here or keep it in App.vue and pass via props.
// To keep valid functionality, I will copy the logic 1:1 here.
const props = defineProps(['accountGroups', 'rotationStatus', 'greetingStats', 'automationLogs'])
const emit = defineEmits(['go-back', 'stop-all', 'request-config-load'])
// Constants
const USER_KEY = 'user_data'
const CONFIG_KEY = 'autoDm_runConfig'
// State
const currentTab = ref('A')
const isLoading = ref(false)
const automationStatus = ref({})
const selectedViewId = ref(null)
const viewAccountMap = ref({})
// We use props for these now? Or still load config here?
// The original App.vue loaded config.
// Let's use the props if provided, otherwise load locally?
// Actually App.vue code showed `loadConfig` was called on mounted.
// Since `accountGroups` is passed as prop in my new design (implied by previous thought), I should use it.
// BUT, to be safe and independent:
const internalAccountGroups = ref([])
// use props if available, else local
const effectiveAccountGroups = computed(() => props.accountGroups || internalAccountGroups.value)
// Computed
const tabs = computed(() => [
{ id: 'A', label: effectiveAccountGroups.value[0]?.name || 'Tab A', viewIds: [1, 2, 3] },
{ id: 'B', label: effectiveAccountGroups.value[1]?.name || 'Tab B', viewIds: [4, 5, 6] },
{ id: 'C', label: effectiveAccountGroups.value[2]?.name || 'Tab C', viewIds: [7, 8, 9] }
])
const currentTabConfig = computed(() => tabs.value.find(t => t.id === currentTab.value) || tabs.value[0])
// Lifecycle
onMounted(() => {
// If props are not provided (standalone usage), load config
if (!props.accountGroups) {
loadConfig()
} else {
// Build viewAccountMap from props
buildViewMap(props.accountGroups)
}
// Listeners specific to browser view
// ...
})
// Original loadConfig logic
const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(CONFIG_KEY)
if (savedConfig) {
const config = JSON.parse(savedConfig)
internalAccountGroups.value = config.accountGroups || []
buildViewMap(config.accountGroups)
}
} catch { }
}
const buildViewMap = (groups) => {
const map = {}
if (!groups) return
groups.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 }
}
})
})
viewAccountMap.value = map
}
watch(() => props.accountGroups, (newVal) => {
if (newVal) buildViewMap(newVal)
})
// Actions
const handleTabSwitch = async (tab) => {
if (tab === currentTab.value) return
if (isElectron()) {
try {
const result = await window.electronAPI.switchTab(tab)
if (result.success) {
currentTab.value = tab
selectedViewId.value = null
}
} catch (error) {
console.error('切换标签失败:', error)
}
} else {
currentTab.value = tab
selectedViewId.value = null
}
}
const handleViewSwitch = async (viewId) => {
selectedViewId.value = viewId
if (isElectron()) {
await window.electronAPI.switchToView(viewId)
}
}
const handleGoToConfig = async () => {
emit('go-back')
}
const handleStopAll = async () => {
emit('stop-all')
}
</script>

View File

@@ -0,0 +1,560 @@
<template>
<div class="h-full w-full overflow-y-auto bg-gray-50 p-6">
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-sm border border-slate-100 dark:border-slate-800 p-6 h-full flex flex-col">
<!-- 顶部筛选区域 -->
<div class="mb-6 space-y-4">
<!-- 第一行主要筛选条件 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center flex-wrap gap-4">
<el-checkbox v-model="queryFormData.isFilter" :label="$t('hostsList.filterPrivateUsers') || '过滤私密账号'" size="large"
class="!mr-0" />
<!-- Coins Input -->
<div class="flex flex-col gap-1">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<span>💰</span> {{ $t('hostsList.coins') || '金币' }}
</label>
<div class="flex items-center gap-2">
<el-input v-model="queryFormData.coinMin" :placeholder="$t('hostsList.minCoins') || '最小'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
<span class="text-slate-300">/</span>
<el-input v-model="queryFormData.coinMax" :placeholder="$t('hostsList.maxCoins') || '最大'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
</div>
</div>
<!-- Level Input -->
<div class="flex flex-col gap-1">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<span>📊</span> {{ $t('hostsList.level') || '等级' }}
</label>
<div class="flex items-center gap-2">
<el-input v-model="queryFormData.levelMin" :placeholder="$t('hostsList.minLevel') || '最小'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
<span class="text-slate-300">/</span>
<el-input v-model="queryFormData.levelMax" :placeholder="$t('hostsList.maxLevel') || '最大'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
</div>
</div>
</div>
<!-- Info Pill -->
<div class="bg-white/60 border border-slate-200 rounded-full px-5 py-2 flex items-center gap-4 text-sm shadow-sm">
<span class="font-medium text-slate-500">{{ $t('hostsList.runningTime') || '运行时间' }}:</span>
<span class="font-mono font-bold text-slate-700">
{{ String(hourstuo).padStart(2, '0') }}:{{ String(minutestuo).padStart(2, '0') }}:{{ String(secondstuo).padStart(2, '0') }}
</span>
<div class="w-px h-4 bg-slate-200"></div>
<span class="font-medium text-slate-500">{{ $t('hostsList.total') || '总数' }}:</span>
<span class="font-bold text-slate-700">{{ getBrotherInfodata.total }}</span>
<div class="w-px h-4 bg-slate-200"></div>
<span class="font-medium text-slate-500">{{ $t('hostsList.valid') || '有效' }}:</span>
<span class="font-bold text-primary">{{ getBrotherInfodata.valid }}</span>
</div>
<!-- Right Action Buttons -->
<div class="flex items-center gap-3">
<el-button @click="streamdialogVisible = true" :disabled="isRunnings" type="primary"
class="!rounded-xl !font-semibold shadow-lg shadow-blue-500/20">
<span class="mr-1">📍</span>
{{ streamdialogVisibletext ? ($t('hostsList.specifiedRooms') || '已指定') : ($t('hostsList.specifyRooms') || '指定直播间') }}
</el-button>
<el-button v-show="!isRunnings" type="success" @click="getBigBrother"
class="!rounded-xl !font-semibold shadow-lg shadow-emerald-500/20">
{{ $t('hostsList.start') || '开始' }}
</el-button>
<el-button v-show="isRunnings" type="danger" @click="BigBrotherstop"
class="!rounded-xl !font-semibold shadow-lg shadow-red-500/20">
{{ $t('hostsList.end') || '结束' }}
</el-button>
</div>
</div>
<div class="h-px bg-slate-100 w-full"></div>
<!-- 第二行搜索和操作 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<el-select v-model="searchForm.region" filterable :placeholder="$t('hostsList.selectCountry') || '选择国家'" style="width: 160px">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="searchForm.displayId" :placeholder="$t('hostsList.bigBrotherId') || '大哥ID'" style="width: 180px" clearable>
<template #prefix>
<span class="font-semibold text-slate-400">@</span>
</template>
</el-input>
<el-button @click="serch">
<span class="mr-1">🔍</span> {{ $t('hostsList.search') || '搜索' }}
</el-button>
<el-button @click="reset">
<span class="mr-1">🔄</span> {{ $t('hostsList.reset') || '重置' }}
</el-button>
<el-button :disabled="tableData.length == 0" @click="exportList">
<span class="mr-1">📥</span> {{ $t('hostsList.exportExcel') || '导出' }}
</el-button>
<el-button @click="filterdialogVisible = true">
<span class="mr-1"></span> {{ $t('hostsList.moreFilters') || '更多筛选' }}
</el-button>
<el-button @click="openTikTok">
<span class="mr-1">🎵</span> {{ $t('hostsList.openTikTok') || '打开TK' }}
</el-button>
</div>
<!-- Status Info -->
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-100 text-sm">
<span class="text-slate-500">{{ $t('hostsList.currentNetwork') || '当前网络' }}:</span>
<span class="ml-2 font-bold text-primary">{{ countryData }}</span>
</div>
</div>
</div>
<!-- 表格区域 -->
<div class="flex-1 overflow-hidden border border-slate-100 rounded-xl">
<el-table ref="multipleTableRef" :data="tableData" stripe v-loading="loading" height="100%"
@cell-dblclick="handleCellDbClick" @selection-change="handleSelectionChange">
<el-table-column fixed prop="displayId" :label="$t('hostsList.id') || 'ID'" min-width="120">
<template #default="scope">
<div class="text-primary font-semibold cursor-pointer hover:underline" @click="openHTML(scope.row.displayId)">
{{ scope.row.displayId }}
</div>
</template>
</el-table-column>
<el-table-column prop="hostDisplayId" :label="$t('hostsList.hostId') || '主播ID'" min-width="120">
<template #default="scope">
<div class="text-primary font-semibold cursor-pointer hover:underline" @click.ctrl.exact="handleLongPress(scope.row.hostDisplayId)">
{{ scope.row.hostDisplayId }}
</div>
</template>
</el-table-column>
<el-table-column v-for="label in labelList" :key="label.paramCode" :prop="label.paramCode"
:label="label.paramCodeMeaning" min-width="120">
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="mt-4 flex justify-between items-center">
<div></div>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" background
layout="sizes, prev, pager, next" :total="total" :page-sizes="[10, 20, 50, 100, 500, 1000]"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
<!-- 更多筛选弹窗 -->
<el-dialog v-model="filterdialogVisible" :title="$t('hostsList.moreFilters') || '更多筛选'" width="700px">
<div class="space-y-4">
<div class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ $t('hostsList.time') || '时间' }}</div>
<div class="col-span-9">
<el-date-picker v-model="createTimes" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="$t('hostsList.selectTime') || '选择时间'" class="w-full" />
</div>
</div>
<div v-for="(field, index) in fields" :key="index" class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ field.label }}</div>
<div class="col-span-9 flex gap-2 items-center">
<el-input type="number" v-model.number="searchForm[field.minModel]" :placeholder="$t('hostsList.minValue') || '最小值'" />
<span>-</span>
<el-input type="number" v-model.number="searchForm[field.maxModel]" :placeholder="$t('hostsList.maxValue') || '最大值'" />
</div>
</div>
<div class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ $t('hostsList.sort') || '排序' }}</div>
<div class="col-span-9 flex gap-4">
<el-select v-model="sortData.sortName" class="w-full">
<el-option v-for="item in sortNameOptions" :key="item.type" :label="item.label" :value="item.type" />
</el-select>
<el-select v-model="sortData.sort" class="w-full">
<el-option :label="$t('hostsList.ascending') || '升序'" value="asc" />
<el-option :label="$t('hostsList.descending') || '降序'" value="desc" />
</el-select>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="reset">{{ $t('hostsList.reset') || '重置' }}</el-button>
<el-button type="primary" @click="handelClick">{{ $t('hostsList.confirm') || '确认' }}</el-button>
</span>
</template>
</el-dialog>
<!-- 指定直播间弹窗 -->
<el-dialog v-model="streamdialogVisible" :title="$t('hostsList.specifyRooms') || '指定直播间'" width="600px">
<el-input v-model="textarea" :rows="10" type="textarea"
:placeholder="$t('hostsList.enterRoomIds') || '请输入房间ID每行一个'" @input="handleInput" />
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="specifyCancel">{{ $t('hostsList.cancelSpecify') || '取消指定' }}</el-button>
<el-button @click="specifyreset">{{ $t('hostsList.specifyReset') || '重置' }}</el-button>
<el-button type="primary" @click="specifyClick">{{ $t('hostsList.specifyConfirm') || '确认' }}</el-button>
<el-button type="success" @click="specifyClickStart">{{ $t('hostsList.specifyStart') || '确认并开始' }}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from "vue";
import { usePythonBridge } from "@/utils/pythonBridge";
import { getUser } from "@/utils/storage";
import { getCountryName } from "@/utils/countryUtil";
import { ElMessage, ElMessageBox, ElLoading } from "element-plus";
import { useI18n } from 'vue-i18n';
// Mock API calls if not present
// Ideally we should import these from api file, but for simplicity I will mock them or use empty callbacks
// if the user hasn't provided the api file content.
// Based on hostsList.vue reading, it uses `tkhostdata` from `@/api/account`.
// I will attempt to import, but if it fails I might need to create it.
// Assuming verify step will catch missing API functions.
import { tkhostdata, getCountryinfo } from "@/api/account";
const { t, locale } = useI18n();
// Component State
const queryFormData = ref({
coinMin: "",
coinMax: "",
levelMin: "",
levelMax: "",
isFilter: false,
isRunning: false,
anchor_ids: [],
});
const searchForm = ref({});
const createTimes = ref([]);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const tableData = ref([]);
const loading = ref(false);
const sortData = ref({ sortName: "createTime", sort: "desc" });
const hourstuo = ref(0);
const minutestuo = ref(0);
const secondstuo = ref(0);
const startTime = ref(null);
const getBrotherInfodata = ref({
total: 0,
valid: 0,
});
const streamdialogVisible = ref(false);
const streamdialogVisibletext = ref(false);
const filterdialogVisible = ref(false);
const textarea = ref("");
const countryData = ref("");
const userInfo = ref({});
const options = ref([]);
const labelList = ref([
{ paramCode: "userIdStr", paramCodeMeaning: t("hostsList.userId") || "用户ID" },
{ paramCode: "level", paramCodeMeaning: t("hostsList.level") || "等级" },
{ paramCode: "fansLevel", paramCodeMeaning: t("hostsList.fansLevel") || "粉丝等级" },
{ paramCode: "hostcoins", paramCodeMeaning: t("hostsList.coins") || "金币" },
{ paramCode: "region", paramCodeMeaning: t("hostsList.region") || "地区" },
{ paramCode: "followerCount", paramCodeMeaning: t("hostsList.followerCount") || "粉丝数" },
{ paramCode: "followingCount", paramCodeMeaning: t("hostsList.followingCount") || "关注数" },
{ paramCode: "createTime", paramCodeMeaning: t("hostsList.createTime") || "创建时间" },
{ paramCode: "totalGiftCoins", paramCodeMeaning: t("hostsList.totalGiftCoins") || "打赏总额" },
]);
const fields = [
{ label: t("hostsList.level") || "等级", minModel: "levelMin", maxModel: "levelMax" },
];
const sortNameOptions = ref([
{ label: t("hostsList.createTime") || "创建时间", type: "createTime" },
{ label: t("hostsList.coins") || "金币", type: "hostsCoins" },
{ label: t("hostsList.totalGiftCoins") || "打赏总额", type: "totalGiftCoins" },
{ label: t("hostsList.level") || "等级", type: "level" },
]);
// Bridge
const {
givePyAnchorId,
exportToExcel,
loginTikTok,
controlTask,
getBrotherInfo,
Specifystreaming,
readSetInfos,
storageSetInfos,
openAnchorIdRooms,
setClipboards,
} = usePythonBridge();
const isRunnings = ref(false);
const timerId = ref(null);
// Lifecycle
onMounted(async () => {
userInfo.value = getUser() || { tenantId: 0, id: 0 };
getIpInfo();
getCountry();
getlist();
const savedSettings = await readSetInfos({ key: "UserSettings" });
if (savedSettings) {
try {
// savedSettings might be object already if backend returned object, or string
const data = typeof savedSettings === 'string' ? JSON.parse(savedSettings) : savedSettings;
queryFormData.value = data;
if (data.anchor_ids && data.anchor_ids.length > 0) {
streamdialogVisibletext.value = true;
textarea.value = data.anchor_ids.join("\n");
}
} catch(e) { console.error("Error parsing settings", e); }
}
});
onBeforeUnmount(() => {
stopTimerfun();
if (timerId.value) clearInterval(timerId.value);
});
// Methods
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
const getlist = () => {
loading.value = true;
// Use API if available, else mock
if (typeof tkhostdata === 'function') {
tkhostdata({
tenantId: Number(userInfo.value.tenantId),
sort: sortData.value.sort,
sortName: sortData.value.sortName,
current: page.value,
pageSize: pageSize.value,
createTimeStart: createTimes.value[0],
createTimeEnd: createTimes.value[1],
...searchForm.value,
}).then((res) => {
loading.value = false;
if (res && res.records) {
total.value = Number(res.total);
tableData.value = res.records.map((item) => ({
...item,
createTime: formatDate(new Date(item.createTime)),
}));
}
}).catch(e => {
console.log("Mocking data due to error", e);
loading.value = false;
// Mock data
tableData.value = [];
});
} else {
console.warn("tkhostdata API not found");
loading.value = false;
}
};
function getBigBrother() {
const settingData = { ...queryFormData.value, tenantId: userInfo.value.tenantId, region: countryData.value };
// Save settings
storageSetInfos({ key: "UserSettings", data: settingData });
controlTask(JSON.stringify(settingData)).then(() => {
isRunnings.value = true;
queryFormData.value.isRunning = true;
startTimerfun();
// Start polling stats
timerId.value = setInterval(() => {
getBrotherInfo().then(res => {
getBrotherInfodata.value = res;
});
}, 1000);
});
}
function BigBrotherstop() {
stopTimerfun();
isRunnings.value = false;
queryFormData.value.isRunning = false;
if (timerId.value) {
clearInterval(timerId.value);
timerId.value = null;
}
// Send stop command (logic in controlTask might handle toggle or we need stop logic)
// Original uses controlTask to START, but maybe stop logic is handled by setting isRunning=false in payload?
// Original code calls controlTask with payload again.
const settingData = { ...queryFormData.value, tenantId: userInfo.value.tenantId, region: countryData.value, isRunning: false };
controlTask(JSON.stringify(settingData));
}
function startTimerfun() {
stopTimerfun();
startTime.value = setInterval(() => {
secondstuo.value++;
if (secondstuo.value >= 60) {
secondstuo.value = 0;
minutestuo.value++;
if (minutestuo.value >= 60) {
minutestuo.value = 0;
hourstuo.value++;
}
}
}, 1000);
}
function stopTimerfun() {
if (startTime.value) clearInterval(startTime.value);
}
// Specify Room Logic
const MAX_SPECIFY_LINES = 50;
function handleInput(value) {
if (typeof value !== "string") return;
const lines = value.split("\n");
if (lines.length > MAX_SPECIFY_LINES) {
textarea.value = lines.slice(0, MAX_SPECIFY_LINES).join("\n");
}
}
function specifyClickStart() {
if (!textarea.value.trim()) {
ElMessage.error(t('hostsList.enterRoomId') || "请输入房间ID");
return;
}
queryFormData.value.anchor_ids = textarea.value.split("\n").filter(id => id.trim());
streamdialogVisible.value = false;
streamdialogVisibletext.value = true;
getBigBrother();
}
function specifyClick() {
if (!textarea.value.trim()) {
streamdialogVisibletext.value = false;
queryFormData.value.anchor_ids = [];
} else {
streamdialogVisibletext.value = true;
queryFormData.value.anchor_ids = textarea.value.split("\n").filter(id => id.trim());
}
streamdialogVisible.value = false;
}
function specifyCancel() {
streamdialogVisible.value = false;
streamdialogVisibletext.value = false;
queryFormData.value.anchor_ids = [];
textarea.value = "";
}
function specifyreset() {
textarea.value = "";
}
// Table / Filter Logic
function serch() {
page.value = 1;
getlist();
}
function reset() {
searchForm.value = {};
createTimes.value = [];
sortData.value = { sortName: "createTime", sort: "desc" };
getlist();
}
function handelClick() {
filterdialogVisible.value = false;
getlist();
}
function handleSizeChange(val) {
pageSize.value = val;
getlist();
}
function handleCurrentChange(val) {
page.value = val;
getlist();
}
function handleSelectionChange(val) {
//
}
function openHTML(id) {
givePyAnchorId(id);
}
function handleLongPress(id) {
openAnchorIdRooms(id);
}
function handleCellDbClick(row, column, cell) {
const text = cell?.textContent?.trim();
if (text) {
setClipboards(text).then(() => ElMessage.success("Copied"));
}
}
function exportList() {
exportToExcel(tableData.value);
}
function openTikTok() {
loginTikTok();
}
// IP / Country
const getIpInfo = async () => {
try {
const response = await fetch("https://ipapi.co/json/");
if (response.ok) {
const data = await response.json();
countryData.value = getCountryName(data.country);
}
} catch {}
};
function getCountry() {
if (typeof getCountryinfo === 'function') {
getCountryinfo({})
.then((res) => {
res.forEach((item) => {
if (item.countryGroupName) {
options.value.push({ value: item.countryGroupName, label: item.countryGroupName });
}
});
})
.catch(() => {});
}
}
</script>

500
src/views/tk/HostsList.vue Normal file
View File

@@ -0,0 +1,500 @@
<template>
<div class="bg-white dark:bg-slate-50 rounded-3xl shadow-2xl flex flex-col overflow-hidden h-full">
<!-- Header -->
<header class="px-8 py-6 border-b border-slate-100 dark:border-slate-200/60 bg-white">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-[200px]">
<select v-model="searchForm.country"
class="w-full bg-slate-50 border-none rounded-xl py-3 pl-4 pr-10 text-sm text-slate-600 appearance-none focus:ring-2 focus:ring-primary/20 shadow-soft-inner outline-none">
<option value="">{{ $t('hostList.selectAll') }}</option>
<option v-for="item in options" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
<span
class="material-icons-round absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">expand_more</span>
</div>
<div class="relative flex-1 min-w-[200px]">
<span
class="material-icons-round absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm pointer-events-none">calendar_today</span>
<input ref="dateInput" v-model="searchForm.createTime" type="date" @click="openDatePicker"
class="w-full bg-slate-50 border-none rounded-xl py-3 pl-10 pr-4 text-sm text-slate-600 focus:ring-2 focus:ring-primary/20 shadow-soft-inner outline-none cursor-pointer" />
</div>
<div class="relative flex-[1.5] min-w-[240px]">
<input v-model="searchForm.hostsId"
class="w-full bg-slate-50 border-none rounded-xl py-3 px-4 text-sm text-slate-600 focus:ring-2 focus:ring-primary/20 shadow-soft-inner outline-none"
:placeholder="$t('hostList.placeHostId')" />
</div>
<div class="flex items-center gap-2 ml-auto">
<button @click="serch"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-medium text-sm transition-all shadow-lg shadow-primary/20 flex items-center gap-2">
<span class="material-icons-round text-sm">search</span>
{{ $t('hostList.query') }}
</button>
<button @click="filterdialogVisible = true"
class="bg-slate-100 hover:bg-slate-200 text-slate-600 p-3 rounded-xl transition-all">
<span class="material-icons-round">tune</span>
</button>
</div>
</div>
</header>
<!-- Table -->
<div class="flex-1 flex flex-col overflow-hidden relative" style="padding-left: 20px;">
<div v-if="loading" class="absolute inset-0 bg-white/50 z-30 flex items-center justify-center">
<span class="material-icons-round animate-spin text-primary text-4xl">sync</span>
</div>
<!-- Table with Fixed Layout -->
<div class="flex-1 overflow-auto">
<table class="w-full text-left" style="table-layout: fixed; min-width: 1000px;">
<colgroup>
<col style="width: 12%;">
<col style="width: 5%;">
<col style="width: 8%;">
<col style="width: 8%;">
<col style="width: 6%;">
<col style="width: 10%;">
<col style="width: 8%;">
<col style="width: 8%;">
<col style="width: 7%;">
<col style="width: 7%;">
<col style="width: 7%;">
<col style="width: 7%;">
</colgroup>
<thead class="bg-white sticky top-0 z-10">
<tr class="text-slate-400 text-xs font-semibold uppercase tracking-wider border-b border-slate-100">
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.hostId') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.grade') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.invitationType') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.liveSessions') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.country') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.creationTime') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.anchorcoins') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.yesterdayGoldCoins') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.fansNum') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.followersNum') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.onlineFans') }}</th>
<th class="py-3 px-2 font-semibold bg-white">{{ $t('hostList.anchorType') }}</th>
</tr>
</thead>
<tbody class="text-sm text-slate-600">
<tr v-if="tableData.length === 0" class="text-center">
<td colspan="14" class="py-10 text-slate-400">暂无数据</td>
</tr>
<tr v-for="row in tableData" :key="row.hostId" class="group hover:bg-slate-50/80 transition-colors">
<!-- Host ID -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
<span @click="openHTML(row.hostId)"
class="font-medium text-slate-900 border-b border-transparent group-hover:border-primary/30 transition-all cursor-pointer hover:text-blue-600">
{{ row.hostId }}
</span>
</td>
<!-- Level -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.hostlevel }}
</td>
<!-- Invitation Type -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
<span class="px-3 py-1 text-[10px] font-bold uppercase rounded-full"
:class="row.invitationType == 1 ? 'bg-green-50 text-green-600' : 'bg-amber-50 text-amber-600'">
{{ row.invitationType == 1 ? $t('hostList.invitationType1') : $t('hostList.invitationType2') }}
</span>
</td>
<!-- Data Buttons -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
<div class="flex gap-2">
<button
@click="getliveHost(row.hostId)"
class="px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-600 hover:text-white rounded-lg text-xs font-medium transition-all"
>
{{ $t('hostList.viewSessions') }}
</button>
</div>
</td>
<!-- Country -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.country }}
</td>
<!-- Time -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
<div class="flex flex-col">
<span>{{ formatTimeOnlyDate(row.createTime) }}</span>
<span class="text-[10px] text-slate-400">{{ formatTimeOnlyTime(row.createTime) }}</span>
</div>
</td>
<!-- Coins -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none font-semibold text-slate-900">
{{ row.hostsCoins }}
</td>
<!-- Yesterday Coins -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.yesterdayCoins }}
</td>
<!-- Fans -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.fans }}
</td>
<!-- Followers -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.fllowernum }}
</td>
<!-- Online Fans -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.onlineFans }}
</td>
<!-- Host Kind -->
<td class="py-4 px-2 border-b border-slate-50 group-last:border-none">
{{ row.hostsKind }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Footer / Pagination -->
<footer
class="px-8 py-6 border-t border-slate-100 dark:border-slate-200/60 bg-white flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="relative">
<select v-model="pageSize" @change="handleSizeChange"
class="bg-slate-50 border-none rounded-lg py-2 pl-4 pr-10 text-sm text-slate-600 appearance-none focus:ring-2 focus:ring-primary/20 shadow-soft-inner outline-none">
<option :value="10">10/page</option>
<option :value="20">20/page</option>
<option :value="50">50/page</option>
<option :value="100">100/page</option>
</select>
<span
class="material-icons-round absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-sm pointer-events-none">expand_more</span>
</div>
<span class="text-xs text-slate-400 font-medium">总条数: <span class="text-blue-600">{{ total }}</span></span>
</div>
<div class="flex items-center gap-1">
<button @click="changePage(page - 1)" :disabled="page <= 1"
class="p-2 text-slate-400 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50">
<span class="material-icons-round text-lg">chevron_left</span>
</button>
<!-- Page Numbers -->
<template v-for="(p, index) in paginationPages" :key="index">
<span v-if="p === '...'" class="w-8 h-8 flex items-center justify-center text-slate-400 text-sm">...</span>
<button v-else @click="changePage(p)" class="w-8 h-8 rounded-lg text-xs font-bold transition-all"
:class="p === page ? 'bg-slate-900 text-white shadow-md' : 'text-slate-600 hover:bg-slate-100'">
{{ p }}
</button>
</template>
<button @click="changePage(page + 1)" :disabled="page >= totalPages"
class="p-2 text-slate-400 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50">
<span class="material-icons-round text-lg">chevron_right</span>
</button>
</div>
</footer>
<!-- Filter Dialog (Preserved Element Plus) -->
<el-dialog v-model="filterdialogVisible" width="800px" :before-close="handleClose">
<el-row v-for="(field, index) in fields" :key="index" :gutter="20" style="margin-bottom: 10px">
<el-col :span="4">
<div style="height: 100%; padding-top: 10px" class="flex items-center justify-center">
{{ field.label }}
</div>
</el-col>
<el-col :span="10">
<div><label class="text-sm text-slate-500 mb-1 block">{{ $t('hostList.min') }}</label></div>
<el-input type="number" :oninput="'if(value.length>9)value=value.slice(0,9)'"
v-model.number="searchForm[field.minModel]" :placeholder="$t('hostList.placeMin')" />
</el-col>
<el-col :span="10">
<div><label class="text-sm text-slate-500 mb-1 block">{{ $t('hostList.max') }}</label></div>
<el-input type="number" :oninput="'if(value.length>9)value=value.slice(0,9)'"
v-model.number="searchForm[field.maxModel]" :placeholder="$t('hostList.placeMax')" />
</el-col>
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :span="4">
<div style="height: 100%;padding-top: 10px;" class="flex items-center justify-center">
{{ $t('hostList.sort') }}
</div>
</el-col>
<el-col :span="10">
<div><label class="text-sm text-slate-500 mb-1 block">{{ $t('hostList.sortType') }}</label></div>
<el-select v-model="sortData.sortType" filterable :placeholder="$t('hostList.selectPlaceholder')"
class="w-full">
<el-option v-for="item in sortNameOptions" :key="item.type" :label="item.label" :value="item.type" />
</el-select>
</el-col>
<el-col :span="10">
<div><label class="text-sm text-slate-500 mb-1 block">{{ $t('hostList.ascending') }}/{{
$t('hostList.descending')
}}</label></div>
<el-select v-model="sortData.sortForm" filterable :placeholder="$t('hostList.selectPlaceholder')"
class="w-full">
<el-option
v-for="item in [{ label: $t('hostList.ascending'), value: 'asc' }, { label: $t('hostList.descending'), value: 'desc' }]"
:key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-col>
</el-row>
<template #footer>
<span class="dialog-footer">
<el-button @click="reset">
{{ $t('hostList.reset') }}
</el-button>
<el-button type="primary" @click="handelClick" class="bg-blue-600">
{{ $t('hostList.sure') }}
</el-button>
</span>
</template>
</el-dialog>
<!-- Dialogs -->
<LiveRecordDialog v-model:modelValue="liveDetailDialogVisible" :rows="liveDetailRecords"
@select="handleLiveSelect" />
</div>
</template>
<script setup>
import { tkhostdata, getCountryinfo, liveHostDetail } from '@/api/account';
import { usePythonBridge } from '@/utils/pythonBridge'
import { getUser } from '@/utils/storage'
import { ref, reactive, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n'
import LiveRecordDialog from '@/components/LiveRecordDialog.vue'
const { t } = useI18n()
const loading = ref(false)
const dateInput = ref(null)
const { givePyAnchorId, exportToExcel } = usePythonBridge();
const userInfo = ref(getUser())
const tableData = ref([])
const searchForm = ref({
country: '',
createTime: '',
hostsId: '',
fansMin: null, fansMax: null,
onlineFansMin: null, onlineFansMax: null,
hostsCoinsMin: null, hostsCoinsMax: null,
fllowernumMin: null, fllowernumMax: null
})
const fields = [
{ label: t('hostList.fansNum'), minModel: 'fansMin', maxModel: 'fansMax' },
{ label: t('hostList.onlineFans'), minModel: 'onlineFansMin', maxModel: 'onlineFansMax' },
{ label: t('hostList.anchorcoins'), minModel: 'hostsCoinsMin', maxModel: 'hostsCoinsMax' },
{ label: t('hostList.followersNum'), minModel: 'fllowernumMin', maxModel: 'fllowernumMax' },
]
let sortData = ref({ sortForm: 'desc', sortType: "createTime" })
let sortNameOptions = ref([
{ label: t('hostList.creationTime'), type: 'createTime' },
{ label: t('hostList.anchorcoins'), type: 'hostsCoins' },
{ label: t('hostList.fansNum'), type: 'fans' },
{ label: t('hostList.yesterdayGoldCoins'), type: 'yesterdayCoins' },
{ label: t('hostList.onlineFans'), type: 'onlineFans' },
{ label: t('hostList.followersNum'), type: 'fllowernum' },
])
let filterdialogVisible = ref(false)
let liveDetailDialogVisible = ref(false)
let liveDetailRecords = ref([])
let pageSize = ref(10)
let page = ref(1)
let total = ref(0)
let options = ref([])
onMounted(() => {
getCountry();
getlist();
})
function serch() {
page.value = 1
getlist();
}
// 手动打开日期选择器
function openDatePicker(event) {
const input = event.target;
if (input && typeof input.showPicker === 'function') {
try {
input.showPicker();
} catch (e) {
input.focus();
}
}
}
function handleSizeChange() {
page.value = 1
getlist();
}
function changePage(newPage) {
if (newPage < 1 || (newPage - 1) * pageSize.value >= total.value) return
page.value = newPage
getlist()
}
// 计算总页数
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 生成分页页码数组
const paginationPages = computed(() => {
const current = page.value
const totalP = totalPages.value
const pages = []
if (totalP <= 7) {
for (let i = 1; i <= totalP; i++) {
pages.push(i)
}
} else {
pages.push(1)
if (current > 4) {
pages.push('...')
}
let start = Math.max(2, current - 2)
let end = Math.min(totalP - 1, current + 2)
if (current <= 4) end = Math.min(5, totalP - 1)
if (current >= totalP - 3) start = Math.max(totalP - 4, 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < totalP - 3) {
pages.push('...')
}
if (totalP > 1) {
pages.push(totalP)
}
}
return pages
})
const getlist = () => {
loading.value = true
// Fix: Check if userInfo is valid before call
const tenantId = userInfo.value ? Number(userInfo.value.tenantId) : 0
tkhostdata({
tenantId: tenantId,
sort: sortData.value.sortForm,
sortName: sortData.value.sortType,
"current": page.value,
"pageSize": pageSize.value,
...searchForm.value,
}).then(res => {
loading.value = false
if (res) {
console.log('主播列表', res)
total.value = Number(res.total)
tableData.value = res.records.map(item => ({
hostId: item.hostsId,
hostlevel: item.hostsLevel,
country: item.country,
createTime: item.createTime,
fans: item.fans,
fllowernum: item.fllowernum,
hostsCoins: item.hostsCoins,
hostsKind: item.hostsKind,
onlineFans: item.onlineFans,
yesterdayCoins: item.yesterdayCoins,
belongBy: item.belongBy,
useable: item.useable,
invitationType: item.invitationType,
}));
}
}).catch(() => {
loading.value = false
})
}
function handelClick() {
filterdialogVisible.value = false
getlist()
}
function reset() {
searchForm.value.fansMin = null
searchForm.value.fansMax = null
searchForm.value.onlineFansMin = null
searchForm.value.onlineFansMax = null
searchForm.value.hostsCoinsMin = null
searchForm.value.hostsCoinsMax = null
searchForm.value.fllowernumMin = null
searchForm.value.fllowernumMax = null
}
function handleClose(done) {
done()
}
function getliveHost(hostId) {
liveHostDetail({
"hostsId": hostId,
"tenantId": userInfo.value?.tenantId
}).then(res => {
const detailList = Array.isArray(res) ? res : (res?.records || [])
liveDetailRecords.value = detailList
liveDetailDialogVisible.value = true
})
}
function handleLiveSelect(row) {
liveDetailDialogVisible.value = false
}
function openHTML(id) {
givePyAnchorId(id)
}
function getCountry() {
getCountryinfo({}).then(res => {
if(res) {
res.forEach(item => {
if (item.countryGroupName) {
options.value.push({ value: item.countryGroupName, label: item.countryGroupName })
}
})
}
})
}
function formatTimeOnlyDate(val) {
if (!val) return ''
return val.split(' ')[0] || val
}
function formatTimeOnlyTime(val) {
if (!val) return ''
return val.split(' ')[1] || ''
}
</script>
<style scoped>
/* Scoped styles mainly for Element Plus overrides if needed */
</style>

View File

@@ -0,0 +1,787 @@
<template>
<div class="h-full w-full overflow-y-auto bg-gray-50 p-6">
<div class="container mx-auto">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4 mb-4">
<!-- Stat Cards -->
<!-- 总数量 (较小) -->
<div
class="lg:col-span-2 bg-white dark:bg-slate-900 p-4 rounded-xl shadow-sm border border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-slate-500">{{ $t('workbenches.totalnumber') }}</span>
<span class="material-icons-round text-primary/40 text-lg">analytics</span>
</div>
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ hostData.totalCount }}</div>
</div>
<!-- 新建主播 (较小) -->
<div
class="lg:col-span-2 bg-white dark:bg-slate-900 p-4 rounded-xl shadow-sm border border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-slate-500">{{ $t('workbenches.createHost') }}</span>
<span class="material-icons-round text-secondary/40 text-lg">person_add</span>
</div>
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ hostData.validAnchorsCount }}</div>
</div>
<!-- 查询 (较小) -->
<div
class="lg:col-span-2 bg-white dark:bg-slate-900 p-4 rounded-xl shadow-sm border border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-slate-500">{{ $t('workbenches.query') }}</span>
<span class="material-icons-round text-amber-400/60 text-lg">search</span>
</div>
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ hostData.checkedDataCount }}</div>
</div>
<!-- 邀请 (较大突出显示) -->
<div
class="lg:col-span-3 bg-gradient-to-br from-blue-600 to-blue-500 p-5 rounded-xl shadow-lg shadow-blue-500/20 text-white relative overflow-hidden">
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2">
</div>
<div class="flex items-center justify-between mb-2 relative z-10">
<span class="text-sm font-medium text-white/80">{{ $t('workbenches.invite') }}</span>
<span class="material-icons-round text-white/60">mail_outline</span>
</div>
<div class="text-3xl font-bold text-white relative z-10">{{ hostData.canInvitationCount }}</div>
<div class="text-xs text-white/60 mt-1">可邀请主播</div>
</div>
<!-- 运行时间 (较大) -->
<div
class="lg:col-span-3 bg-white dark:bg-slate-900 p-5 rounded-xl shadow-sm border border-slate-100 dark:border-slate-800 flex flex-col justify-center">
<div class="flex items-center justify-between">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider block mb-1">{{
$t('workbenches.runTime') }}</span>
<div class="text-2xl font-mono font-bold text-blue-600">{{ formattedTime }}</div>
</div>
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full" :class="isTkLoggedIn ? 'bg-emerald-500' : 'bg-red-500'"></span>
<button @click="openTK"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-semibold shadow-lg shadow-blue-600/25">
{{ $t('workbenches.openTK') }}
</button>
</div>
</div>
</div>
</div>
<!-- Guild Accounts -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div v-for="(item, index) in 2" :key="index"
class="bg-white border border-slate-100 p-5 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full"
:class="tkData[index].code == 1 ? 'bg-emerald-500' : 'bg-red-500'"></span>
<h3 class="font-bold text-slate-800 dark:text-white">{{ $t('workbenches.guildAccount') }} {{ index === 0
? 'A'
: 'B' }}</h3>
</div>
<span class=" text-slate-500" style="font-size: 17px;">{{ $t('workbenches.queriedNum') }}: {{
tkData[index].num }}</span>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs font-semibold text-slate-500 mb-1 block">{{ $t('workbenches.guildAccount')
}}</label>
<el-input v-model="tkData[index].account" :placeholder="$t('workbenches.guildAccountPlace')"
:disabled="!(tkData[index].code == 0 && !isLogin[index])" class="el-input-custom" />
</div>
<div>
<label class="text-xs font-semibold text-slate-500 mb-1 block">{{ $t('workbenches.guildPass') }}</label>
<el-input v-model="tkData[index].password" type="password" show-password
:placeholder="$t('workbenches.guildPassPlace')"
:disabled="!(tkData[index].code == 0 && !isLogin[index])" class="el-input-custom" />
</div>
</div>
<button @click="loginTK(index)" :disabled="!(tkData[index].code == 0 && !isLogin[index])"
class="w-full bg-slate-900 dark:bg-slate-700 hover:bg-black text-white py-2.5 rounded-lg font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('workbenches.loginBackend') }}
</button>
</div>
</div>
</div>
<!-- Configuration Panel -->
<div
class="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-100 dark:border-slate-800 overflow-hidden">
<div class="p-6 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-slate-100 dark:bg-slate-800 rounded-lg flex items-center justify-center">
<span class="material-icons-round text-slate-600 dark:text-slate-400 text-lg">settings</span>
</div>
<h2 class="font-bold text-slate-800 dark:text-white">{{ $t('workbenchesSetup.workbenches') }}</h2>
</div>
<div class="flex items-center gap-4 text-sm">
<div class="text-slate-500">{{ $t('workbenchesSetup.network') }}: <span class="text-blue-600 font-bold">{{
locale
== 'zh' ? countryData : countryDataEN }}</span></div>
<div class="flex items-center gap-2">
<span class="text-slate-500">指定国家:</span>
<select v-model="country_info"
class="bg-slate-50 dark:bg-slate-800 border-none rounded-lg text-xs font-medium focus:ring-0">
<option value="全部">全部</option>
<option v-for="(item, index) in country_Lst" :key="index" :value="item">{{ item }}</option>
</select>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
<!-- Coins -->
<div>
<h4 class="text-sm font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
<span class="w-1 h-4 bg-blue-600 rounded-full"></span>
{{ $t('workbenchesSetup.setCoinsNum') }}
</h4>
<div class="space-y-3">
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<span
class="bg-slate-50 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-28 flex items-center border-r border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.minCoinsNum') }}</span>
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.gold.min" :disabled="!pyData.isStart" />
</div>
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<span
class="bg-slate-50 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-28 flex items-center border-r border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.maxCoinsNum') }}</span>
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.gold.max" :disabled="!pyData.isStart" />
</div>
</div>
</div>
<!-- Fans -->
<div>
<h4 class="text-sm font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
<span class="w-1 h-4 bg-secondary rounded-full"></span>
{{ $t('workbenchesSetup.setFansNum') }}
</h4>
<div class="space-y-3">
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<span
class="bg-slate-50 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-28 flex items-center border-r border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.minFansNum') }}</span>
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.fans.min" :disabled="!pyData.isStart" />
</div>
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<span
class="bg-slate-50 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-28 flex items-center border-r border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.maxFansNum') }}</span>
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.fans.max" :disabled="!pyData.isStart" />
</div>
</div>
</div>
<!-- Frequency -->
<div>
<h4 class="text-sm font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
<span class="w-1 h-4 bg-emerald-500 rounded-full"></span>
{{ $t('workbenchesSetup.setQuery') }}
</h4>
<div class="space-y-3">
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.frequency.hour" :disabled="!pyData.isStart" />
<span
class="bg-slate-100 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-24 flex items-center justify-center border-l border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.hour') }}</span>
</div>
<div class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="pyData.frequency.day" :disabled="!pyData.isStart" />
<span
class="bg-slate-100 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-24 flex items-center justify-center border-l border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.hour24') }}</span>
</div>
</div>
</div>
<!-- Quantity Limit -->
<div>
<h4 class="text-sm font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
<span class="w-1 h-4 bg-orange-400 rounded-full"></span>
{{ $t('workbenchesSetup.setNum') }}
<span class="text-[10px] text-slate-400 font-normal ml-1">({{ $t('workbenchesSetup.prompt') }})</span>
</h4>
<div class="space-y-3">
<div class="flex gap-2">
<button @click="isLimit = true" :disabled="!pyData.isStart"
class="flex-1 px-3 py-2 text-xs font-semibold rounded-md border transition-colors"
:class="isLimit ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-slate-600 border-slate-200 hover:border-blue-600/50'">
{{ $t('workbenchesSetup.setHostNum') }}
</button>
<button @click="isLimit = false" :disabled="!pyData.isStart"
class="flex-1 px-3 py-2 text-xs font-semibold rounded-md border transition-colors"
:class="!isLimit ? 'bg-slate-500 text-white border-slate-500' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'">
{{ $t('workbenchesSetup.unlimitedQuantity') }}
</button>
</div>
<div v-if="isLimit"
class="flex shadow-sm rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<input
class="flex-1 px-4 py-2 text-sm bg-white dark:bg-slate-900 border-none outline-none focus:ring-0 disabled:bg-slate-100"
type="number" v-model="hostNum" :disabled="!pyData.isStart" />
<span
class="bg-slate-100 dark:bg-slate-800 px-3 py-2 text-xs font-medium text-slate-500 w-16 flex items-center justify-center border-l border-slate-200 dark:border-slate-700">{{
$t('workbenchesSetup.num') }}</span>
</div>
</div>
</div>
</div>
<div
class="flex flex-col lg:flex-row items-center justify-between gap-6 pt-4 border-t border-slate-100 dark:border-slate-800">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2 cursor-pointer group" @click="toggleFilter('filterGame')">
<span class="w-4 h-4 rounded border-2 flex items-center justify-center transition-all"
:class="pyData.filterGame ? 'bg-blue-600 border-blue-600' : 'bg-white border-slate-300'">
<span v-if="pyData.filterGame" class="material-icons-round text-white text-xs">check</span>
</span>
<span
class="text-sm text-slate-600 dark:text-slate-400 group-hover:text-blue-600 transition-colors">过滤游戏主播</span>
</div>
<div class="flex items-center gap-2 cursor-pointer group" @click="toggleFilter('filterSelling')">
<span class="w-4 h-4 rounded border-2 flex items-center justify-center transition-all"
:class="pyData.filterSelling ? 'bg-blue-600 border-blue-600' : 'bg-white border-slate-300'">
<span v-if="pyData.filterSelling" class="material-icons-round text-white text-xs">check</span>
</span>
<span
class="text-sm text-slate-600 dark:text-slate-400 group-hover:text-blue-600 transition-colors">过滤带货主播</span>
</div>
<div class="flex items-center gap-2 cursor-pointer group" @click="toggleFilter('rankingList')">
<span class="w-4 h-4 rounded border-2 flex items-center justify-center transition-all"
:class="pyData.rankingList ? 'bg-blue-600 border-blue-600' : 'bg-white border-slate-300'">
<span v-if="pyData.rankingList" class="material-icons-round text-white text-xs">check</span>
</span>
<span
class="text-sm text-slate-600 dark:text-slate-400 group-hover:text-blue-600 transition-colors">过滤排行榜单</span>
</div>
</div>
</div>
<div class="mt-6 text-center">
<button v-if="pyData.isStart" @click="submit"
class="bg-slate-900 dark:bg-blue-600 hover:scale-[1.02] active:scale-[0.98] text-white px-10 py-3 rounded-xl font-bold text-lg shadow-xl shadow-slate-900/10 dark:shadow-blue-600/20 transition-all flex items-center gap-2 mx-auto">
<span class="material-icons-round">bolt</span>
{{ $t('workbenchesSetup.start') }}
</button>
<button v-else @click="unsubmit"
class="bg-red-500 hover:bg-red-600 hover:scale-[1.02] active:scale-[0.98] text-white px-12 py-4 rounded-xl font-bold text-lg shadow-xl shadow-red-500/20 transition-all flex items-center gap-3 mx-auto">
<span class="material-icons-round">stop</span>
{{ $t('workbenchesSetup.stop') }}
</button>
<p class="mt-4 text-xs font-medium text-emerald-600 dark:text-emerald-400">
到期时间: {{ timestampToTime(expiredTime) }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { usePythonBridge, } from '@/utils/pythonBridge'
import { setNumData, getNumData, getUser, setTkUser, getTkUser } from '@/utils/storage'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCountryName } from '@/utils/countryUtil'
import { tkaccountuseinfo, getExpiredTime } from '@/api/account'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
//导入python交互方法
const { fetchDataConfig, fetchDataCount, loginBackStage, loginTikTok, backStageloginStatus, backStageloginStatusCopy, getTkLoginStatus } = usePythonBridge();
//ip国家
let countryData = ref('');
//英文国家
let countryDataEN = ref('');
let country_info = ref('全部');
let country_Lst = ref();
//获取主播数量的定时器
let getHostTimer = ref(null);
//获取查询次数定时器
let getNumTimer = ref(null);
//获取的主播信息
let hostData = ref({
totalCount: 0,
validAnchorsCount: 0,
canInvitationCount: 0,
checkedDataCount: 0,
});
//是否开启tk
// let isTk = ref(true);
//账号是否登陆中
let isLogin = ref([false, false]);
//TK登录状态
let isTkLoggedIn = ref(false);
//TK状态轮询定时器
let tkStatusTimer = ref(null);
//设置状态轮询定时器
let statusTimer = ref(null);
let statusTimerCopy = ref(null);
//设置次数最大值
let maxCount = ref([
{
hourMax: 50,
dayMax: 300,
},
{
hourMax: 100,
dayMax: 600,
},
]);
//tk账号信息
let tkData = ref([
{
account: '',
password: '',
index: 1,
code: 0,
num: 0
},
{
account: '',
password: '',
index: 2,
code: 0,
num: 0
},
]);
//python需要的数据
let pyData = ref({
gold: { min: 0, max: 0 },
fans: { min: 0, max: 0 },
frequency: { hour: 0, day: 0 },
isStart: true,
country: countryData.value,
filterSelling: false,
filterGame: false,
rankingList: false,
tenantId: getUser()?.tenantId || '',
userId: getUser()?.userId || '',
});
//是否限制查询数量
let isLimit = ref(false);
//需要查询的主播数
let hostNum = ref(0);
//按钮提交状态
let submitting = ref(true);
let expiredTime = ref(null);
onMounted(async () => {
//从缓存获取数据
if (getNumData()) {
pyData.value = getNumData();
}
if (getTkUser()) {
tkData.value = getTkUser();
tkData.value[0].code = 0;
tkData.value[1].code = 0;
}
tkaccountuse(tkData.value[0].account, 0)
tkaccountuse(tkData.value[1].account, 1)
getIpInfo()
setTimeout(() => {
// Check if user exists before calling getExpiredTime
if (getUser()?.tenantId) {
getExpiredTime(getUser().tenantId).then((res) => {
console.log('time:', res);
expiredTime.value = res.expiredTime
})
}
}, 1000);
checkVPN()
setInterval(async () => {
await checkVPN()
}, 1000 * 20)
})
const getIpInfo = async () => {
try {
const response = await fetch('https://ipapi.co/json/');
if (!response.ok) {
throw new Error('请求失败');
}
const data = await response.json();
console.log('IP信息:', data.country);
countryDataEN.value = data.country_name
countryData.value = getCountryName(data.country);
const url = `https://datasave.api.yolozs.com/api/save_data/country_info?countryName=${countryData.value}`;
const res = await fetch(url);
const countryres = await res.json();
country_Lst.value = countryres.data
} catch (error) {
console.error('请求出错:', error);
// Optional: Re-enable if needed, but alert can be annoying
// ElMessageBox.prompt('请输入将要获取国家的中文名', '获取国家失败', { ... })
}
};
//提交数据到py
const submit = () => {
pyData.value.country = countryData.value;
console.log('提交的区间值:', pyData.value);
if (((Number(pyData.value.gold.min) > Number(pyData.value.gold.max)) || (Number(pyData.value.fans.min) > Number(pyData.value.fans.max)))) {
ElMessage.error('请输入正确的区间值');
return;
}
if ((Number(pyData.value.gold.max) <= 0 || Number(pyData.value.fans.max <= 0)) || pyData.value.gold.max == '' || pyData.value.fans.max == '') {
ElMessage.error('请输入正确的区间值');
return;
}
if (Number(pyData.value.frequency.hour) <= 0 || Number(pyData.value.frequency.day) <= 0 || pyData.value.frequency.hour == '' || pyData.value.frequency.day == '') {
ElMessage.error('请输入正确的频率区间值');
return;
}
//是否限制爬取数量
if (isLimit.value) {
if (hostNum.value <= 0) {
ElMessage.error('请输入正确的可邀请数量');
return;
}
}
ElMessageBox.confirm(
'确认开始爬取数据?',
'开始',
{
confirmButtonText: '开始',
cancelButtonText: '取消',
type: 'success',
}
)
.then(() => {
// console.log('提交的区间值:', pyData.value.gold, pyData.value.fans, pyData.value.frequency);
//开始按钮的状态 改为禁用
submitting.value = true;
setNumData(pyData.value);
console.error('提交的区间值:', JSON.stringify(pyData.value));
fetchDataConfig(JSON.stringify({
gold: pyData.value.gold,
fans: pyData.value.fans,
frequency: pyData.value.frequency,
isStart: true,
filterSelling: pyData.value.filterSelling,
filterGame: pyData.value.filterGame,
rankingList: !pyData.value.rankingList,
country: countryData.value,
tenantId: getUser().tenantId,
userId: getUser().id,
crawl_single_nation: country_info.value == '全部' ? '' : country_info.value
})).then((res) => {
//开始计时器
startTimer();
//开启查询次数
getHostTimer.value = setInterval(() => {
fetchDataCount().then((res) => {
hostData.value = JSON.parse(res);
if (isLimit.value) {
if (hostData.value.canInvitationCount >= hostNum.value) {
unsubmit();
alert('爬取完毕')
}
}
})
}, 1000);
getNumTimer.value = setInterval(() => {
tkaccountuse(tkData.value[0].account, 0)
tkaccountuse(tkData.value[1].account, 1)
}, 5000);
}).finally(() => {
setTimeout(() => {
pyData.value.isStart = false;
submitting.value = false;
}, 2000)
})
})
.catch(() => {
})
};
//停止
const unsubmit = () => {
fetchDataConfig(JSON.stringify({
gold: pyData.value.gold,
fans: pyData.value.fans,
frequency: pyData.value.frequency,
isStart: false,
filterSelling: pyData.value.filterSelling,
filterGame: pyData.value.filterGame,
rankingList: !pyData.value.rankingList,
country: countryData.value,
tenantId: getUser().tenantId,
userId: getUser().id,
crawl_single_nation: country_info.value == '全部' ? '' : country_info.value
})).then((res) => {
pauseTimer();
pyData.value.isStart = true;
clearInterval(getHostTimer.value);
getHostTimer.value = null;
clearInterval(getNumTimer.value);
getNumTimer.value = null;
// ElMessage.sussec('已停止')
}).catch((err) => {
// ElMessage.error('停止失败')
})
};
// 重置 (Unused but kept for port completeness)
// const reset = () => {
// pyData.value.gold = { min: 0, max: 0 };
// pyData.value.fans = { min: 0, max: 0 };
// pyData.value.frequency = { hour: 0, day: 0 };
// };
// 切换过滤选项 (用于Electron环境下的即时响应)
const toggleFilter = (filterName) => {
if (!pyData.value.isStart) return; // 如果已启动则不允许修改
pyData.value[filterName] = !pyData.value[filterName];
};
const loginTK = (index) => {
setTkUser(tkData.value)
loginBackStage({
account: tkData.value[index].account,
password: tkData.value[index].password,
index: index
})
if (index == 0) {
isLogin.value[1] = true;
statusTimer = setInterval(() => {
getloginStatus();
}, 2000)
} else if (index == 1) {
isLogin.value[0] = true;
statusTimerCopy = setInterval(() => {
getloginStatusCopy();
}, 2000)
}
}
const openTK = () => {
loginTikTok();
// 开始轮询TK登录状态
if (tkStatusTimer.value) {
clearInterval(tkStatusTimer.value);
}
tkStatusTimer.value = setInterval(() => {
checkTkLoginStatus();
}, 3000);
}
// 检查TK登录状态
const checkTkLoginStatus = () => {
getTkLoginStatus().then((res) => {
isTkLoggedIn.value = res === true || res === 'true';
}).catch(() => {
isTkLoggedIn.value = false;
});
}
function getloginStatus() {
backStageloginStatus().then((res) => {
const data = JSON.parse(res);
tkData.value[data.index].code = data.code
if (data.code == 1) {
clearInterval(statusTimer);
statusTimer = null;
submitting.value = false
isLogin.value[1] = false;
}
})
}
function getloginStatusCopy() {
backStageloginStatusCopy().then((res) => {
const data = JSON.parse(res);
tkData.value[data.index].code = data.code
if (data.code == 1) {
clearInterval(statusTimer);
statusTimer = null;
submitting.value = false
isLogin.value[0] = false;
}
})
}
function tkaccountuse(id, index) {
let num = 0;
console.log(id, index, "查询次数")
if (!id || id == '') {
return
}
tkaccountuseinfo(id).then((res) => {
console.log("查询返回", res)
num = res
tkData.value[index].num = num
setTkUser(tkData.value)
console.log('账号使用次数', tkData.value[index].num)
}).catch((err) => {
console.log('账号使用次数', err)
})
}
const isRunning = ref(false);
const totalSeconds = ref(0);
//定时器
let timerCrawl = null;
const startTimedata = ref(null);
//清空时间 并开始运行
const startTimer = () => {
resetTimer();
if (isRunning.value) return;
isRunning.value = true;
startTimedata.value = Date.now();
timerCrawl = setInterval(() => {
totalSeconds.value = Math.floor((Date.now() - startTimedata.value) / 1000);
}, 1000);
};
//结束运行 暂停
const pauseTimer = () => {
isRunning.value = false;
clearInterval(timerCrawl);
};
//清空时间
const resetTimer = () => {
isRunning.value = false;
clearInterval(timerCrawl);
totalSeconds.value = 0;
};
// 格式化时间为 HH:MM:SS
const formattedTime = computed(() => {
const hours = Math.floor(totalSeconds.value / 3600);
const minutes = Math.floor((totalSeconds.value % 3600) / 60);
const seconds = totalSeconds.value % 60;
return [
hours.toString().padStart(2, '0'),
minutes.toString().padStart(2, '0'),
seconds.toString().padStart(2, '0')
].join(':');
});
function timestampToTime(timestamp_ms) {
const date = new Date(timestamp_ms);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
// const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
let isWifi = ref(false);
const checkVPN = async () => {
try {
// 设置超时 5 秒钟
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), 10000) // 10秒超时
);
// 使用 Promise.race 来进行超时控制
const response = await Promise.race([
fetch('https://www.google.com', { method: 'HEAD', mode: 'no-cors' }),
timeout
]);
// 判断 fetch 请求是否成功
if (response && response.type === 'opaque') {
// ElMessage.success('VPN连接正常');
isWifi.value = false;
} else {
ElMessage.error('VPN连接失败无法访问网络。');
isWifi.value = true;
}
} catch (error) {
// 捕获超时错误或其他错误
ElMessage.error('VPN连接失败无法访问网络。');
isWifi.value = true;
}
};
</script>
<style scoped>
/* Element Plus 输入框统一样式 */
.el-input-custom :deep(.el-input__wrapper) {
background-color: white;
border: 1px solid rgb(226, 232, 240);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
box-shadow: none;
transition: all 0.15s ease;
}
.el-input-custom :deep(.el-input__wrapper:hover) {
border-color: rgb(203, 213, 225);
}
.el-input-custom :deep(.el-input__wrapper.is-focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.el-input-custom :deep(.el-input__inner) {
font-size: 0.875rem;
}
.el-input-custom :deep(.el-input__wrapper.is-disabled) {
opacity: 0.5;
}
/* 暗色模式支持 */
.dark .el-input-custom :deep(.el-input__wrapper) {
background-color: rgb(30, 41, 59);
border-color: rgb(51, 65, 85);
}
.dark .el-input-custom :deep(.el-input__wrapper:hover) {
border-color: rgb(71, 85, 105);
}
</style>

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

@@ -1,37 +0,0 @@
/// <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
}

View File

@@ -2,7 +2,7 @@
export default { export default {
content: [ content: [
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx,vue}",
], ],
theme: { theme: {
extend: { extend: {

View File

@@ -1,32 +0,0 @@
{
"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.js Normal file
View File

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

View File

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