11 Commits

Author SHA1 Message Date
5f45cde984 新增大哥池弹窗 2026-04-17 17:15:52 +08:00
5bdf5722a1 新增tk版 自动私信 2026-04-17 16:32:07 +08:00
c0125a5a9f tk版私信出版 2026-04-16 17:31:45 +08:00
1f8b830d27 通知功能 2026-04-13 14:51:44 +08:00
e2e39cc674 2.4.9 2026-04-10 14:58:46 +08:00
5e2da72d88 优化退出错误代码提示 2026-04-08 13:08:20 +08:00
b2f9dbf2a2 2.4.7 优化过滤 新增重置任务 2026-04-01 13:27:27 +08:00
466a853905 令牌下线提示 2026-03-30 09:53:08 +08:00
b81a0377b8 2.4.4 更改主播上限 2026-03-26 13:38:35 +08:00
1c67cbc5ea 新增pk保加利亚国家 2026-03-24 16:31:03 +08:00
6c86c11e60 更新北京时间 2026-03-24 14:17:55 +08:00
26 changed files with 2412 additions and 419 deletions

View File

@@ -6,5 +6,7 @@ VITE_REGISTER_API_URL=http://192.168.2.22:48080
# VITE_REGISTER_API_URL=https://backstageapi.yolozs.com
# pk api地址
VITE_PK_MINI_API_URL=http://192.168.2.22:8086
# VITE_PK_MINI_API_URL=https://pk.yolozs.com
# 商店地址
VITE_SHOP_URL=https://www.tkzyw.com

View File

@@ -1,13 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YoloAI助手Web版</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yolo终端</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
</body>
</html>

View File

@@ -20,18 +20,20 @@
<!-- 浏览器页面 -->
<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"
/>
<WorkbenchLayout :account-groups="accountGroups" :rotation-status="rotationStatus"
:greeting-stats="greetingStats" :automation-logs="automationLogs" @go-back="handleGoToConfig"
@stop-all="handleStopAll" @logout="handleLogout" />
</div>
</template>
</template>
<!-- 关闭中加载遮罩 -->
<div v-if="isClosing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p class="text-lg font-medium text-gray-800">关闭中请稍候...</p>
</div>
</div>
</template>
<script setup>
@@ -44,6 +46,7 @@ import WorkbenchLayout from './layout/WorkbenchLayout.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import NoticeBar from './components/NoticeBar.vue'
import { useNoticeStore } from './stores/noticeStore'
import { ElMessage } from 'element-plus'
// Constants
const USER_KEY = 'user_data'
@@ -53,6 +56,7 @@ const CONFIG_KEY = 'autoDm_runConfig'
const updateReady = ref(false)
const currentPage = ref('login')
const isLoading = ref(false)
const isClosing = ref(false)
const automationStatus = ref({})
const accountGroups = ref([])
const viewAccountMap = ref({})
@@ -67,15 +71,32 @@ const isDev = window.location.port === '5173'
const noticeStore = useNoticeStore()
noticeStore.fetchNotices()
// 定期检查新通知
let noticeCheckInterval = null
const startNoticeCheck = () => {
// 每 5 分钟检查一次新通知
noticeCheckInterval = setInterval(() => {
noticeStore.fetchNotices()
}, 5 * 60 * 1000)
}
const stopNoticeCheck = () => {
if (noticeCheckInterval) {
clearInterval(noticeCheckInterval)
noticeCheckInterval = null
}
}
// Lifecycle
onMounted(() => {
// Set Title
getAppVersion().then(version => {
document.title = `YoloAI助手Web版v${version}`
document.title = `Yolo终端v${version}`
}).catch(() => {
document.title = 'YoloAI助手Web版'
document.title = 'Yolo终端'
})
console.log('[App]',!isDev , isElectronEnv , !updateReady.value)
console.log('[App]', !isDev, isElectronEnv, !updateReady.value)
// Check Login
try {
const userData = localStorage.getItem(USER_KEY)
@@ -94,6 +115,25 @@ onMounted(() => {
localStorage.removeItem(USER_KEY)
})
// 应用关闭事件
window.electronAPI.onAppClosingStart(async () => {
console.log('[App] 收到应用关闭开始事件')
isClosing.value = true
// 隐藏所有视图,避免遮罩被视图盖住
if (isElectron()) {
window.electronAPI.hideViews().catch((e) => {
console.warn('[App] 隐藏视图失败:', e)
})
await handleStopAll()
}
})
window.electronAPI.onAppClosingComplete(() => {
console.log('[App] 收到应用关闭完成事件')
isClosing.value = false
})
// Rotation Status
window.electronAPI.getRotationStatus().then(status => {
rotationStatus.value = status
@@ -101,7 +141,7 @@ onMounted(() => {
window.electronAPI.onRotationStatusChanged(status => {
rotationStatus.value = status
console.log('[App] 收到轮换状态变化123:', status)
console.log('[App] 收到轮换状态变化123:', status)
// Auto switch tab if group changes
if (status && status.currentActiveGroup && status.enabled) {
console.log('[App] 收到轮换状态变化456:', status)
@@ -130,9 +170,17 @@ console.log('[App] 收到轮换状态变化123:', status)
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('storage', handleStorageChange)
// 监听账号组配置更新事件
window.addEventListener('config-updated', handleConfigUpdate)
window.addEventListener('auth-expired', handleAuthExpired)
loadConfig()
// 启动定期检查新通知
startNoticeCheck()
console.log('[App] 已启动通知检查')
// Health Check
startHealthCheck()
})
@@ -140,6 +188,10 @@ console.log('[App] 收到轮换状态变化123:', status)
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('config-updated', handleConfigUpdate)
window.removeEventListener('auth-expired', handleAuthExpired)
stopNoticeCheck()
console.log('[App] 已停止通知检查')
stopHealthCheck()
})
@@ -154,24 +206,59 @@ const handleStorageChange = (e) => {
}
}
const clearLoginState = () => {
localStorage.removeItem(USER_KEY)
localStorage.removeItem('user')
localStorage.removeItem('token')
}
// 处理配置更新事件
const handleConfigUpdate = () => {
console.log('[App] 收到配置更新事件,重新加载配置')
loadConfig()
}
let healthCheckInterval = null
const resetToLogin = async (message) => {
if (message) {
ElMessage.error(message)
}
stopHealthCheck()
clearLoginState()
currentPage.value = 'login'
if (isElectron()) {
try {
await window.electronAPI.hideViews()
await handleStopAll()
} catch (e) {
console.warn('[App] 娓呯悊瑙嗗浘澶辫触:', e)
}
}
}
const handleAuthExpired = async (event) => {
await resetToLogin(event?.detail?.message)
}
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)
await resetToLogin(result.message); return
// 隐藏所有 BrowserView 并停止自动化,防止视图悬浮在登录页上方
try {
await window.electronAPI.hideViews()
await handleStopAll()
} catch (e) {
console.warn('[App] 清理视图失败:', e)
}
currentPage.value = 'login'
// try {
// await window.electronAPI.hideViews()
// await handleStopAll()
// } catch (e) {
// console.warn('[App] 清理视图失败:', e)
// }
// currentPage.value = 'login'
}
} catch (error) {
console.error('[App] 健康检查失败:', error)
@@ -233,7 +320,7 @@ const handleGoToConfig = async () => {
const handleLogout = async () => {
stopHealthCheck()
currentPage.value = 'login'
localStorage.removeItem(USER_KEY)
clearLoginState()
if (isElectron()) {
try { await window.electronAPI.logout() } catch (e) { console.warn('[App] logout失败:', e) }

View File

@@ -226,3 +226,12 @@ export function resendEmail(data) {
export function logout(data) {
return postAxios({ url: 'user/logout', data })
}
// 获取商品列表
export function getPkItemList(data) {
return getAxios({ url: 'pkItem/list', params: data })
}
// 购买商品
export function buyPkItem(data) {
return postAxios({ url: 'pkItem/buy', data })
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,337 @@
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
<div class="mx-4 flex max-h-[84vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<div>
<h3 class="text-lg font-semibold text-gray-900">大哥池</h3>
<p class="mt-1 text-sm text-gray-500">读取本地 sqlite `brother_info` 数据</p>
</div>
<div class="flex items-center gap-2">
<button @click="handleDeleteAll" :disabled="loading || total === 0"
class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50">全部删除</button>
<button @click="handleClose"
class="rounded-lg px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700">关闭</button>
</div>
</div>
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-4">
<input v-model.trim="filters.keyword" type="text" placeholder="关键词displayId / 昵称 / userIdStr"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500"
@keyup.enter="handleSearch" />
<input v-model.trim="filters.region" type="text" placeholder="地区"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500"
@keyup.enter="handleSearch" />
<input v-model.trim="filters.hostDisplayId" type="text" placeholder="主播 displayId"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500"
@keyup.enter="handleSearch" />
<input v-model.trim="filters.displayId" type="text" placeholder="大哥 displayId"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500"
@keyup.enter="handleSearch" />
</div>
<div class="mt-3 grid grid-cols-2 gap-3 md:grid-cols-6">
<input v-model.number="filters.minLevel" type="number" min="0" placeholder="最低等级"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500" />
<input v-model.number="filters.maxLevel" type="number" min="0" placeholder="最高等级"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500" />
<input v-model.number="filters.minCoins" type="number" min="0" placeholder="最低金币"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500" />
<input v-model.number="filters.maxCoins" type="number" min="0" placeholder="最高金币"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500" />
<select v-model.number="pageSize"
class="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500">
<option :value="10">10 / </option>
<option :value="20">20 / </option>
<option :value="50">50 / </option>
<option :value="100">100 / </option>
</select>
<div class="flex gap-2">
<button @click="handleSearch" :disabled="loading"
class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60">查询</button>
<button @click="handleReset" :disabled="loading"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60">重置</button>
</div>
</div>
</div>
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3 text-sm text-gray-500">
<div> {{ total }} 本页 {{ rows.length }} </div>
<div v-if="loading" class="text-blue-600">加载中...</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full border-collapse text-left text-sm">
<thead class="sticky top-0 bg-slate-900 text-slate-100">
<tr>
<th class="px-4 py-3 font-medium">ID</th>
<th class="px-4 py-3 font-medium">displayId</th>
<th class="px-4 py-3 font-medium">昵称</th>
<th class="px-4 py-3 font-medium">地区</th>
<th class="px-4 py-3 font-medium">等级</th>
<th class="px-4 py-3 font-medium">打赏金币</th>
<th class="px-4 py-3 font-medium">粉丝数</th>
<th class="px-4 py-3 font-medium">主播ID</th>
<th class="px-4 py-3 font-medium">创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 hover:bg-blue-50/50">
<td class="px-4 py-3 text-gray-700">{{ row.id ?? '-' }}</td>
<td class="px-4 py-3 font-medium text-gray-900">{{ row.displayId || '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ row.nickname || '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ row.region || '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ row.level ?? '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ formatNumber(row.hostcoins) }}</td>
<td class="px-4 py-3 text-gray-700">{{ formatNumber(row.followerCount) }}</td>
<td class="px-4 py-3 text-gray-700">{{ row.hostDisplayId || '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ formatTime(row.createTime) }}</td>
</tr>
<tr v-if="!loading && rows.length === 0">
<td colspan="9" class="px-4 py-12 text-center text-gray-400">暂无大哥池数据</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-gray-200 px-5 py-4">
<div class="text-sm text-gray-500"> {{ page }} / {{ totalPages || 1 }} </div>
<div class="flex items-center gap-2">
<button @click="goPrev" :disabled="loading || page <= 1"
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50">上一页</button>
<button @click="goNext" :disabled="loading || page >= totalPages"
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50">下一页</button>
</div>
</div>
</div>
<div v-if="showDeleteConfirm" class="absolute inset-0 z-[10001] flex items-center justify-center bg-black/45 px-4">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<div class="text-lg font-semibold text-gray-900">确认全部删除</div>
<p class="mt-3 text-sm leading-6 text-gray-600">
将删除本地 sqlite <code class="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-700">brother_info</code>
的全部数据删除后不可恢复
</p>
<div class="mt-6 flex justify-end gap-3">
<button
@click="showDeleteConfirm = false"
:disabled="loading"
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
取消
</button>
<button
@click="confirmDeleteAll"
:disabled="loading"
class="rounded-lg bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ loading ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
required: true
}
})
const emit = defineEmits(['close'])
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const totalPages = ref(0)
const showDeleteConfirm = ref(false)
const filters = reactive({
keyword: '',
region: '',
hostDisplayId: '',
displayId: '',
minLevel: undefined,
maxLevel: undefined,
minCoins: undefined,
maxCoins: undefined
})
function getTkBridge() {
return window?.electronAPI?.tk || null
}
function buildPayload() {
const nextFilters = {}
for (const [key, value] of Object.entries(filters)) {
if (value === '' || value === null || value === undefined) continue
nextFilters[key] = value
}
return {
page: page.value,
pageSize: pageSize.value,
filters: nextFilters
}
}
async function loadData() {
const tkBridge = getTkBridge()
if (!tkBridge?.queryBrotherInfo) {
rows.value = []
total.value = 0
totalPages.value = 0
ElMessage.error('当前客户端未加载大哥池查询桥接,请重启客户端后再试')
return
}
loading.value = true
try {
const raw = await tkBridge.queryBrotherInfo(JSON.stringify(buildPayload()))
const result = typeof raw === 'string' ? JSON.parse(raw) : raw
if (result?.status !== 'success') {
throw new Error(result?.message || '查询大哥池失败')
}
rows.value = Array.isArray(result?.list) ? result.list : []
total.value = Number(result?.total || 0)
totalPages.value = Math.max(1, Number(result?.totalPages || 0))
page.value = Number(result?.page || page.value || 1)
} catch (error) {
rows.value = []
total.value = 0
totalPages.value = 0
ElMessage.error(error instanceof Error ? error.message : String(error))
} finally {
loading.value = false
}
}
async function handleDeleteAll() {
const tkBridge = getTkBridge()
if (!tkBridge?.getAllBrotherInfo || !tkBridge?.deleteBrotherInfo) {
ElMessage.error('当前客户端未加载大哥池删除桥接,请重启客户端后再试')
return
}
showDeleteConfirm.value = true
}
async function confirmDeleteAll() {
const tkBridge = getTkBridge()
if (!tkBridge?.getAllBrotherInfo || !tkBridge?.deleteBrotherInfo) {
showDeleteConfirm.value = false
ElMessage.error('当前客户端未加载大哥池删除桥接,请重启客户端后再试')
return
}
loading.value = true
try {
const rawList = await tkBridge.getAllBrotherInfo()
const listResult = typeof rawList === 'string' ? JSON.parse(rawList) : rawList
if (listResult?.status !== 'success') {
throw new Error(listResult?.message || '读取大哥池失败')
}
const ids = (Array.isArray(listResult?.list) ? listResult.list : [])
.map(item => Number(item?.id))
.filter(id => Number.isFinite(id))
if (ids.length === 0) {
showDeleteConfirm.value = false
ElMessage.success('大哥池已经是空的')
rows.value = []
total.value = 0
totalPages.value = 1
page.value = 1
return
}
const rawDelete = await tkBridge.deleteBrotherInfo(JSON.stringify({ ids }))
const deleteResult = typeof rawDelete === 'string' ? JSON.parse(rawDelete) : rawDelete
if (deleteResult?.status !== 'success') {
throw new Error(deleteResult?.message || '删除大哥池失败')
}
ElMessage.success(`已删除 ${ids.length} 条大哥池数据`)
showDeleteConfirm.value = false
page.value = 1
await loadData()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : String(error))
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
void loadData()
}
function handleReset() {
filters.keyword = ''
filters.region = ''
filters.hostDisplayId = ''
filters.displayId = ''
filters.minLevel = undefined
filters.maxLevel = undefined
filters.minCoins = undefined
filters.maxCoins = undefined
page.value = 1
pageSize.value = 20
void loadData()
}
function handleClose() {
emit('close')
}
function goPrev() {
if (page.value <= 1) return
page.value -= 1
void loadData()
}
function goNext() {
if (page.value >= totalPages.value) return
page.value += 1
void loadData()
}
function formatNumber(value) {
if (value === null || value === undefined || value === '') return '-'
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue.toLocaleString() : String(value)
}
function formatTime(value) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) return '-'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
watch(() => props.visible, (visible) => {
if (!visible) return
void loadData()
})
watch(pageSize, () => {
if (!props.visible) return
page.value = 1
void loadData()
})
</script>

View File

@@ -70,9 +70,12 @@
<!-- 翻译开关 -->
<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 class="flex items-center gap-2 cursor-pointer"
:class="{ 'opacity-50': inputMode === 'individual' }">
<input type="checkbox" v-model="needTranslate" class="w-4 h-4"
:disabled="inputMode === 'individual'" />
<span class="text-sm text-gray-700">{{ inputMode === 'individual' ? '单个编辑模式下翻译不可用' : '启用翻译'
}}</span>
</label>
</div>
@@ -212,9 +215,20 @@ const emit = defineEmits(['close', 'confirm'])
const STORAGE_KEY = 'greeting_dialog_data'
const REGION_LIST = getRegions()
// 为不同模式分别存储内容
const sentences = ref([''])
const bulkText = ref('')
const inputMode = ref('bulk') // 'bulk' 或 'individual'
// 为两个模式分别存储内容
const modeData = ref({
bulk: {
sentences: [''],
bulkText: ''
},
individual: {
sentences: ['']
}
})
const selectedRegions = ref([])
const translations = ref({})
const activeTab = ref('')
@@ -286,18 +300,48 @@ watch(selectedLanguages, (newLangs) => {
}
})
// 当模式切换时,保存当前模式的内容并加载新模式的内容
watch(inputMode, (newMode, oldMode) => {
// 保存旧模式的内容
if (oldMode) {
if (oldMode === 'bulk') {
modeData.value.bulk.sentences = [...sentences.value]
modeData.value.bulk.bulkText = bulkText.value
} else if (oldMode === 'individual') {
modeData.value.individual.sentences = [...sentences.value]
}
}
// 加载新模式的内容
if (newMode === 'bulk') {
sentences.value = [...modeData.value.bulk.sentences]
bulkText.value = modeData.value.bulk.bulkText
} else if (newMode === 'individual') {
sentences.value = [...modeData.value.individual.sentences]
needTranslate.value = false
}
})
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.modeData) modeData.value = data.modeData
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
if (data.inputMode) inputMode.value = data.inputMode
// 加载当前模式的内容
if (inputMode.value === 'bulk') {
sentences.value = [...modeData.value.bulk.sentences]
bulkText.value = modeData.value.bulk.bulkText
} else if (inputMode.value === 'individual') {
sentences.value = [...modeData.value.individual.sentences]
}
} catch (e) {
console.error('加载本地数据失败:', e)
}
@@ -305,8 +349,16 @@ function loadFromStorage() {
}
function saveToStorage() {
// 保存当前模式的内容到 modeData
if (inputMode.value === 'bulk') {
modeData.value.bulk.sentences = [...sentences.value]
modeData.value.bulk.bulkText = bulkText.value
} else if (inputMode.value === 'individual') {
modeData.value.individual.sentences = [...sentences.value]
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({
sentences: sentences.value,
modeData: modeData.value,
selectedRegions: selectedRegions.value,
translations: translations.value,
needTranslate: needTranslate.value,

View File

@@ -68,14 +68,12 @@
<!-- 下拉菜单 -->
<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="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"
<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 }}
@@ -95,10 +93,16 @@
</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>
<div class="flex gap-2">
<button @click="selectAllLevels()"
class="text-xs text-gray-500 hover:text-gray-700">
全选
</button>
<button @click="selectNoneLevels()"
class="text-xs text-gray-500 hover:text-gray-700">
全不选
</button>
</div>
<button @click="showLevelDropdown = false"
class="text-xs text-blue-600 hover:text-blue-700">
完成
@@ -120,11 +124,10 @@
<!-- 主播列表 -->
<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.id" @click="toggleSelect(host.id)"
:class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selected.has(host.id) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
]">
<div v-for="host in filteredHosts" :key="host.id" @click="toggleSelect(host.id)" :class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selected.has(host.id) ? '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 }}
@@ -182,7 +185,8 @@
<!-- 主播ID输入 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">主播ID每行一个支持批量粘贴</label>
<textarea v-model="addForm.idsText" rows="6" placeholder="粘贴主播ID每行一个&#10;例如:&#10;anchor_001&#10;anchor_002&#10;anchor_003"
<textarea v-model="addForm.idsText" rows="6"
placeholder="粘贴主播ID每行一个&#10;例如:&#10;anchor_001&#10;anchor_002&#10;anchor_003"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none resize-none font-mono"></textarea>
<div class="text-xs text-gray-400 mt-1">
已输入 {{ parsedIds.length }} 个ID
@@ -229,7 +233,8 @@
<!-- 底部按钮 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span v-if="addStatus" :class="['text-sm', addStatus.type === 'success' ? 'text-green-600' : 'text-red-600']">
<span v-if="addStatus"
:class="['text-sm', addStatus.type === 'success' ? 'text-green-600' : 'text-red-600']">
{{ addStatus.message }}
</span>
<span v-else></span>
@@ -309,7 +314,7 @@ const filters = ref({
minOnlineFans: '',
maxOnlineFans: '',
})
const maxCount = ref(100)
const maxCount = ref()
const selectedLevels = ref(new Set())
const showLevelDropdown = ref(false)
@@ -418,11 +423,16 @@ const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI.getAutomationConfig()
if (config?.maxAnchorCount !== undefined) {
maxCount.value = config.maxAnchorCount
if (config?.filters?.maxAnchorCount !== undefined) {
// 如果后端返回 9999999前端显示为空
maxCount.value = config.filters.maxAnchorCount === 9999999 ? '' : config.filters.maxAnchorCount
}
if (config?.filters?.hostsLevelList) {
selectedLevels.value = new Set(config.filters.hostsLevelList)
// 计算反向等级列表:后端存储的是要过滤的等级,前端需要的是要显示的等级
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
const excludedLevels = config.filters.hostsLevelList
const includedLevels = allLevels.filter(level => !excludedLevels.includes(level))
selectedLevels.value = new Set(includedLevels)
}
// 加载进阶票/普票过滤配置
if (config?.filters?.gold !== undefined) {
@@ -462,10 +472,21 @@ const updateLevelFilter = async (levels) => {
selectedLevels.value = levels
if (!isElectron()) return
try {
// 计算反向等级列表:前端勾选的是要显示的,后端需要的是要过滤的(不显示的)
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
const includedLevels = Array.from(levels)
// 当全不选时,传给后端的是空数组(不过滤任何等级)
// 当有选择时,传给后端的是未选择的等级(过滤这些等级)
let excludedLevels = []
if (includedLevels.length > 0) {
excludedLevels = allLevels.filter(level => !includedLevels.includes(level))
}
await window.electronAPI.updateAutomationConfig({
filters: { hostsLevelList: Array.from(levels) }
filters: { hostsLevelList: excludedLevels }
})
console.log('[HostListDialog] 等级过滤已更新:', Array.from(levels))
console.log('[HostListDialog] 等级过滤已更新:', excludedLevels)
} catch (e) {
console.error('更新等级配置失败:', e)
}
@@ -494,12 +515,27 @@ const toggleParentLevel = (parentValue) => {
updateLevelFilter(newSet)
}
const selectAllLevels = () => {
// 全选所有等级
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
updateLevelFilter(new Set(allLevels))
}
const selectNoneLevels = () => {
// 全不选所有等级
updateLevelFilter(new Set())
}
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)
// 如果不填写,传 9999999 表示无限制
const maxAnchorCount = value || 9999999
await window.electronAPI.updateAutomationConfig({
filters: { maxAnchorCount }
})
console.log('[HostListDialog] 主播数据上限已更新:', maxAnchorCount)
} catch (e) {
console.error('更新配置失败:', e)
}

View File

@@ -1,7 +1,6 @@
<template>
<!-- info / category 的公告滚动栏显示 title -->
<div v-if="infoNotices.length > 0"
:class="['notice-bar', 'notice-bar--info']">
<div v-if="infoNotices.length > 0" :class="['notice-bar', 'notice-bar--info']">
<!-- 图标 -->
<span class="material-icons-round notice-bar__icon">campaign</span>
@@ -18,31 +17,20 @@
</span>
<!-- 关闭按钮 -->
<button v-if="closable" class="notice-bar__close" @click="handleClose"
:title="t('notice.close')">
<button v-if="closable" class="notice-bar__close" @click="handleClose" :title="t('notice.close')">
<span class="material-icons-round text-base">close</span>
</button>
</div>
<!-- danger / warning 的公告弹窗逐条显示 title + content -->
<el-dialog
v-model="dialogVisible"
:title="currentAlert?.title"
width="480px"
:close-on-click-modal="false"
align-center
>
<el-dialog v-model="dialogVisible" :title="currentAlert?.title" width="480px" align-center>
<div class="alert-notice__content" v-html="currentAlert?.content"></div>
<template #footer>
<el-button
v-if="alertIndex < alertNotices.length - 1"
@click="nextAlert"
>
<el-button v-if="alertIndex < alertNotices.length - 1" @click="nextAlert">
下一条 ({{ alertIndex + 1 }}/{{ alertNotices.length }})
</el-button>
<el-button type="primary" @click="closeAlert">
{{ alertIndex < alertNotices.length - 1 ? '全部关闭' : '我知道了' }}
</el-button>
{{ alertIndex < alertNotices.length - 1 ? '全部关闭' : '我知道了' }} </el-button>
</template>
</el-dialog>
</template>
@@ -250,6 +238,7 @@ onUnmounted(() => {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}

View File

@@ -93,7 +93,7 @@ const props = defineProps({
permissionKey: {
type: String,
required: true,
validator: (value) => ['bigBrother', 'crawl', 'webAi'].includes(value)
validator: (value) => ['bigBrother', 'crawl', 'webAi', 'autotk'].includes(value)
},
title: {
type: String,
@@ -120,11 +120,17 @@ const visibleIndices = ref([])
const visibleContacts = ref([])
const guardKey = computed(() => `guard_${props.permissionKey}_${Date.now()}`)
const effectivePermissionKey = computed(() => {
if (props.permissionKey === 'webAi' && props.title.includes('TK')) {
return 'autotk'
}
return props.permissionKey
})
const permissionsData = ref(getPermissions())
const hasAccess = computed(() => {
return permissionsData.value[props.permissionKey] === 1
return permissionsData.value[effectivePermissionKey.value] === 1
})
const pickRandomIndices = (sourceIndices, count) => {
@@ -175,6 +181,7 @@ const refreshPage = async () => {
bigBrother: res.bigBrother,
crawl: res.crawl,
webAi: res.webAi,
autotk: res.autotk ?? res.autoTK,
});
}
}

View File

@@ -307,18 +307,20 @@ const showInviteList = async () => {
// 复制主播 ID
const copyAnchorId = (id) => {
// 去除前面的 @ 符号
const cleanId = id.startsWith('@') ? id.substring(1) : id
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器使用 Clipboard API
navigator.clipboard.writeText(id).then(() => {
navigator.clipboard.writeText(cleanId).then(() => {
showCopySuccess()
}).catch(err => {
console.error('复制失败:', err)
// 回退到传统方法
fallbackCopyTextToClipboard(id)
fallbackCopyTextToClipboard(cleanId)
})
} else {
// 传统方法
fallbackCopyTextToClipboard(id)
fallbackCopyTextToClipboard(cleanId)
}
}

View File

@@ -19,11 +19,13 @@
</div>
<div class="country">{{ item.country }}</div>
<div class="stat-item">
<img class="stat-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<img class="stat-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png"
alt="" />
<span>金币: <b>{{ item.coin }}K</b></span>
</div>
<div class="stat-item">
<img class="stat-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<img class="stat-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png"
alt="" />
<span>场次: <b>{{ item.pkNumber }}</b></span>
</div>
</div>
@@ -66,35 +68,20 @@
<!-- 国家 -->
<div class="form-row">
<el-select-v2
v-model="formData.country"
:options="countryOptions"
placeholder="请选择国家"
filterable
style="width: 100%"
/>
<el-select-v2 v-model="formData.country" :options="countryOptions" placeholder="请选择国家" filterable
style="width: 100%" />
</div>
<!-- 性别 -->
<div class="form-row">
<el-select-v2
v-model="formData.gender"
:options="genderOptions"
placeholder="请选择性别"
style="width: 100%"
/>
<el-select-v2 v-model="formData.gender" :options="genderOptions" placeholder="请选择性别"
style="width: 100%" />
</div>
<!-- PK时间 -->
<div class="form-row">
<el-date-picker
v-model="formData.pkTime"
type="datetime"
placeholder="请选择PK时间"
style="width: 100%"
format="YYYY/MM/DD HH:mm"
value-format="x"
/>
<el-date-picker v-model="formData.pkTime" type="datetime" placeholder="请选择PK时间北京时间" style="width: 100%"
:formatter="formatBeijingTime" :parser="parseBeijingTime" value-format="x" />
</div>
<!-- 金币和场次 -->
@@ -136,13 +123,8 @@
<el-dialog v-model="showAnchorDialog" title="选择我的主播" width="800" align-center>
<div class="anchor-dialog-content">
<div class="anchor-list">
<div
v-for="(item, index) in anchorLibrary"
:key="index"
class="anchor-item"
:class="{ selected: selectedAnchor === item }"
@click="selectedAnchor = item"
>
<div v-for="(item, index) in anchorLibrary" :key="index" class="anchor-item"
:class="{ selected: selectedAnchor === item }" @click="selectedAnchor = item">
<img class="anchor-avatar" :src="item.headerIcon" alt="" />
<div class="anchor-info">
<div class="anchor-name">{{ item.anchorId }}</div>
@@ -167,12 +149,7 @@
<el-dialog v-model="showTopDialog" title="置顶" width="500" align-center>
<div class="top-dialog-content">
<p class="top-tip">置顶后您的PK信息将在首页优先展示可以获得更多曝光机会</p>
<el-select-v2
v-model="topDuration"
:options="topDurationOptions"
placeholder="请选择置顶时长"
style="width: 100%"
/>
<el-select-v2 v-model="topDuration" :options="topDurationOptions" placeholder="请选择置顶时长" style="width: 100%" />
<div class="dialog-btns">
<div class="reset-btn" @click="showTopDialog = false">取消</div>
<div class="confirm-btn" @click="confirmTop">确认置顶</div>
@@ -194,7 +171,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import {
getPkInfo,
releasePkInfo,
@@ -227,6 +204,36 @@ const list = ref([])
const page = ref(0)
const formatTime = TimestamptolocalTime
// 北京时间格式化函数
function formatBeijingTime(timestamp) {
// 创建一个UTC时间的Date对象
const utcDate = new Date(timestamp)
// 将UTC时间转换为北京时间UTC+8
const beijingDate = new Date(utcDate.getTime() + 8 * 60 * 60 * 1000)
const year = beijingDate.getUTCFullYear()
const month = String(beijingDate.getUTCMonth() + 1).padStart(2, '0')
const day = String(beijingDate.getUTCDate()).padStart(2, '0')
const hours = String(beijingDate.getUTCHours()).padStart(2, '0')
const minutes = String(beijingDate.getUTCMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hours}:${minutes}`
}
// 解析北京时间字符串为时间戳
function parseBeijingTime(dateString) {
const [datePart, timePart] = dateString.split(' ')
const [year, month, day] = datePart.split('/').map(Number)
const [hours, minutes] = timePart.split(':').map(Number)
// 创建一个UTC时间的Date对象将北京时间的小时减去8小时
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - 8, minutes, 0, 0))
// 返回UTC时间的时间戳毫秒
return utcDate.getTime()
}
// 表单数据
const formData = ref({
anchorName: '',
@@ -355,6 +362,7 @@ async function handleSubmit() {
}
if (formData.value.pkTime < Date.now()) {
ElMessage.error('PK时间不能早于当前时间')
console.log(formData.value.pkTime)
return
}
if (!formData.value.country) {
@@ -567,7 +575,7 @@ onMounted(() => {
}
.card-content:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
@@ -766,7 +774,7 @@ onMounted(() => {
}
.confirm-btn:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
@@ -785,7 +793,7 @@ onMounted(() => {
}
.reset-btn:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}

View File

@@ -6,6 +6,7 @@
<div class="points-text">
我的积分: <span class="points-num">{{ currentUser.points || 0 }}</span>
</div>
<button class="exchange-btn" @click="openExchangeDialog">积分兑换</button>
</div>
<div class="points-list" v-if="pointsList.length > 0">
@@ -18,14 +19,51 @@
</div>
</div>
<div class="empty-tip" v-else>您还没有积分记录</div>
<!-- 积分兑换弹窗 -->
<div class="exchange-dialog" v-if="exchangeDialogVisible">
<div class="dialog-overlay" @click="closeExchangeDialog"></div>
<div class="dialog-content">
<div class="dialog-header">
<div class="dialog-title">积分兑换</div>
<div class="close-btn" @click="closeExchangeDialog">×</div>
</div>
<div class="dialog-body">
<div class="user-info">
我的积分: <span class="points-num">{{ currentUser.points || 0 }}</span>
</div>
<div class="item-list" v-if="itemList.length > 0">
<div class="item-card" v-for="item in itemList" :key="item.id">
<div class="item-info">
<div class="item-header">
<div class="item-name">{{ item.itemName }}</div>
<div class="item-price">
<span class="price-tag">积分</span>
<span class="price-num">{{ item.itemPrice }}</span>
</div>
</div>
<div class="item-desc">{{ item.itemDesc }}</div>
<button class="exchange-btn" @click="exchangeItem(item)"
:disabled="(currentUser.points || 0) < item.itemPrice">
{{ (currentUser.points || 0) < item.itemPrice ? '积分不足' : '立即兑换' }} </button>
</div>
</div>
</div>
<div class="empty-tip" v-else-if="!loading">暂无商品</div>
<div class="loading-tip" v-else>加载中...</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getIntegralDetail } from '@/api/pk-mini'
import { getIntegralDetail, getPkItemList, buyPkItem } from '@/api/pk-mini'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
import { ElMessage } from 'element-plus'
import { getCurrent } from '@/api/account'
function getUserId(user) {
return user?.id || user?.userId || user?.uid || null
@@ -36,6 +74,66 @@ const pointsList = ref([])
const page = ref(0)
const formatTime = TimestamptolocalTime
// 弹窗状态
const exchangeDialogVisible = ref(false)
const itemList = ref([])
const loading = ref(false)
// 打开积分兑换弹窗
const openExchangeDialog = async () => {
exchangeDialogVisible.value = true
await loadItemList()
}
// 关闭积分兑换弹窗
const closeExchangeDialog = () => {
exchangeDialogVisible.value = false
}
// 加载商品列表
const loadItemList = async () => {
try {
loading.value = true
const res = await getPkItemList({})
if (res && res.length > 0) {
itemList.value = res
}
} catch (e) {
console.error('加载商品列表失败', e)
ElMessage.error('加载商品列表失败')
} finally {
loading.value = false
}
}
// 兑换商品
const exchangeItem = async (item) => {
const userPoints = currentUser.value.points || 0
if (userPoints < item.itemPrice) {
ElMessage.warning('积分不足')
return
}
try {
// 调用兑换接口
const res = await buyPkItem({ itemId: item.id })
if (res) {
ElMessage.success(`兑换 ${item.itemName} 成功`)
// 重新获取用户信息,更新积分
const userRes = await getCurrent()
if (userRes) {
currentUser.value = userRes
}
closeExchangeDialog()
} else {
ElMessage.error('兑换失败,请稍后重试')
}
} catch (e) {
console.error('兑换商品失败', e)
ElMessage.error('兑换失败,请稍后重试')
}
}
async function loadPointsList() {
const userId = getUserId(currentUser.value)
if (!userId) return
@@ -54,8 +152,22 @@ async function loadPointsList() {
}
}
onMounted(() => {
currentUser.value = getMainUserData() || {}
onMounted(async () => {
try {
// 获取最新的用户信息,包括积分
const res = await getCurrent()
if (res) {
currentUser.value = res
} else {
// 如果获取失败,使用缓存中的数据
currentUser.value = getMainUserData() || {}
}
} catch (e) {
console.error('获取用户信息失败', e)
// 出错时使用缓存中的数据
currentUser.value = getMainUserData() || {}
}
const userId = getUserId(currentUser.value)
if (userId) {
loadPointsList()
@@ -73,8 +185,27 @@ onMounted(() => {
width: 100%;
height: 70px;
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
padding: 0 20px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.exchange-btn {
padding: 8px 16px;
background: #ff6b35;
color: #fff;
border: none;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s ease;
&:hover {
background: #ff5216;
}
}
}
.points-icon {
@@ -148,4 +279,181 @@ onMounted(() => {
font-size: 18px;
color: #03aba8;
}
/* 积分兑换弹窗样式 */
.exchange-dialog {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: relative;
width: 90%;
max-width: 800px;
max-height: 80vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
.dialog-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid #eee;
.dialog-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 24px;
color: #999;
cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: #333;
}
}
}
.dialog-body {
padding: 20px;
max-height: calc(80vh - 60px);
overflow: auto;
.user-info {
font-size: 16px;
color: #666;
margin-bottom: 20px;
.points-num {
font-weight: bold;
color: #ff6b35;
margin-left: 5px;
}
}
.item-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.item-card {
background: #f9f9f9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
&:hover {
transform: translateY(-3px);
}
.item-info {
padding: 15px;
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.item-name {
font-size: 16px;
font-weight: bold;
color: #333;
flex: 1;
margin-right: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-price {
display: flex;
align-items: center;
.price-tag {
font-size: 12px;
color: #ff6b35;
background: rgba(255, 107, 53, 0.1);
padding: 3px 8px;
border-radius: 4px;
margin-right: 8px;
}
.price-num {
font-size: 18px;
font-weight: bold;
color: #ff6b35;
}
}
}
.item-desc {
font-size: 14px;
color: #666;
margin-bottom: 15px;
line-height: 1.4;
min-height: 40px;
}
.exchange-btn {
width: 100%;
height: 32px;
background: #ff6b35;
color: #fff;
border: none;
border-radius: 16px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s ease;
&:hover:not(:disabled) {
background: #ff5216;
}
&:disabled {
background: #ccc;
cursor: not-allowed;
}
}
}
}
.empty-tip,
.loading-tip {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #999;
}
}
}
}
</style>

View File

@@ -1,73 +1,71 @@
<template>
<div class="flex h-screen w-screen overflow-hidden bg-white">
<!-- Left Navigation Sidebar -->
<div ref="sidebarRef" class="flex flex-col items-center py-4 border-r z-50" style="flex: 0 0 calc(100vw * 2 / 19); min-width: 96px; max-width: 400px; background-color: #F8F9FA;">
<div ref="sidebarRef" class="flex flex-col items-center py-4 border-r z-50"
style="flex: 0 0 calc(100vw * 2 / 19); min-width: 96px; max-width: 400px; background-color: #F8F9FA;">
<div class="mb-6" style="border-bottom: 1px solid #A0AEC023; padding: 10%;">
<!-- Logo or Brand -->
<div class="" >
<div>
<img :src="yoloIcon" class="yolo-logo" />
</div>
</div>
<div class="flex-1 flex flex-col w-full px-2" style="gap: 2vh;">
<!-- TK Workbench Tab -->
<button @click="currentView = 'tk'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'tk' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'tk' ? nav11 : nav1" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">TK 工作台</span>
</button>
<!-- Hosts List Tab -->
<button @click="currentView = 'hosts'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'hosts' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'hosts' ? nav22 : nav2" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">主播列表</span>
</button>
<!-- Auto DM Workbench Tab -->
<button @click="currentView = 'auto_dm'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'auto_dm' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'auto_dm' ? nav33 : nav3" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">自动私信</span>
</button>
<!-- Fan Workbench Tab -->
<button @click="currentView = 'auto_dm_tk'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'auto_dm_tk' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'auto_dm_tk' ? nav33 : nav3" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">自动私信TK版</span>
</button>
<button @click="currentView = 'FanWorkbench'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'FanWorkbench' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'FanWorkbench' ? nav44 : nav4" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">大哥工作台</span>
</button>
<!-- PK 工作台 Tab -->
<button @click="currentView = 'pk_mini'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'pk_mini' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'pk_mini' ? nav55 : nav5" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">PK 工作台</span>
</button>
<!-- yolo商店 Tab -->
<button @click="currentView = 'shop'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200" style="height: 6vh;"
:class="currentView === 'shop' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'shop' ? nav66 : nav6" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">TK商店</span>
</button>
<button
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200 text-slate-400 hover:bg-[rgba(21,96,250,0.06)]"
style="height: 6vh;">
<span class="text-base font-medium truncate">敬请期待...</span>
</button>
</div>
<div class="mt-auto w-full px-2">
<!-- Logout -->
<button @click="$emit('logout')"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 text-slate-400 bg-white shadow shadow-blue-900/20 transition-all">
<img :src="backIcon" class="w-9 h-9 object-contain flex-shrink-0" />
@@ -76,91 +74,61 @@
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 h-full relative">
<!-- Tab 1: Auto DM Workbench (Config + Browser) - webAi 权限 -->
<div v-show="currentView === 'auto_dm'" class="absolute inset-0 z-10 h-full w-full">
<PermissionMask
permission-key="webAi"
title="自动私信工作台未开通"
description="您当前没有使用自动私信功能的权限"
:placeholder-image="placeholderWebAi"
:contacts="serviceContacts"
>
<PermissionMask permission-key="webAi" title="自动私信工作台未开通" description="您当前没有使用自动私信功能的权限"
:placeholder-image="placeholderWebAi" :contacts="serviceContacts">
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
<ConfigPage
@go-to-browser="handleGoToBrowser"
@logout="$emit('logout')"
/>
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="$emit('logout')"
@config-updated="handleConfigUpdated" />
</div>
<div v-show="autoDmMode === 'browser'" class="h-full w-full">
<YoloBrowser
v-bind="$attrs"
:nav-sidebar-width="navSidebarWidth"
@go-back="handleBackToConfig"
@stop-all="handleStopAll"
/>
<YoloBrowser v-bind="$attrs" :nav-sidebar-width="navSidebarWidth" @go-back="handleBackToConfig"
@stop-all="handleStopAll" />
</div>
</PermissionMask>
</div>
<!-- Tab 2: TK Workbench - crawl 权限 -->
<div v-show="currentView === 'tk'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden">
<PermissionMask
permission-key="crawl"
title="TK工作台未开通"
description="您当前没有使用TK工作台功能的权限"
:placeholder-image="placeholderTk"
:contacts="serviceContacts"
>
<TkWorkbenches />
<div v-show="currentView === 'auto_dm_tk'" class="absolute inset-0 z-20 h-full w-full">
<PermissionMask permission-key="autotk" title="自动私信TK版工作台未开通" description="您当前没有使用自动私信TK版功能的权限"
:placeholder-image="placeholderWebAi" :contacts="serviceContacts">
<AutoDmTkWorkbench :nav-sidebar-width="navSidebarWidth" />
</PermissionMask>
</div>
<div v-show="currentView === 'tk'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden">
<PermissionMask permission-key="crawl" title="TK工作台未开通" description="您当前没有使用TK工作台功能的权限"
:placeholder-image="placeholderTk" :contacts="serviceContacts">
<TkWorkbenches :key="tkWorkbenchKey" />
</PermissionMask>
</div>
<!-- Tab 3: Hosts List - crawl 权限 -->
<div v-show="currentView === 'hosts'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden">
<PermissionMask
permission-key="crawl"
title="主播列表未开通"
description="您当前没有使用主播列表功能的权限"
:placeholder-image="placeholderHosts"
:contacts="serviceContacts"
>
<PermissionMask permission-key="crawl" title="主播列表未开通" description="您当前没有使用主播列表功能的权限"
:placeholder-image="placeholderHosts" :contacts="serviceContacts">
<HostsList />
</PermissionMask>
</div>
<!-- Tab 4: Fan Workbench - bigBrother 权限 -->
<div v-show="currentView === 'FanWorkbench'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden">
<PermissionMask
permission-key="bigBrother"
title="大哥工作台未开通"
description="您当前没有使用大哥工作台功能的权限"
:placeholder-image="placeholderBigBrother"
:contacts="serviceContacts"
>
<PermissionMask permission-key="bigBrother" title="大哥工作台未开通" description="您当前没有使用大哥工作台功能的权限"
:placeholder-image="placeholderBigBrother" :contacts="serviceContacts">
<FanWorkbench />
</PermissionMask>
</div>
<!-- Tab 5: PK Mini 工作台 - 无需权限控制 -->
<div v-show="currentView === 'pk_mini'" class="absolute inset-0 z-20 h-full overflow-hidden">
<PkMiniWorkbench />
</div>
<!-- Tab 6: yolo商店 - Electron BrowserViewWeb iframe 兜底 -->
<div v-show="currentView === 'shop'" class="absolute inset-0 z-20 h-full overflow-hidden">
<div v-if="isElectron()" class="w-full h-full flex items-center justify-center text-base text-slate-500 bg-white">
<div v-if="isElectron()"
class="w-full h-full flex items-center justify-center text-base text-slate-500 bg-white">
正在进入商店...
</div>
<iframe
v-else-if="adminLoaded"
:src="shopUrl"
class="w-full h-full border-0"
<iframe v-else-if="adminLoaded" :src="shopUrl" class="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads"
></iframe>
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads" />
</div>
</div>
</div>
@@ -175,11 +143,11 @@ import HostsList from '@/views/tk/HostsList.vue'
import ConfigPage from '@/pages/ConfigPage.vue'
import FanWorkbench from '@/views/tk/FanWorkbench.vue'
import PkMiniWorkbench from '@/views/pk-mini/PkMiniWorkbench.vue'
import AutoDmTkWorkbench from '@/views/auto-dm/AutoDmTkWorkbench.vue'
import PermissionMask from '@/components/PermissionMask.vue'
import { ENV } from '@/config'
import { getCustomServiceInfo } from '@/api/account'
// 导航图标
import yoloIcon from '@/assets/nav/yolo.png'
import nav1 from '@/assets/nav/nav1.png'
import nav11 from '@/assets/nav/nav11.png'
@@ -195,7 +163,6 @@ import nav6 from '@/assets/nav/nav6.png'
import nav66 from '@/assets/nav/nav66.png'
import backIcon from '@/assets/nav/back.png'
// 占位图片 - 无权限时显示的工作台截图
import placeholderTk from '@/assets/placeholder-tk.png'
import placeholderHosts from '@/assets/placeholder-hosts.png'
import placeholderWebAi from '@/assets/placeholder-webai.png'
@@ -203,80 +170,88 @@ import placeholderBigBrother from '@/assets/placeholder-bigbrother.png'
const emit = defineEmits(['logout', 'go-back', 'stop-all'])
const currentView = ref('tk') // Default Tab
const autoDmMode = ref('config') // Default Sub-state: 'config' or 'browser'
const adminLoaded = ref(false) // Web iframe 懒加载(仅非 Electron
const shopOpened = ref(false) // Electron 只首开加载一次
const currentView = ref('tk')
const autoDmMode = ref('config')
const adminLoaded = ref(false)
const shopOpened = ref(false)
const shopUrl = ENV.SHOP_URL
const sidebarRef = useTemplateRef('sidebarRef')
const navSidebarWidth = ref(200) // 左侧导航菜单的实际宽度px传给 YoloBrowser/Sidebar 使用
const navSidebarWidth = ref(200)
const tkWorkbenchKey = ref(0)
const reloadTkWorkbench = () => {
tkWorkbenchKey.value++
console.log('TK 工作台已重新加载')
}
window.reloadTkWorkbench = reloadTkWorkbench
// 客服名片
const serviceContacts = ref([])
const loadServiceContacts = async () => {
try {
const res = await getCustomServiceInfo()
console.log("获取名片",res)
if (res) {
serviceContacts.value = res.map(item => ({
avatar: item.avater,
name: item.name,
desc: item.description,
qrcode: item.concat,
phone: item.phone
}))
}
} catch (e) {
console.error('获取客服名片失败:', e)
try {
const res = await getCustomServiceInfo()
if (res) {
serviceContacts.value = res.map(item => ({
avatar: item.avater,
name: item.name,
desc: item.description,
qrcode: item.concat,
phone: item.phone
}))
}
} catch (e) {
console.error('获取客服名片失败:', e)
}
}
// 监听菜单栏实际宽度,通知后端更新 BrowserView 定位
let resizeObserver = null
const notifySidebarWidth = (width) => {
navSidebarWidth.value = Math.round(width)
if (isElectron()) {
window.electronAPI.setSidebarWidth(Math.round(width)).catch(() => {})
}
navSidebarWidth.value = Math.round(width)
if (isElectron()) {
window.electronAPI.setSidebarWidth(Math.round(width)).catch(() => { })
}
}
onMounted(() => {
loadServiceContacts()
if (!isElectron()) return
resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width
if (width) notifySidebarWidth(width)
})
if (sidebarRef.value) {
resizeObserver.observe(sidebarRef.value)
// 立即上报初始宽度
notifySidebarWidth(sidebarRef.value.getBoundingClientRect().width)
}
loadServiceContacts()
if (!isElectron()) return
resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width
if (width) notifySidebarWidth(width)
})
if (sidebarRef.value) {
resizeObserver.observe(sidebarRef.value)
notifySidebarWidth(sidebarRef.value.getBoundingClientRect().width)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver?.disconnect()
})
const handleGoToBrowser = async () => {
autoDmMode.value = 'browser'
if (isElectron()) {
await window.electronAPI.showViews()
}
autoDmMode.value = 'browser'
if (isElectron()) {
await window.electronAPI.showViews()
}
}
const handleBackToConfig = async () => {
autoDmMode.value = 'config'
if (isElectron()) {
await window.electronAPI.hideViews()
}
autoDmMode.value = 'config'
if (isElectron()) {
await window.electronAPI.hideViews()
}
}
const handleStopAll = () => {
emit('stop-all')
emit('stop-all')
}
const handleConfigUpdated = () => {
window.dispatchEvent(new CustomEvent('config-updated'))
}
// Watch for view changes to manage native Electron BrowserViews
watch(currentView, async (newVal, oldVal) => {
// 懒加载 Web 端 iframe仅非 Electron
if (newVal === 'shop' && !adminLoaded.value && !isElectron()) {
adminLoaded.value = true
}
@@ -284,19 +259,11 @@ watch(currentView, async (newVal, oldVal) => {
if (!isElectron()) return
if (newVal === 'shop') {
if (!shopOpened.value) {
try {
shopOpened.value = true
try {
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
} else {
try {
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
} else if (oldVal === 'shop') {
try {
@@ -306,43 +273,39 @@ watch(currentView, async (newVal, oldVal) => {
}
}
if (newVal === 'auto_dm' && autoDmMode.value === 'browser') {
// Switching TO Auto DM tab AND we are in browser mode: Show views
const shouldShowAutoDmViews =
newVal === 'auto_dm' && autoDmMode.value === 'browser'
if (shouldShowAutoDmViews) {
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)
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()
}
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');
.yolo-logo{
.yolo-logo {
width: 70%;
}
</style>

View File

@@ -59,8 +59,7 @@
<!-- 卡片头部 -->
<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="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>
@@ -86,8 +85,7 @@
@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">
<span class="text-xs text-gray-400 group-hover/item:text-blue-400">
{{ config.accountGroups.indexOf(group) + 1 }}
</span>
</button>
@@ -152,15 +150,16 @@
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="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"
<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>
@@ -183,16 +182,16 @@
<!-- 轮换设置 -->
<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>
<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">
@@ -219,7 +218,7 @@
</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"
@@ -324,9 +323,10 @@
<HostListDialog :visible="showHostDialog" @close="showHostDialog = false" @save="() => { }" />
<!-- 打招呼内容弹窗 -->
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
<!-- 预热 Loading 遮罩 -->
<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">
@@ -363,7 +363,7 @@ import GreetingDialog from '../components/GreetingDialog.vue'
import AIConfigDialog from '../components/AIConfigDialog.vue'
import { isElectron } from '../utils/electronBridge'
const emit = defineEmits(['goToBrowser', 'logout'])
const emit = defineEmits(['goToBrowser', 'logout', 'configUpdated'])
const CONFIG_KEY = 'autoDm_runConfig'
@@ -383,7 +383,9 @@ const defaultConfig = {
switchMinutes: 60,
prologueList: {},
needTranslate: false,
maxAnchorCount: 100,
filters: {
maxAnchorCount: 99999
},
lang: 'en'
}
@@ -702,7 +704,9 @@ const handleStart = async (specificGroupIndex) => {
inviteThreshold: config.value.inviteThreshold,
prologueList,
needTranslate: config.value.needTranslate, // 添加翻译开关配置
maxAnchorCount: config.value.maxAnchorCount,
filters: {
maxAnchorCount: config.value.filters?.maxAnchorCount !== undefined ? config.value.filters.maxAnchorCount : 100
},
rotationEnabled: config.value.rotateEnabled,
rotationIntervalMinutes: config.value.switchMinutes,
currentActiveGroup: activeGroupName,
@@ -726,8 +730,8 @@ const handleStart = async (specificGroupIndex) => {
const cleanAccount = JSON.parse(JSON.stringify(acc))
startTasks.push({
viewId: currentViewId,
account: {
...cleanAccount,
account: {
...cleanAccount,
group: group.name,
lang: config.value.lang || 'en' // 传递语言配置
},
@@ -738,7 +742,7 @@ const handleStart = async (specificGroupIndex) => {
}
}
// 预热所有视图,确保后台视图完成渲染,解决自动化执行失败问题
// 预热所有视图,确保后台视图完成渲染,解决自动化执行失败问题
warmingUp.value = true
try {
console.log('[ConfigPage] 预热所有视图...')
@@ -789,6 +793,8 @@ const handleStart = async (specificGroupIndex) => {
rotationStatus.value = status
handleStatusChange(status)
warmingUp.value = false //关闭遮罩
// 触发自定义事件通知配置已更新
emit('configUpdated')
emit('goToBrowser')
}

View File

@@ -403,6 +403,7 @@ const handleSubmit = async () => {
bigBrother: result.user.bigBrother,
crawl: result.user.crawl,
webAi: result.user.webAi,
autotk: result.user.autoTk
});
emit('loginSuccess')

View File

@@ -77,6 +77,29 @@ export interface GreetingStats {
details: ViewStats[]
}
export interface StandaloneTikTokAutomationOptions {
greetingMessages?: string[]
prologueList?: Record<string, string[]>
needTranslate?: boolean
replyMessages?: string[]
replyUnreadMessages?: boolean
dataPoolSource?: 'anchor_hosts' | 'brother_info' | '/api/anchor/hosts/getAll' | '/api/gifters/brotherInfo/getAll'
groupSwitchMinutes?: number
groupViewCounts?: number[]
continuousMode?: boolean
runMode?: string
homeUrl?: string
waitForManualLogin?: boolean
searchKeyword?: string
}
export interface ViewProxyConfig {
mode: 'fixed_servers' | string
proxyRules: string
username?: string
password?: string
}
export interface ElectronAPI {
// 基础视图控制
hideViews: () => Promise<{ success: boolean }>
@@ -98,6 +121,17 @@ export interface ElectronAPI {
// TikTok 自动化
startTikTokAutomation: (viewId: number, account: Account) => Promise<{ success: boolean; message?: string; error?: string }>
stopTikTokAutomation: (viewId: number) => Promise<{ success: boolean; message?: string; error?: string }>
startStandaloneTikTokAutomationAll: (
options: StandaloneTikTokAutomationOptions
) => Promise<{ success: boolean; error?: string }>
stopStandaloneTikTokAutomationAll: () => Promise<{ success: boolean; error?: string }>
getViewProxy: (viewId: number) => Promise<{ success: boolean; proxyConfig?: ViewProxyConfig | null; error?: string }>
setViewProxy: (
viewId: number,
proxyConfig: ViewProxyConfig,
reloadCurrentView?: boolean
) => Promise<{ success: boolean; warning?: string; error?: string }>
clearViewProxy: (viewId: number, reloadCurrentView?: boolean) => Promise<{ success: boolean; error?: string }>
updateAutomationConfig: (config: Partial<AutomationConfig>) => Promise<{ success: boolean }>
getAutomationConfig: () => Promise<AutomationConfig>
@@ -145,6 +179,8 @@ export interface ElectronAPI {
onRotationStatusChanged: (callback: (status: RotationStatus) => void) => () => void
onRequestSaveConfig: (callback: () => void) => () => void
onRequestClearLogin: (callback: () => void) => () => void
onAppClosingStart: (callback: () => void) => () => void
onAppClosingComplete: (callback: () => void) => () => void
onUpdateChecking: (callback: () => void) => () => void
onUpdateAvailable: (callback: (info: UpdateInfo) => void) => () => void
onUpdateNotAvailable: (callback: () => void) => () => void
@@ -153,6 +189,38 @@ export interface ElectronAPI {
onUpdateError: (callback: (error: { message: string }) => void) => () => void
onUpdateManualInstall: (callback: (info: { path: string }) => void) => () => void
onGreetingStatsChanged: (callback: (stats: GreetingStats) => void) => () => void
tk?: {
updateStartConfig: (config: string) => Promise<{ success: boolean; error?: string }>
getDataCount: () => Promise<string>
loginTikTok: () => Promise<void>
loginBigTikTok: () => Promise<void>
loginBackStage: (data: string) => Promise<void>
loginBackStageCopy: (data: string) => Promise<void>
checkBackStageLoginStatus: (account?: string) => Promise<string>
checkBackStageLoginStatusCopy: () => Promise<string>
stopCrawl: () => Promise<void>
getVersion: () => Promise<string>
checkTkLoginStatus: () => Promise<string>
visitAnchor: (id: string) => Promise<void>
exportData: (data: string) => Promise<void>
controlTask: (data: string) => Promise<{ success: boolean; message?: string; error?: string }>
getBrotherInfo: () => Promise<string>
queryBrotherInfo: (data: string) => Promise<string>
getAllBrotherInfo: () => Promise<string>
deleteBrotherInfo: (data: string) => Promise<string>
findBigBrother: (data: string) => Promise<{ success: boolean }>
storageSet: (data: string) => Promise<{ success: boolean; error?: string }>
storageRead: (data: string) => Promise<string>
openRoom: (id: string) => Promise<void>
storageAccount: (data: string) => Promise<{ success: boolean; error?: string }>
readAccount: (data: string) => Promise<string>
setClipboard: (text: string) => Promise<{ success: boolean; error?: string }>
startBrotherMonitor: () => Promise<{ success: boolean; error?: string }>
getBrotherLoginStatus: () => Promise<string>
visitGifter: (data: string) => Promise<{ success: boolean; error?: string }>
closeAllBrowsers: () => Promise<{ success: boolean; error?: string }>
}
}
// 声明全局类型

View File

@@ -4,7 +4,6 @@
*/
import axios from 'axios'
import { getToken } from '@/utils/storage'
import router from '@/router'
import { ElMessage } from 'element-plus';
import { usePythonBridge, } from '@/utils/pythonBridge'
@@ -16,6 +15,13 @@ const { stopScript } = usePythonBridge();
// 请求地址前缀
const baseURL = ENV.API_BASE_URL
function emitAuthExpired(code, message) {
window.dispatchEvent(new CustomEvent('auth-expired', {
detail: { code, message }
}))
}
// 请求拦截器
axios.interceptors.request.use((config) => {
// console.log("config", config)
@@ -43,10 +49,12 @@ axios.interceptors.response.use((response) => {
return response.data.data
} else if (response.data.code == 40400) {
stopScript();
router.push('/')
emitAuthExpired(response.data.code, response.data.message)
ElMessage.error(response.data.code + '' + response.data.message);
return Promise.reject(response.data)
} else {
ElMessage.error(response.data.code + '' + response.data.message);
return Promise.reject(response.data)
}
}, (error) => {

View File

@@ -1,4 +1,4 @@
// 国家数据 - 简化版本,直接内嵌数据
// 国家数据 - 完整补全版本
const zhCountries = {
"CN": "中国",
"US": "美国",
@@ -23,6 +23,7 @@ const zhCountries = {
"PH": "菲律宾",
"TW": "中国台湾",
"HK": "中国香港",
"MO": "中国澳门",
"AE": "阿联酋",
"SA": "沙特阿拉伯",
"TR": "土耳其",
@@ -51,7 +52,200 @@ const zhCountries = {
"IL": "以色列",
"PK": "巴基斯坦",
"BD": "孟加拉国",
"NZ": "新西兰"
"NZ": "新西兰",
"HR": "克罗地亚",
"BG": "保加利亚",
// --- 新增补全 ---
"KP": "朝鲜",
"IE": "爱尔兰",
"AG": "安提瓜和巴布达",
"BS": "巴哈马",
"BB": "巴巴多斯",
"BZ": "伯利兹",
"BW": "博茨瓦纳",
"KY": "开曼群岛",
"CX": "圣诞岛",
"CC": "科科斯群岛",
"CK": "库克群岛",
"DM": "多米尼克",
"SZ": "埃斯瓦蒂尼",
"FK": "福克兰群岛",
"FJ": "斐济",
"GM": "冈比亚",
"GH": "加纳",
"GI": "直布罗陀",
"GD": "格林纳达",
"GU": "关岛",
"GG": "根西岛",
"GY": "圭亚那",
"HM": "赫德岛和麦克唐纳群岛",
"IM": "曼岛",
"JM": "牙买加",
"JE": "泽西岛",
"KE": "肯尼亚",
"KI": "基里巴斯",
"LS": "莱索托",
"LR": "利比里亚",
"MW": "马拉维",
"MH": "马绍尔群岛",
"MU": "毛里求斯",
"FM": "密克罗尼西亚",
"MS": "蒙特塞拉特",
"NA": "纳米比亚",
"NR": "瑙鲁",
"NU": "纽埃",
"NF": "诺福克岛",
"MP": "北马里亚纳群岛",
"PW": "帕劳",
"PG": "巴布亚新几内亚",
"PN": "皮特凯恩群岛",
"SH": "圣赫勒拿",
"KN": "圣基茨和尼维斯",
"LC": "圣卢西亚",
"VC": "圣文森特和格林纳丁斯",
"SL": "塞拉利昂",
"SB": "所罗门群岛",
"GS": "南乔治亚岛和南桑威奇群岛",
"SS": "南苏丹",
"TK": "托克劳",
"TT": "特立尼达和多巴哥",
"TC": "特克斯和凯科斯群岛",
"TV": "图瓦卢",
"UG": "乌干达",
"UM": "美国本土外小岛屿",
"VG": "英属维尔京群岛",
"VI": "美属维尔京群岛",
"ZM": "赞比亚",
"ZW": "津巴布韦",
"AS": "美属萨摩亚",
"AI": "安圭拉",
"AQ": "南极洲",
"BM": "百幕大",
"IO": "英属印度洋领地",
"BJ": "贝宁",
"BF": "布基纳法索",
"BI": "布隆迪",
"CM": "喀麦隆",
"CF": "中非共和国",
"TD": "乍得",
"CD": "刚果民主共和国",
"CI": "科特迪瓦",
"DJ": "吉布提",
"GF": "法属圭亚那",
"PF": "法属波利尼西亚",
"TF": "法属南部领地",
"GA": "加蓬",
"GP": "瓜德罗普",
"GN": "几内亚",
"HT": "海地",
"LU": "卢森堡",
"MG": "马达加斯加",
"ML": "马里",
"MQ": "马提尼克",
"YT": "马约特",
"MC": "摩纳哥",
"NC": "新喀里多尼亚",
"NE": "尼日尔",
"RE": "留尼汪",
"BL": "圣巴泰勒米",
"MF": "法属圣马丁",
"PM": "圣皮埃尔和密克隆",
"SN": "塞内加尔",
"SC": "塞舌尔",
"TG": "多哥",
"WF": "瓦利斯和富图纳",
"LI": "列支敦士登",
"VA": "梵蒂冈",
"SM": "圣马力诺",
"BO": "玻利维亚",
"CR": "哥斯达黎加",
"CU": "古巴",
"DO": "多米尼加共和国",
"EC": "厄瓜多尔",
"SV": "萨尔瓦多",
"GQ": "赤道几内亚",
"GT": "危地马拉",
"HN": "洪都拉斯",
"NI": "尼加拉瓜",
"PA": "巴拿马",
"PY": "巴拉圭",
"PR": "波多黎各",
"UY": "乌拉圭",
"VE": "委内瑞拉",
"CV": "佛得角",
"GW": "几内亚比绍",
"MZ": "莫桑比克",
"ST": "圣多美和普林西比",
"AO": "安哥拉",
"BN": "文莱",
"BH": "巴林",
"KM": "科摩罗",
"IQ": "伊拉克",
"JO": "约旦",
"KW": "科威特",
"LB": "黎巴嫩",
"LY": "利比亚",
"MR": "毛里塔尼亚",
"MA": "摩洛哥",
"OM": "阿曼",
"PS": "巴勒斯坦",
"QA": "卡塔尔",
"SD": "苏丹",
"SY": "叙利亚",
"TN": "突尼斯",
"EH": "西撒哈拉",
"YE": "也门",
"DZ": "阿尔及利亚",
"MM": "缅甸",
"LK": "斯里兰卡",
"CW": "库拉索",
"SX": "荷属圣马丁",
"SR": "苏里南",
"BQ": "荷属加勒比区",
"MD": "摩尔多瓦",
"LA": "老挝",
"AL": "阿尔巴尼亚",
"AD": "安道尔",
"AM": "亚美尼亚",
"AZ": "阿塞拜疆",
"BY": "白俄罗斯",
"BT": "不丹",
"BA": "波斯尼亚和黑塞哥维那",
"KH": "柬埔寨",
"CY": "塞浦路斯",
"ER": "厄立特里亚",
"EE": "爱沙尼亚",
"ET": "埃塞俄比亚",
"GE": "格鲁吉亚",
"GL": "格陵兰",
"IS": "冰岛",
"IR": "伊朗",
"AF": "阿富汗",
"KZ": "哈萨克斯坦",
"KG": "吉尔吉斯斯坦",
"LV": "拉脱维亚",
"LT": "立陶宛",
"MV": "马尔代夫",
"MT": "马耳他",
"MN": "蒙古",
"ME": "黑山",
"RS": "塞尔维亚",
"NP": "尼泊尔",
"MK": "北马其顿",
"SJ": "斯瓦尔巴群岛和扬马延岛",
"BV": "布韦岛",
"RW": "卢旺达",
"WS": "萨摩亚",
"SK": "斯洛伐克",
"SI": "斯洛文尼亚",
"SO": "索马里",
"TJ": "塔吉克斯坦",
"TZ": "坦桑尼亚",
"TL": "东帝汶",
"TO": "汤加",
"TM": "土库曼斯坦",
"UZ": "乌兹别克斯坦",
"VU": "瓦努阿图"
}
const enCountries = {
@@ -78,6 +272,7 @@ const enCountries = {
"PH": "Philippines",
"TW": "Taiwan",
"HK": "Hong Kong",
"MO": "Macao",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
@@ -106,7 +301,200 @@ const enCountries = {
"IL": "Israel",
"PK": "Pakistan",
"BD": "Bangladesh",
"NZ": "New Zealand"
"NZ": "New Zealand",
"HR": "Croatia",
"BG": "Bulgaria",
// --- 新增补全 ---
"KP": "North Korea",
"IE": "Ireland",
"AG": "Antigua and Barbuda",
"BS": "Bahamas",
"BB": "Barbados",
"BZ": "Belize",
"BW": "Botswana",
"KY": "Cayman Islands",
"CX": "Christmas Island",
"CC": "Cocos Islands",
"CK": "Cook Islands",
"DM": "Dominica",
"SZ": "Eswatini",
"FK": "Falkland Islands",
"FJ": "Fiji",
"GM": "Gambia",
"GH": "Ghana",
"GI": "Gibraltar",
"GD": "Grenada",
"GU": "Guam",
"GG": "Guernsey",
"GY": "Guyana",
"HM": "Heard Island and McDonald Islands",
"IM": "Isle of Man",
"JM": "Jamaica",
"JE": "Jersey",
"KE": "Kenya",
"KI": "Kiribati",
"LS": "Lesotho",
"LR": "Liberia",
"MW": "Malawi",
"MH": "Marshall Islands",
"MU": "Mauritius",
"FM": "Micronesia",
"MS": "Montserrat",
"NA": "Namibia",
"NR": "Nauru",
"NU": "Niue",
"NF": "Norfolk Island",
"MP": "Northern Mariana Islands",
"PW": "Palau",
"PG": "Papua New Guinea",
"PN": "Pitcairn Islands",
"SH": "Saint Helena",
"KN": "Saint Kitts and Nevis",
"LC": "Saint Lucia",
"VC": "Saint Vincent and the Grenadines",
"SL": "Sierra Leone",
"SB": "Solomon Islands",
"GS": "South Georgia and the South Sandwich Islands",
"SS": "South Sudan",
"TK": "Tokelau",
"TT": "Trinidad and Tobago",
"TC": "Turks and Caicos Islands",
"TV": "Tuvalu",
"UG": "Uganda",
"UM": "U.S. Minor Outlying Islands",
"VG": "British Virgin Islands",
"VI": "U.S. Virgin Islands",
"ZM": "Zambia",
"ZW": "Zimbabwe",
"AS": "American Samoa",
"AI": "Anguilla",
"AQ": "Antarctica",
"BM": "Bermuda",
"IO": "British Indian Ocean Territory",
"BJ": "Benin",
"BF": "Burkina Faso",
"BI": "Burundi",
"CM": "Cameroon",
"CF": "Central African Republic",
"TD": "Chad",
"CD": "DR Congo",
"CI": "Ivory Coast",
"DJ": "Djibouti",
"GF": "French Guiana",
"PF": "French Polynesia",
"TF": "French Southern Territories",
"GA": "Gabon",
"GP": "Guadeloupe",
"GN": "Guinea",
"HT": "Haiti",
"LU": "Luxembourg",
"MG": "Madagascar",
"ML": "Mali",
"MQ": "Martinique",
"YT": "Mayotte",
"MC": "Monaco",
"NC": "New Caledonia",
"NE": "Niger",
"RE": "Réunion",
"BL": "Saint Barthélemy",
"MF": "Saint Martin",
"PM": "Saint Pierre and Miquelon",
"SN": "Senegal",
"SC": "Seychelles",
"TG": "Togo",
"WF": "Wallis and Futuna",
"LI": "Liechtenstein",
"VA": "Vatican City",
"SM": "San Marino",
"BO": "Bolivia",
"CR": "Costa Rica",
"CU": "Cuba",
"DO": "Dominican Republic",
"EC": "Ecuador",
"SV": "El Salvador",
"GQ": "Equatorial Guinea",
"GT": "Guatemala",
"HN": "Honduras",
"NI": "Nicaragua",
"PA": "Panama",
"PY": "Paraguay",
"PR": "Puerto Rico",
"UY": "Uruguay",
"VE": "Venezuela",
"CV": "Cape Verde",
"GW": "Guinea-Bissau",
"MZ": "Mozambique",
"ST": "São Tomé and Príncipe",
"AO": "Angola",
"BN": "Brunei",
"BH": "Bahrain",
"KM": "Comoros",
"IQ": "Iraq",
"JO": "Jordan",
"KW": "Kuwait",
"LB": "Lebanon",
"LY": "Libya",
"MR": "Mauritania",
"MA": "Morocco",
"OM": "Oman",
"PS": "Palestine",
"QA": "Qatar",
"SD": "Sudan",
"SY": "Syria",
"TN": "Tunisia",
"EH": "Western Sahara",
"YE": "Yemen",
"DZ": "Algeria",
"MM": "Myanmar",
"LK": "Sri Lanka",
"CW": "Curaçao",
"SX": "Sint Maarten",
"SR": "Suriname",
"BQ": "Caribbean Netherlands",
"MD": "Moldova",
"LA": "Laos",
"AL": "Albania",
"AD": "Andorra",
"AM": "Armenia",
"AZ": "Azerbaijan",
"BY": "Belarus",
"BT": "Bhutan",
"BA": "Bosnia and Herzegovina",
"KH": "Cambodia",
"CY": "Cyprus",
"ER": "Eritrea",
"EE": "Estonia",
"ET": "Ethiopia",
"GE": "Georgia",
"GL": "Greenland",
"IS": "Iceland",
"IR": "Iran",
"AF": "Afghanistan",
"KZ": "Kazakhstan",
"KG": "Kyrgyzstan",
"LV": "Latvia",
"LT": "Lithuania",
"MV": "Maldives",
"MT": "Malta",
"MN": "Mongolia",
"ME": "Montenegro",
"RS": "Serbia",
"NP": "Nepal",
"MK": "North Macedonia",
"SJ": "Svalbard and Jan Mayen",
"BV": "Bouvet Island",
"RW": "Rwanda",
"WS": "Samoa",
"SK": "Slovakia",
"SI": "Slovenia",
"SO": "Somalia",
"TJ": "Tajikistan",
"TZ": "Tanzania",
"TL": "Timor-Leste",
"TO": "Tonga",
"TM": "Turkmenistan",
"UZ": "Uzbekistan",
"VU": "Vanuatu"
}
// 创建中文名称到国家代码的映射

View File

@@ -11,3 +11,19 @@ export function TimestamptolocalTime(date) {
return `${year}/${month}/${day} ${hours}:${minutes}`
}
// 时间戳转换为北京时间,格式为 YYYY/MM/DD hh:mm
export function TimestampttoBeijingTime(date) {
if (!date || isNaN(date)) return ''
const d = new Date(date)
// 北京时间是 UTC+8
const beijingTime = new Date(d.getTime() + 8 * 60 * 60 * 1000)
const year = beijingTime.getUTCFullYear()
const month = String(beijingTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(beijingTime.getUTCDate()).padStart(2, '0')
const hours = String(beijingTime.getUTCHours()).padStart(2, '0')
const minutes = String(beijingTime.getUTCMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hours}:${minutes}`
}

View File

@@ -58,26 +58,28 @@ const PERMISSIONS_KEY = 'user_permissions';
/**
* 存储权限信息
* @param {Object} permissions - 权限对象 { bigBrother, crawl, webAi }
* @param {Object} permissions - 权限对象 { bigBrother, crawl, webAi, autotk }
*/
export function setPermissions(permissions) {
const autotkValue = permissions.autotk ?? permissions.autoTK ?? 0;
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify({
bigBrother: permissions.bigBrother ?? 0,
crawl: permissions.crawl ?? 0,
webAi: permissions.webAi ?? 0,
autotk: autotkValue,
}));
}
/**
* 获取权限信息
* @returns {Object} 权限对象 { bigBrother, crawl, webAi }
* @returns {Object} 权限对象 { bigBrother, crawl, webAi, autotk }
*/
export function getPermissions() {
try {
const permissions = JSON.parse(localStorage.getItem(PERMISSIONS_KEY));
return permissions || { bigBrother: 0, crawl: 0, webAi: 0 };
return permissions || { bigBrother: 0, crawl: 0, webAi: 0, autotk: 0 };
} catch {
return { bigBrother: 0, crawl: 0, webAi: 0 };
return { bigBrother: 0, crawl: 0, webAi: 0, autotk: 0 };
}
}

View File

@@ -109,10 +109,42 @@ onMounted(() => {
buildViewMap(props.accountGroups)
}
// 监听本地存储变化,当配置更新时重新加载
window.addEventListener('storage', handleStorageChange)
// 监听自定义配置更新事件
window.addEventListener('config-updated', handleConfigUpdate)
// Listeners specific to browser view
// ...
})
onUnmounted(() => {
// 清理监听器
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('config-updated', handleConfigUpdate)
})
// 处理本地存储变化
const handleStorageChange = (event) => {
if (event.key === CONFIG_KEY) {
loadConfig()
// 强制重新构建 viewAccountMap确保使用最新的账号信息
if (props.accountGroups) {
buildViewMap(props.accountGroups)
}
}
}
// 处理配置更新事件
const handleConfigUpdate = () => {
console.log('[YoloBrowser] 收到配置更新事件,重新加载配置')
loadConfig()
// 强制重新构建 viewAccountMap确保使用最新的账号信息
if (props.accountGroups) {
buildViewMap(props.accountGroups)
}
}
// Original loadConfig logic
const loadConfig = () => {
try {
@@ -142,7 +174,7 @@ const buildViewMap = (groups) => {
watch(() => props.accountGroups, (newVal) => {
if (newVal) buildViewMap(newVal)
})
}, { deep: true })
// Actions
const handleTabSwitch = async (tab) => {
@@ -166,6 +198,12 @@ const handleTabSwitch = async (tab) => {
const handleViewSwitch = async (viewId) => {
selectedViewId.value = viewId
// 重新加载配置,确保获取最新的账号信息
loadConfig()
// 强制重新构建 viewAccountMap确保使用最新的账号信息
if (props.accountGroups) {
buildViewMap(props.accountGroups)
}
if (isElectron()) {
await window.electronAPI.switchToView(viewId)
}

View File

@@ -0,0 +1,496 @@
<template>
<div class="h-full w-full overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200">
<div v-if="pageMode === 'config'" class="h-full overflow-auto p-6">
<div class="mx-auto max-w-5xl pb-8">
<div class="rounded-2xl bg-white p-8 shadow-xl">
<div class="mb-4 flex items-end justify-between gap-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900">自动私信工作台TK版</h1>
</div>
<div class="flex items-center gap-3">
<span :class="statusChipClass">{{ statusText }}</span>
<button @click="handlePrepareViews" :disabled="isPreparing || !isElectronEnv" class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-emerald-500 to-teal-500 px-4 py-2 text-sm text-white shadow-sm transition-all hover:from-emerald-600 hover:to-teal-600 disabled:cursor-not-allowed disabled:opacity-60">
<span>{{ isPreparing ? '预热中...' : '预热视图' }}</span>
</button>
<button @click="openBrowserView" class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue-500 to-cyan-500 px-4 py-2 text-sm text-white shadow-sm transition-all hover:from-blue-600 hover:to-cyan-600">
<span>打开浏览器视图</span>
</button>
</div>
</div>
<div class="mb-6 flex w-fit items-center gap-2 rounded-full border border-blue-200 bg-gradient-to-r from-blue-50 to-green-50 px-4 py-2 text-sm text-gray-700">
<span class="h-2 w-2 animate-pulse rounded-full bg-green-500" />
先填写配置再预热视图登录完成后再启动脚本
</div>
<div class="grid grid-cols-3 gap-6">
<div class="col-span-2 space-y-6">
<section class="rounded-xl border border-gray-200 bg-gradient-to-b from-white to-gray-50 p-6 shadow-sm">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="h-4 w-1 rounded-full bg-gradient-to-b from-blue-500 to-green-500" />
<span class="font-medium text-gray-900">运行参数</span>
</div>
<div class="grid grid-cols-3 gap-3 text-center">
<div class="min-w-[88px] rounded-lg border border-gray-200 bg-white px-3 py-2">
<div class="text-[10px] text-gray-500">话术数</div>
<div class="mt-1 text-base font-semibold text-gray-800">{{ greetingCount }}</div>
</div>
<div class="min-w-[88px] rounded-lg border border-gray-200 bg-white px-3 py-2">
<div class="text-[10px] text-gray-500">数据池</div>
<div class="mt-1 text-base font-semibold text-gray-800">{{ dataPoolLabel }}</div>
</div>
<div class="min-w-[88px] rounded-lg border border-gray-200 bg-white px-3 py-2">
<div class="text-[10px] text-gray-500">开启视图</div>
<div class="mt-1 text-base font-semibold text-gray-800">{{ totalEnabledViews }}</div>
</div>
</div>
</div>
<div class="space-y-5">
<div class="rounded-xl border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between gap-4">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700">打招呼内容</label>
</div>
<button @click="showGreetingDialog = true" :disabled="isStarting || !isElectronEnv" class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-100 disabled:cursor-not-allowed disabled:opacity-60">
打招呼内容配置
</button>
</div>
<div class="mt-3 flex items-center gap-3 text-xs text-gray-500">
<span class="rounded-full border border-gray-200 bg-gray-100 px-3 py-1">基础话术 {{ greetingCount }} </span>
<span class="rounded-full border border-gray-200 bg-gray-100 px-3 py-1">翻译 {{ configForm.needTranslate ? '已开启' : '未开启' }}</span>
<span class="rounded-full border border-gray-200 bg-gray-100 px-3 py-1">语种 {{ languageCount }}</span>
</div>
</div>
<div class="grid grid-cols-[minmax(0,1fr),220px] gap-4 items-end">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700">分组轮换间隔分钟</label>
<input v-model.number="configForm.groupSwitchMinutes" :disabled="isStarting || !isElectronEnv" type="number" min="1" step="1" class="h-11 w-full rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-800 outline-none focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60" @blur="handleSwitchMinutesBlur" />
</div>
<label class="flex h-11 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
<input v-model="configForm.replyUnreadMessages" :disabled="isStarting || !isElectronEnv" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>开启 AI 回复未读消息</span>
</label>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700">数据池来源</label>
<p class="text-xs text-gray-500">启动时只需告诉后端用主播池还是大哥池</p>
</div>
<span class="rounded-full border border-gray-200 bg-gray-100 px-3 py-1 text-xs text-gray-600">{{ dataPoolLabel }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-3 rounded-lg border px-4 py-3 text-sm transition-colors" :class="configForm.dataPoolSource === 'anchor_hosts' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-200 bg-gray-50 text-gray-700'">
<input v-model="configForm.dataPoolSource" :disabled="isStarting || !isElectronEnv" type="radio" value="anchor_hosts" class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>主播池</span>
</label>
<label class="flex items-center gap-3 rounded-lg border px-4 py-3 text-sm transition-colors" :class="configForm.dataPoolSource === 'brother_info' ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 bg-gray-50 text-gray-700'">
<input v-model="configForm.dataPoolSource" :disabled="isStarting || !isElectronEnv" type="radio" value="brother_info" class="h-4 w-4 border-gray-300 text-purple-600 focus:ring-purple-500" />
<span>大哥池</span>
</label>
</div>
<div v-if="isBrotherInfoMode" class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-gray-700">大哥随机回复话术</label>
<span class="rounded-full border border-gray-200 bg-gray-100 px-3 py-1 text-xs text-gray-600">{{ replyMessageCount }} </span>
</div>
<textarea v-model="configForm.replyMessagesText" :disabled="isStarting || !isElectronEnv" rows="5" placeholder="一行一条回复话术,启动时传给后端 replyMessages" class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-800 outline-none focus:border-purple-500 disabled:cursor-not-allowed disabled:opacity-60" />
</div>
</div>
</div>
</section>
<section class="rounded-xl border border-gray-200 bg-gradient-to-b from-white to-gray-50 p-6 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="h-4 w-1 rounded-full bg-gradient-to-b from-purple-500 to-blue-500" />
<span class="font-medium text-gray-900">视图分组</span>
</div>
<span class="text-xs text-gray-500">每组支持 0-3 个视图</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div v-for="(count, index) in configForm.groupViewCounts" :key="index" class="rounded-xl border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-sm font-medium text-gray-800"> {{ index + 1 }} </div>
<div class="mt-1 text-xs text-gray-500">{{ groupRangeLabel(index) }}</div>
</div>
<span class="rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-600">{{ groupViewText(count) }}</span>
</div>
<input v-model.number="configForm.groupViewCounts[index]" :disabled="isStarting || !isElectronEnv" type="number" min="0" max="3" step="1" class="mt-4 h-11 w-full rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-800 outline-none focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60" @blur="handleGroupCountBlur(index)" />
</div>
</div>
</section>
</div>
<div class="space-y-6">
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<div><div class="text-sm font-medium text-gray-900">AI 人设</div></div>
<span :class="['rounded-full px-3 py-1 text-xs font-medium', aiConfigured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700']">{{ aiConfigured ? '已配置' : '未配置' }}</span>
</div>
<button @click="showAIDialog = true" class="mt-4 w-full rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-100">配置 / 修改 AI 人设</button>
</section>
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="text-sm font-medium text-gray-900">执行主播库</div>
<button @click="showHostDialog = true" class="mt-4 w-full rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm font-medium text-purple-600 transition-colors hover:bg-purple-100">打开执行主播库</button>
</section>
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900">大哥池</div>
<p class="mt-1 text-xs leading-5 text-gray-500">参考 brother_info 数据池维护大哥列表并支持切换为大哥池模式启动任务</p>
</div>
<span :class="['rounded-full px-3 py-1 text-xs font-medium', isBrotherInfoMode ? 'bg-fuchsia-100 text-fuchsia-700' : 'bg-gray-100 text-gray-600']">{{ isBrotherInfoMode ? '当前使用中' : '可切换' }}</span>
</div>
<button @click="showBrotherInfoDialog = true" class="mt-4 w-full rounded-lg border border-fuchsia-200 bg-fuchsia-50 px-4 py-3 text-sm font-medium text-fuchsia-600 transition-colors hover:bg-fuchsia-100">打开大哥池</button>
</section>
<section class="rounded-xl bg-slate-950 p-5 shadow-sm">
<div class="text-sm font-medium text-slate-100">本次提交参数</div>
<pre class="mt-3 overflow-x-auto text-xs leading-6 text-slate-300">{{ payloadPreview }}</pre>
</section>
</div>
</div>
<div v-if="!isElectronEnv" class="mt-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-700">当前是 Web 环境无法调用 Electron TikTok 自动私信能力</div>
</div>
</div>
</div>
<div v-else class="flex h-full w-full bg-gradient-to-br from-gray-50 to-gray-100 animate-fadeIn">
<aside :style="sidebarStyle" class="h-full flex-shrink-0 border-r border-gray-200 bg-white shadow-sm">
<div class="m-3 mb-0 flex gap-2">
<button @click="backToConfig" class="flex-1 rounded-lg border border-gray-200 bg-gray-100 px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:bg-gray-200">返回配置</button>
<button @click="handleStop" :disabled="isStopping || !isElectronEnv" class="rounded-lg bg-red-500 px-3 py-2 text-xs text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50">{{ isStopping ? '停止中' : '停止任务' }}</button>
</div>
<div class="border-b border-gray-200 p-4">
<h1 class="bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-lg font-bold text-transparent">自动私信TK版</h1>
<p class="mt-1 text-xs text-gray-500">浏览器视图页位置与 BrowserView 完全对齐</p>
</div>
<div class="space-y-4 overflow-auto p-4">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">视图分组</div>
<div class="space-y-3">
<div v-for="group in browserViewGroups" :key="group.groupIndex" class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-800"> {{ group.groupIndex + 1 }} </span>
<span class="text-xs text-gray-500">{{ group.label }}</span>
</div>
<div class="grid grid-cols-3 gap-2">
<button v-for="view in group.views" :key="view.viewId" :disabled="!view.enabled || isSwitchingView || !isElectronEnv" @click="handleSwitchView(view.viewId)" :class="['h-10 rounded-lg border text-sm font-medium transition-all disabled:cursor-not-allowed', view.active ? 'border-blue-500 bg-blue-500 text-white shadow-sm' : view.enabled ? 'border-gray-200 bg-gray-50 text-gray-700 hover:border-blue-300 hover:bg-blue-50' : 'border-dashed border-gray-200 bg-gray-100 text-gray-400 opacity-70']">{{ view.viewId }}</button>
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">快速操作</div>
<div class="space-y-3">
<label class="flex items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-3 text-sm text-gray-700">
<input v-model="loginConfirmed" :disabled="isStarting || !isElectronEnv" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>当前视图已完成手动登录</span>
</label>
<button class="h-9 w-full rounded-lg border border-blue-600 bg-blue-600 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60" :disabled="isStarting || !isElectronEnv" @click="handleStart">{{ isStarting ? '启动中...' : '启动任务' }}</button>
<div class="text-xs leading-5 text-gray-500">先打开浏览器视图在当前视图里手动登录账号确认登录完成后再勾选并启动脚本</div>
</div>
</div>
</div>
</aside>
<main class="relative flex min-w-0 flex-1 flex-col">
<div class="flex h-12 items-center gap-2 border-b border-gray-200 bg-white px-4 shadow-sm">
<span class="mr-2 text-sm text-gray-500">视图:</span>
<button v-for="viewId in viewIds" :key="viewId" @click="handleSwitchView(viewId)" :disabled="isSwitchingView || !isElectronEnv" :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60', activeViewId === viewId ? 'bg-blue-500 text-white shadow-md' : 'border border-gray-200 bg-gray-100 text-gray-600 hover:bg-gray-200']">视图 {{ viewId }}</button>
<div class="flex-1" />
<span class="rounded border border-gray-200 bg-gray-100 px-2 py-1 text-xs text-gray-500">{{ statusText }}</span>
<span v-if="isSwitchingView" class="rounded border border-blue-200 bg-blue-50 px-2 py-1 text-xs text-blue-600">切换中...</span>
</div>
<div class="relative flex-1">
<ViewPlaceholder class="absolute inset-0" />
</div>
</main>
</div>
<AIConfigDialog :visible="showAIDialog" :config="aiConfig" @close="showAIDialog = false" @save="handleSaveAIConfig" @change="(key, value) => aiConfig[key] = value" />
<HostListDialog :visible="showHostDialog" @close="showHostDialog = false" @save="() => {}" />
<BrotherInfoDialog :visible="showBrotherInfoDialog" @close="showBrotherInfoDialog = false" />
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { isElectron } from '@/utils/electronBridge'
import ViewPlaceholder from '@/components/ViewPlaceholder.vue'
import HostListDialog from '@/components/HostListDialog.vue'
import AIConfigDialog from '@/components/AIConfigDialog.vue'
import GreetingDialog from '@/components/GreetingDialog.vue'
import BrotherInfoDialog from '@/components/BrotherInfoDialog.vue'
const props = defineProps({ navSidebarWidth: { type: Number, default: 144 } })
const TIKTOK_VIEW_IDS = Array.from({ length: 9 }, (_, index) => index + 10)
const DEFAULT_GROUP_COUNTS = [3, 3, 3]
const isElectronEnv = isElectron()
const pageMode = ref('config')
const activeViewId = ref(TIKTOK_VIEW_IDS[0])
const statusText = ref('待启动')
const showAIDialog = ref(false)
const showHostDialog = ref(false)
const showGreetingDialog = ref(false)
const showBrotherInfoDialog = ref(false)
const aiConfigured = ref(false)
const loginConfirmed = ref(false)
const preparedConfigKey = ref('')
const isPreparing = ref(false)
const isStarting = ref(false)
const isStopping = ref(false)
const isSwitchingView = ref(false)
const configForm = reactive({ prologueList: {}, needTranslate: false, dataPoolSource: 'anchor_hosts', replyMessagesText: '', replyUnreadMessages: true, groupSwitchMinutes: 10, groupViewCounts: [...DEFAULT_GROUP_COUNTS] })
const aiConfig = ref({ agentName: '', guildName: '', contactTool: '', contact: '' })
const sidebarStyle = computed(() => ({ width: `${props.navSidebarWidth}px`, minWidth: '96px', maxWidth: '400px' }))
const greetingMessages = computed(() => {
const baseMessages = Array.isArray(configForm.prologueList?.yolo) ? configForm.prologueList.yolo : []
return baseMessages.map(item => String(item || '').trim()).filter(Boolean)
})
const replyMessages = computed(() => String(configForm.replyMessagesText || '').split(/\r?\n/).map(item => item.trim()).filter(Boolean))
const greetingCount = computed(() => greetingMessages.value.length)
const replyMessageCount = computed(() => replyMessages.value.length)
const languageCount = computed(() => Object.keys(configForm.prologueList || {}).length)
const isBrotherInfoMode = computed(() => configForm.dataPoolSource === 'brother_info')
const dataPoolLabel = computed(() => (isBrotherInfoMode.value ? '大哥池' : '主播池'))
const normalizedGroupSwitchMinutes = computed(() => clampMinutes(configForm.groupSwitchMinutes))
const normalizedGroupViewCounts = computed(() => normalizeGroupViewCounts(configForm.groupViewCounts))
const browserViewGroups = computed(() => normalizedGroupViewCounts.value.map((enabledCount, groupIndex) => {
const baseViewId = TIKTOK_VIEW_IDS[groupIndex * 3]
return { groupIndex, label: `视图 ${baseViewId}-${baseViewId + 2}`, views: Array.from({ length: 3 }, (_, offset) => ({ viewId: baseViewId + offset, enabled: offset < enabledCount, active: activeViewId.value === baseViewId + offset })) }
}))
const viewIds = computed(() => browserViewGroups.value.flatMap(group => group.views.filter(view => view.enabled).map(view => view.viewId)))
const totalEnabledViews = computed(() => normalizedGroupViewCounts.value.reduce((sum, count) => sum + count, 0))
const payloadPreview = computed(() => JSON.stringify(buildStartPayload(), null, 2))
const currentPrepareKey = computed(() => JSON.stringify({ groupViewCounts: normalizedGroupViewCounts.value, groupSwitchMinutes: normalizedGroupSwitchMinutes.value, replyUnreadMessages: Boolean(configForm.replyUnreadMessages), dataPoolSource: configForm.dataPoolSource, replyMessages: replyMessages.value, prologueList: configForm.prologueList || {}, needTranslate: Boolean(configForm.needTranslate) }))
const hasPreparedViews = computed(() => preparedConfigKey.value === currentPrepareKey.value)
const statusChipClass = computed(() => statusText.value.includes('运行') ? 'rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700' : statusText.value.includes('失败') ? 'rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-medium text-rose-700' : 'rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700')
function clampMinutes(value) { const numericValue = Number(value); return !Number.isFinite(numericValue) || numericValue <= 0 ? 10 : Math.max(1, Math.round(numericValue)) }
function clampGroupCount(value) { const numericValue = Number(value); return !Number.isFinite(numericValue) ? 0 : Math.min(3, Math.max(0, Math.round(numericValue))) }
function normalizeGroupViewCounts(values) { const safeValues = Array.isArray(values) ? values.slice(0, 3) : []; while (safeValues.length < 3) safeValues.push(0); return safeValues.map(clampGroupCount) }
function buildStartPayload() {
const payload = { dataPoolSource: configForm.dataPoolSource, replyUnreadMessages: Boolean(configForm.replyUnreadMessages), groupSwitchMinutes: normalizedGroupSwitchMinutes.value, groupViewCounts: normalizedGroupViewCounts.value, prologueList: JSON.parse(JSON.stringify(configForm.prologueList || {})), needTranslate: Boolean(configForm.needTranslate) }
if (greetingMessages.value.length > 0) payload.greetingMessages = greetingMessages.value
if (replyMessages.value.length > 0) payload.replyMessages = replyMessages.value
return payload
}
function ensureElectronCapability(methodName) { if (!isElectronEnv || !window.electronAPI?.[methodName]) { ElMessage.error('当前环境不支持该功能'); return false } return true }
function invalidatePreparedState(nextStatus = '配置已变更,请重新预热视图') { preparedConfigKey.value = ''; loginConfirmed.value = false; if (!statusText.value.includes('运行')) statusText.value = nextStatus }
function handleGreetingConfirm(data) { configForm.prologueList = { yolo: data.sentences || [], ...(data.translations || {}) }; configForm.needTranslate = Boolean(data.needTranslate); showGreetingDialog.value = false; invalidatePreparedState(); void saveSharedConfig() }
function handleSwitchMinutesBlur() { configForm.groupSwitchMinutes = clampMinutes(configForm.groupSwitchMinutes); invalidatePreparedState() }
function handleGroupCountBlur(index) { configForm.groupViewCounts[index] = clampGroupCount(configForm.groupViewCounts[index]); invalidatePreparedState('视图配置已变更,请重新预热视图') }
function groupViewText(count) { return count <= 0 ? '不启用' : `开启 ${count}` }
function groupRangeLabel(index) { const startViewId = TIKTOK_VIEW_IDS[index * 3]; const endViewId = TIKTOK_VIEW_IDS[index * 3 + 2]; return `视图 ${startViewId}-${endViewId}` }
async function checkAIConfig() {
if (!isElectronEnv || !window.electronAPI?.loadAIConfig) return
try {
const saved = await window.electronAPI.loadAIConfig()
aiConfigured.value = Boolean(saved && (saved.agentName || saved.guildName || saved.contactTool || saved.contact))
if (aiConfigured.value) aiConfig.value = saved
} catch {
aiConfigured.value = false
}
}
async function handleSaveAIConfig() {
if (isElectronEnv && window.electronAPI?.saveAIConfig) {
await window.electronAPI.saveAIConfig(JSON.parse(JSON.stringify(aiConfig.value)))
}
aiConfigured.value = true
showAIDialog.value = false
}
async function loadSharedConfig() {
if (!isElectronEnv || !window.electronAPI?.loadRunConfig) return
try {
const saved = await window.electronAPI.loadRunConfig()
if (saved?.prologueList && typeof saved.prologueList === 'object') configForm.prologueList = JSON.parse(JSON.stringify(saved.prologueList))
if (typeof saved?.needTranslate === 'boolean') configForm.needTranslate = saved.needTranslate
if (saved?.standaloneTikTokDataPoolSource === 'anchor_hosts' || saved?.standaloneTikTokDataPoolSource === 'brother_info') configForm.dataPoolSource = saved.standaloneTikTokDataPoolSource
if (Array.isArray(saved?.standaloneTikTokReplyMessages)) configForm.replyMessagesText = saved.standaloneTikTokReplyMessages.join('\n')
} catch (error) {
console.error('load shared config failed:', error)
}
}
async function saveSharedConfig() {
if (!isElectronEnv || !window.electronAPI?.saveRunConfig || !window.electronAPI?.loadRunConfig) return
try {
const saved = await window.electronAPI.loadRunConfig()
const nextConfig = {
...(saved || {}),
prologueList: JSON.parse(JSON.stringify(configForm.prologueList || {})),
needTranslate: Boolean(configForm.needTranslate),
standaloneTikTokDataPoolSource: configForm.dataPoolSource,
standaloneTikTokReplyMessages: replyMessages.value
}
await window.electronAPI.saveRunConfig(nextConfig)
} catch (error) {
console.error('save shared config failed:', error)
}
}
async function handlePrepareViews() {
if (!ensureElectronCapability('prepareStandaloneTikTokViews')) return
if (viewIds.value.length === 0) {
ElMessage.warning('请至少启用一个视图后再预热')
return
}
loginConfirmed.value = false
isPreparing.value = true
try {
const result = await window.electronAPI.prepareStandaloneTikTokViews({ groupViewCounts: normalizedGroupViewCounts.value, targetViewId: activeViewId.value })
if (!result?.success) throw new Error(result?.error || 'prepare standalone tiktok views failed')
if (result.currentViewId) activeViewId.value = result.currentViewId
preparedConfigKey.value = currentPrepareKey.value
await saveSharedConfig()
pageMode.value = 'browser'
await window.electronAPI.showViews()
statusText.value = `视图已预热,请先在视图 ${activeViewId.value} 手动登录,再启动脚本`
ElMessage.success('视图预热完成,已切换到浏览器视图')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
ElMessage.error(`预热视图失败:${message}`)
} finally {
isPreparing.value = false
}
}
async function openBrowserView() {
if (!ensureElectronCapability('switchToView')) return
pageMode.value = 'browser'
try {
await window.electronAPI.switchToView(activeViewId.value)
await window.electronAPI.showViews()
if (!statusText.value.includes('运行')) {
statusText.value = hasPreparedViews.value ? `已打开预热视图 ${activeViewId.value},请手动登录后启动脚本` : '当前配置尚未预热,请先返回工作台执行预热视图'
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
ElMessage.error(`打开浏览器视图失败:${message}`)
}
}
async function backToConfig() {
loginConfirmed.value = false
pageMode.value = 'config'
if (!isElectronEnv || !window.electronAPI?.hideViews) return
try {
await window.electronAPI.hideViews()
} catch (error) {
console.error('hide views failed:', error)
}
}
async function handleSwitchView(viewId) {
if (!ensureElectronCapability('switchToView')) return
isSwitchingView.value = true
try {
await window.electronAPI.switchToView(viewId)
loginConfirmed.value = false
activeViewId.value = viewId
statusText.value = `已切换到视图 ${viewId},请先手动登录再启动脚本`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
statusText.value = '切换失败'
ElMessage.error(`切换视图失败:${message}`)
} finally {
isSwitchingView.value = false
}
}
async function handleStart() {
if (!ensureElectronCapability('startStandaloneTikTokAutomationAll')) return
if (!hasPreparedViews.value) {
ElMessage.warning('请先完成当前配置对应的视图预热,再启动脚本')
return
}
if (isBrotherInfoMode.value && replyMessages.value.length === 0) {
ElMessage.warning('选择大哥池后,请先填写大哥随机回复话术')
return
}
if (!loginConfirmed.value) {
ElMessage.warning('请先在当前视图手动登录,并勾选“当前视图已完成手动登录”后再启动脚本')
return
}
isStarting.value = true
try {
await saveSharedConfig()
await window.electronAPI.switchToView(activeViewId.value)
await window.electronAPI.showViews()
const result = await window.electronAPI.startStandaloneTikTokAutomationAll(buildStartPayload())
if (!result?.success) {
statusText.value = '启动失败'
ElMessage.error(result?.error || '启动自动私信TK版失败')
return
}
statusText.value = `运行中(视图 ${activeViewId.value}`
ElMessage.success('自动私信TK版已启动')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
statusText.value = '启动失败'
ElMessage.error(`启动失败:${message}`)
} finally {
isStarting.value = false
}
}
async function handleStop() {
if (!ensureElectronCapability('stopStandaloneTikTokAutomationAll')) return
isStopping.value = true
try {
const result = await window.electronAPI.stopStandaloneTikTokAutomationAll()
if (!result?.success) {
statusText.value = '停止失败'
ElMessage.error(result?.error || '停止自动私信TK版失败')
return
}
preparedConfigKey.value = ''
loginConfirmed.value = false
statusText.value = '已停止'
ElMessage.success('自动私信TK版已停止')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
statusText.value = '停止失败'
ElMessage.error(`停止失败:${message}`)
} finally {
isStopping.value = false
}
}
watch(currentPrepareKey, (nextKey, prevKey) => {
if (!prevKey) return
if (preparedConfigKey.value && preparedConfigKey.value !== nextKey) invalidatePreparedState()
})
watch(() => configForm.dataPoolSource, () => {
invalidatePreparedState()
void saveSharedConfig()
})
watch(() => configForm.replyMessagesText, () => {
if (!isBrotherInfoMode.value) return
invalidatePreparedState()
})
watch(viewIds, (nextViewIds) => {
if (nextViewIds.length === 0) {
activeViewId.value = TIKTOK_VIEW_IDS[0]
return
}
if (!nextViewIds.includes(activeViewId.value)) activeViewId.value = nextViewIds[0]
}, { immediate: true })
watch(pageMode, async (newVal) => {
if (!isElectronEnv) return
if (newVal === 'config' && window.electronAPI?.hideViews) await window.electronAPI.hideViews().catch(() => {})
})
onMounted(async () => {
await checkAIConfig()
await loadSharedConfig()
if (!isElectronEnv || !window.electronAPI?.hideViews) return
await window.electronAPI.hideViews().catch(() => {})
})
onUnmounted(async () => {
if (!isElectronEnv || !window.electronAPI?.hideViews) return
await window.electronAPI.hideViews().catch(() => {})
})
</script>

View File

@@ -14,21 +14,9 @@
<!-- 国家和性别选择 -->
<div class="select-box">
<el-select-v2
v-model="countryValue"
:options="countryOptions"
placeholder="国家"
filterable
clearable
class="filter-select"
/>
<el-select-v2
v-model="genderValue"
:options="genderOptions"
placeholder="性别"
clearable
class="filter-select"
/>
<el-select-v2 v-model="countryValue" :options="countryOptions" placeholder="国家" filterable clearable
class="filter-select" />
<el-select-v2 v-model="genderValue" :options="genderOptions" placeholder="性别" clearable class="filter-select" />
</div>
<!-- 金币数量 -->
@@ -46,15 +34,8 @@
<!-- 时间选择 (仅PK大厅模式) -->
<div class="time-box" :class="{ 'is-hidden': !isHallMode }">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator=""
start-placeholder="最小PK时间"
end-placeholder="最大PK时间"
format="YYYY/MM/DD HH:mm"
value-format="x"
/>
<el-date-picker v-model="timeRange" type="datetimerange" range-separator="至" start-placeholder="最小PK时间"
end-placeholder="最大PK时间" format="YYYY/MM/DD HH:mm" value-format="x" />
</div>
<!-- 搜索和重置按钮 -->
@@ -74,19 +55,10 @@
<div class="content-area">
<!-- 列表面板 -->
<div class="list-panel">
<div
v-infinite-scroll="loadMore"
:infinite-scroll-distance="100"
:infinite-scroll-disabled="loading || noMore"
class="pk-list"
>
<div
v-for="(item, index) in pkList"
:key="index"
class="pk-card"
:class="{ selected: selectedItem === item }"
@click="handleItemClick(item)"
>
<div v-infinite-scroll="loadMore" :infinite-scroll-distance="50" :infinite-scroll-disabled="loading || noMore"
:infinite-scroll-immediate="true" class="pk-list">
<div v-for="item in pkList" :key="item.id || item.anchorId" class="pk-card"
:class="{ selected: selectedItem === item }" @click="handleItemClick(item)">
<!-- 头像 -->
<div class="pk-avatar">
<img :src="item.anchorIcon" alt="" />
@@ -95,18 +67,20 @@
<!-- 个人信息 -->
<div class="pk-personal">
<span class="pk-name">{{ item.disPlayId }}</span>
<span class="pk-gender" :class="item.sex === 1 ? 'male' : 'female'">
{{ item.sex === 1 ? '男' : '女' }}
<span class="pk-gender" :class="item.sex === '1' ? 'male' : 'female'">
{{ item.sex === '1' ? '男' : '女' }}
</span>
<span class="pk-country">{{ item.country }}</span>
</div>
<!-- 时间 -->
<div class="pk-time">PK时间本地时间: {{ formatTime(item.pkTime * 1000) }}</div>
<div class="pk-time">PK时间本地时间: {{ formatTime(item.pkTime * 1000) }} · 北京时间: {{
TimestampttoBeijingTime(item.pkTime * 1000) }}</div>
<!-- PK信息 -->
<div class="pk-stats">
<img class="stat-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<span>金币: {{ item.coin }}K</span>
<img class="stat-icon session-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<img class="stat-icon session-icon"
src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<span>场次: {{ item.pkNumber }}</span>
</div>
<!-- 备注 -->
@@ -115,6 +89,14 @@
</div>
<div v-if="pkList.length === 0" class="empty-tip">暂无数据</div>
<!-- 加载状态提示 -->
<div v-if="loading" class="loading-tip">
<span class="loading-spinner"></span>
<span>加载中...</span>
</div>
<div v-else-if="noMore && pkList.length > 0" class="no-more-tip">
已加载全部数据
</div>
</div>
</div>
@@ -125,13 +107,10 @@
<span class="chat-header-label">聊天</span>
<span v-if="chatUserInfo.nickName" class="chat-header-name">· {{ chatUserInfo.nickName }}</span>
</div>
<div class="chat-messages" ref="chatMessagesRef" :style="{ visibility: isScrollReady ? 'visible' : 'hidden' }">
<div
v-for="(msg, index) in messagesList"
:key="index"
class="message-item"
:class="{ mine: msg.senderId == currentUser.id, 'pk-message': msg.type === 'pk' }"
>
<div class="chat-messages" ref="chatMessagesRef"
:style="{ visibility: isScrollReady ? 'visible' : 'hidden' }">
<div v-for="(msg, index) in messagesList" :key="index" class="message-item"
:class="{ mine: msg.senderId == currentUser.id, 'pk-message': msg.type === 'pk' }">
<div class="message-triangle" v-if="msg.type === 'text'"></div>
<div class="message-content">
<div v-if="msg.type === 'text'" class="text-message">{{ msg.payload.text }}</div>
@@ -155,11 +134,7 @@
<div class="send-btn" @click="sendMessage">发送</div>
</div>
<div class="input-box">
<textarea
v-model="inputText"
placeholder="输入消息..."
@keydown.enter.prevent="sendMessage"
></textarea>
<textarea v-model="inputText" placeholder="输入消息..." @keydown.enter.prevent="sendMessage"></textarea>
</div>
</div>
</div>
@@ -170,13 +145,7 @@
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<input ref="fileInputRef" type="file" accept="image/*" style="display: none" @change="handleFileSelect" />
<!-- PK邀请弹窗 -->
<el-dialog v-model="inviteDialogVisible" title="选择主播发起PK邀请" width="500" align-center>
@@ -185,23 +154,19 @@
暂无可用主播请先在"我的"页面添加主播
</div>
<div v-else class="anchor-list">
<div
v-for="anchor in myAnchorList"
:key="anchor.id"
class="anchor-item"
:class="{ selected: selectedAnchor?.id === anchor.id }"
@click="selectedAnchor = anchor"
>
<div v-for="anchor in myAnchorList" :key="anchor.id" class="anchor-item"
:class="{ selected: selectedAnchor?.id === anchor.id }" @click="selectedAnchor = anchor">
<img class="anchor-avatar" :src="anchor.anchorIcon" alt="" />
<div class="anchor-info">
<div class="anchor-name">{{ anchor.anchorId }}</div>
<div class="anchor-detail">
<span class="anchor-gender" :class="anchor.sex === 1 ? 'male' : 'female'">
{{ anchor.sex === 1 ? '男' : '女' }}
<span class="anchor-gender" :class="anchor.sex === '1' ? 'male' : 'female'">
{{ anchor.sex === '1' ? '男' : '女' }}
</span>
<span class="anchor-coin">{{ anchor.coin }}K</span>
</div>
<div class="anchor-time">PK时间: {{ formatTime(anchor.pkTime * 1000) }}</div>
<div class="anchor-time">PK时间本地时间: {{ formatTime(anchor.pkTime * 1000) }} · 北京时间: {{
TimestampttoBeijingTime(anchor.pkTime * 1000) }}</div>
</div>
</div>
</div>
@@ -222,7 +187,7 @@
import { ref, onMounted, onUnmounted, onActivated, nextTick } from 'vue'
import { getPkList, getUserInfo, getAnchorListById, createPkRecord } from '@/api/pk-mini'
import { getCountryNamesArray } from '@/utils/pk-mini/countryUtil'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
import { TimestamptolocalTime, TimestampttoBeijingTime } from '@/utils/pk-mini/timeConversion'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { isGoEasyEnabled } from '@/config/pk-mini'
import {
@@ -292,11 +257,20 @@ const formatTime = TimestamptolocalTime
function switchMode() {
isHallMode.value = !isHallMode.value
selectedItem.value = null
// 重置分页和加载状态
page.value = 0
loading.value = false
noMore.value = false
// 清空当前模式的数据
if (isHallMode.value) {
hallList.value = []
pkList.value = hallList.value
} else {
todayList.value = []
pkList.value = todayList.value
}
// 重新加载数据
loadPkList()
}
// 搜索
@@ -324,6 +298,7 @@ function handleReset() {
// 加载更多
function loadMore() {
console.log('[PkHall] loadMore 被调用')
loadPkList()
}
@@ -384,9 +359,27 @@ async function loadPkList() {
}
} catch (e) {
console.error('加载 PK 列表失败', e)
noMore.value = true
// ElMessage.error('加载失败,请稍后重试')
} finally {
loading.value = false
// 数据加载完成后,使用 nextTick 强制重新计算滚动位置
nextTick(() => {
const pkListEl = document.querySelector('.pk-list')
if (pkListEl) {
// 触发一次滚动事件,确保无限滚动指令能够正确检测滚动位置
pkListEl.dispatchEvent(new Event('scroll'))
// 手动检查是否已经滚动到底部,如果是,则直接调用 loadMore
const { scrollTop, scrollHeight, clientHeight } = pkListEl
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10
if (isAtBottom && !loading.value && !noMore.value) {
console.log('[PkHall] 检测到已滚动到底部,手动调用 loadMore')
setTimeout(() => {
loadMore()
}, 100)
}
}
})
}
}
@@ -405,7 +398,7 @@ async function handleItemClick(item) {
// 隐藏后滚到底部再显示,避免视觉跳动
scrollToBottomHidden()
// 标记消息已读
goEasyMessageRead({ id: String(item.senderId) }).catch(() => {})
goEasyMessageRead({ id: String(item.senderId) }).catch(() => { })
} else {
messagesList.value = []
ElMessage.warning('聊天功能暂时不可用GoEasy 订阅未续费)')
@@ -463,14 +456,14 @@ async function sendMessage() {
scrollToBottom()
// 发送消息后标记已读,清除导航栏红点
const senderId = String(selectedItem.value.senderId)
goEasyMessageRead({ id: senderId }).catch(() => {})
goEasyMessageRead({ id: senderId }).catch(() => { })
unreadStore.decrease(1)
} catch (e) {
console.error('发送消息失败', e)
if(e =='Error: id can not be the same as your id'){
if (e == 'Error: id can not be the same as your id') {
ElMessage.error('不能给自己发消息')
}else{
ElMessage.error('发送失败')
} else {
ElMessage.error('发送失败')
}
}
}
@@ -589,7 +582,7 @@ async function confirmInvite() {
}
}
onMounted(() => {
onMounted(async () => {
countryOptions.value = getCountryNamesArray()
currentUser.value = getMainUserData() || {}
const userId = getUserId(currentUser.value)
@@ -599,7 +592,27 @@ onMounted(() => {
// 初始加载 PK 大厅数据(通过 loadPkList 统一管理 page/loading/noMore 状态)
if (userId) {
loadPkList()
await loadPkList()
// 延迟一段时间,确保无限滚动指令已经完全初始化
setTimeout(() => {
const pkListEl = document.querySelector('.pk-list')
if (pkListEl) {
const { scrollTop, scrollHeight, clientHeight } = pkListEl
console.log('[PkHall] pk-list 元素信息:', {
scrollTop,
scrollHeight,
clientHeight,
isAtBottom: scrollTop + clientHeight >= scrollHeight - 10
})
// 手动滚动到底部,然后触发滚动事件
pkListEl.scrollTop = scrollHeight - clientHeight
setTimeout(() => {
pkListEl.dispatchEvent(new Event('scroll'))
console.log('[PkHall] 手动滚动到底部并触发滚动事件')
}, 100)
}
}, 500)
} else {
console.warn('[PkHall] 未找到用户 ID无法加载数据')
}
@@ -741,7 +754,8 @@ onUnmounted(() => {
gap: 8px;
}
.search-btn, .reset-btn {
.search-btn,
.reset-btn {
width: 80px;
height: 30px;
border-radius: 5px;
@@ -765,7 +779,8 @@ onUnmounted(() => {
color: #2563eb; // blue-600
}
.search-btn:hover, .reset-btn:hover {
.search-btn:hover,
.reset-btn:hover {
transform: scale(1.05);
opacity: 0.9;
}
@@ -901,6 +916,43 @@ onUnmounted(() => {
font-size: 16px;
}
// 加载状态提示
.loading-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px;
color: #2563eb; // blue-600
font-size: 14px;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid #e2e8f0;
border-top: 2px solid #2563eb;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.no-more-tip {
text-align: center;
padding: 20px;
color: #94a3b8; // slate-400
font-size: 14px;
}
// 聊天面板
.chat-panel {
width: 350px;
@@ -1013,7 +1065,7 @@ onUnmounted(() => {
.control-btn:hover {
background: white;
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
}
.control-btn img {
@@ -1064,8 +1116,8 @@ onUnmounted(() => {
.time-box.is-hidden {
opacity: 0;
visibility: hidden; // 仍然占位,但看不见
pointer-events: none; // 不能点击
visibility: hidden; // 仍然占位,但看不见
pointer-events: none; // 不能点击
}
// 邀请弹窗样式

View File

@@ -276,16 +276,23 @@
</div>
<div class="mt-6 text-center">
<button v-if="pyData.isStart" @click="submit"
class="bg-slate-900 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 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>
<div class="flex flex-col md:flex-row gap-4 justify-center items-center">
<button v-if="pyData.isStart" @click="submit"
class="bg-slate-900 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 transition-all flex items-center gap-2">
<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-3 rounded-xl font-bold text-lg shadow-xl shadow-red-500/20 transition-all flex items-center gap-3">
<span class="material-icons-round">stop</span>
{{ $t('workbenchesSetup.stop') }}
</button>
<button @click="resetTask"
class="bg-amber-500 hover:bg-amber-600 hover:scale-[1.02] active:scale-[0.98] text-white px-10 py-3 rounded-xl font-bold text-lg shadow-xl shadow-amber-500/20 transition-all flex items-center gap-2">
<span class="material-icons-round">refresh</span>
重置任务
</button>
</div>
<p class="mt-4 text-xs font-medium text-emerald-600">
到期时间: {{ timestampToTime(expiredTime) }}
</p>
@@ -297,7 +304,8 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { usePythonBridge, } from '@/utils/pythonBridge'
import { setNumData, getNumData, getUser, setTkUser, getTkUser } from '@/utils/storage'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -306,6 +314,24 @@ import { tkaccountuseinfo, getExpiredTime } from '@/api/account'
import { useI18n } from 'vue-i18n'
import { useCountryInfo } from '@/composables/useCountryInfo'
// 获取路由实例
const router = useRouter();
// 重启 Python 服务的方法
const restartPythonService = async () => {
return new Promise((resolve, reject) => {
if (window.electronAPI && window.electronAPI.restartPythonService) {
window.electronAPI.restartPythonService().then(result => {
resolve(result)
}).catch(err => {
reject(err)
})
} else {
reject(new Error('重启 Python 服务的方法不可用'))
}
})
}
const { t, locale } = useI18n()
// 使用独立的国家信息管理(不与其他页面共享)
const {
@@ -602,6 +628,44 @@ const toggleFilter = (filterName) => {
pyData.value[filterName] = !pyData.value[filterName];
};
// 重置任务 - 重启 Python 服务
const resetTask = async () => {
try {
ElMessageBox.confirm(
'确定要重置任务吗?这将重启服务并中断当前正在进行的获取主播和大哥的任务。',
'重置任务',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
ElMessage.info('正在重启 Python 服务...');
const result = await restartPythonService();
await restartPythonService();
if (result.success) {
ElMessage.success('Python 服务已重启,正在重新加载工作台页面...');
// 重新加载 TK 工作台页面(相当于重新进入 tk 工作台)
setTimeout(() => {
// 调用 window.reloadTkWorkbench() 方法触发 TK 工作台重新加载
if (window.reloadTkWorkbench) {
window.reloadTkWorkbench();
}
}, 1000);
} else {
ElMessage.error(`重启失败: ${result.error}`);
}
}).catch(() => {
// 用户取消
});
} catch (error) {
console.error('重置任务失败:', error);
ElMessage.error('重置任务失败,请稍后重试');
}
};
const loginTK = (index) => {
setTkUser(tkData.value)
@@ -639,6 +703,11 @@ const openTK = () => {
const checkTkLoginStatus = () => {
getTkLoginStatus().then((res) => {
isTkLoggedIn.value = res === true || res === 'true';
if (isTkLoggedIn.value) {
clearInterval(tkStatusTimer.value);
tkStatusTimer.value = null;
}
}).catch(() => {
isTkLoggedIn.value = false;
});
@@ -746,6 +815,52 @@ const formattedTime = computed(() => {
].join(':');
});
// 清理所有定时器
const clearAllTimers = () => {
// 清理获取主播数量的定时器
if (getHostTimer.value) {
clearInterval(getHostTimer.value);
getHostTimer.value = null;
}
// 清理获取查询次数的定时器
if (getNumTimer.value) {
clearInterval(getNumTimer.value);
getNumTimer.value = null;
}
// 清理 TK 状态轮询定时器
if (tkStatusTimer.value) {
clearInterval(tkStatusTimer.value);
tkStatusTimer.value = null;
}
// 清理设置状态轮询定时器
if (statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
}
// 清理设置状态轮询定时器(副本)
if (statusTimerCopy) {
clearInterval(statusTimerCopy);
statusTimerCopy = null;
}
// 清理运行时间的定时器
if (timerCrawl) {
clearInterval(timerCrawl);
timerCrawl = null;
}
console.log('所有定时器已清理');
};
// 在组件卸载时清理定时器
onUnmounted(() => {
clearAllTimers();
});
function timestampToTime(timestamp_ms) {
const date = new Date(timestamp_ms);
const year = date.getFullYear();