Files
web-fusion/src/pages/ConfigPage.vue
2026-04-10 14:58:46 +08:00

873 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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" />
<!-- 预热 Loading 遮罩 -->
<transition name="fade">
<div v-if="warmingUp"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div
class="bg-white/90 rounded-2xl shadow-2xl border border-white/60 px-6 py-5 flex items-center gap-4">
<!-- spinner -->
<div class="w-10 h-10 rounded-full border-4 border-gray-200 border-t-blue-500 animate-spin"></div>
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-900">正在预热视图</div>
<div class="text-xs text-gray-500">
这会提升后台视图渲染稳定性请稍候
</div>
<!-- 可选进度小点点动画 -->
<div class="flex gap-1 pt-1">
<span
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.2s]"></span>
<span
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.1s]"></span>
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce"></span>
</div>
</div>
</div>
</div>
</transition>
</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', 'configUpdated'])
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,
filters: {
maxAnchorCount: 99999
},
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 {
const configToSave = JSON.parse(JSON.stringify(config.value))
// ConfigPage 不管理 filtersHostListDialog 会单独管理
// 删除 filters 避免用 ConfigPage 中可能过期的状态覆盖后端
delete configToSave.filters
await window.electronAPI.saveRunConfig(configToSave)
} 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
}
}
const warmingUp = ref(false)
// 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, // 添加翻译开关配置
filters: {
maxAnchorCount: config.value.filters?.maxAnchorCount !== undefined ? config.value.filters.maxAnchorCount : 100
},
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++
}
}
}
// 预热所有视图,确保后台视图完成渲染,解决自动化执行失败问题
warmingUp.value = true
try {
console.log('[ConfigPage] 预热所有视图...')
await window.electronAPI.warmUpViews()
console.log('[ConfigPage] 视图预热完成')
} catch (e) {
console.warn('[ConfigPage] 视图预热失败,继续启动:', e)
}
const results = await Promise.allSettled(
startTasks.map(async ({ viewId, account, delay }) => {
await new Promise(r => setTimeout(r, delay))
return window.electronAPI.startTikTokAutomation(viewId, account)
})
)
// 检查启动结果
const failedResults = results
.map((r, i) => ({ result: r, task: startTasks[i] }))
.filter(({ result }) => {
if (result.status === 'rejected') return true
if (result.status === 'fulfilled' && !result.value.success) return true
return false
})
// 如果全部失败,显示错误并不跳转
if (failedResults.length === startTasks.length) {
const firstError = failedResults[0]
let errorMsg = '启动失败'
if (firstError.result.status === 'rejected') {
errorMsg = firstError.result.reason?.message || '启动失败'
} else if (firstError.result.status === 'fulfilled') {
errorMsg = firstError.result.value.error || '启动失败'
}
warmingUp.value = false
alert(`启动失败:${errorMsg}`)
return
}
// 如果部分失败,显示警告但继续
if (failedResults.length > 0) {
console.warn(`部分账号启动失败: ${failedResults.length}/${startTasks.length}`)
}
setIsRunning(true)
currentGroupIndex.value = activeGroupIndex
const status = await window.electronAPI.getRotationStatus()
rotationStatus.value = status
handleStatusChange(status)
warmingUp.value = false //关闭遮罩
// 触发自定义事件通知配置已更新
emit('configUpdated')
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>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>