3 Commits
master ... old

Author SHA1 Message Date
67d1dde6d5 添加主播功能 2026-02-25 18:41:44 +08:00
8d103a91ab 关闭软件过滤条件更新 2026-02-09 16:59:19 +08:00
0dd02a13f6 线上版本更新 2026-02-05 18:50:40 +08:00
111 changed files with 1257 additions and 13557 deletions

View File

@@ -1,12 +0,0 @@
# 后端 api地址
# VITE_API_BASE_URL=https://newclient.api.yolozs.com
VITE_API_BASE_URL=http://192.168.2.22:8101
# 注册地址
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,9 +0,0 @@
# 后端 api地址
VITE_API_BASE_URL=https://newclient.api.yolozs.com
# VITE_API_BASE_URL=https://crawlclient.api.yolozs.com
# 注册地址
VITE_REGISTER_API_URL=https://backstageapi.yolozs.com
# pk api地址
VITE_PK_MINI_API_URL=https://pk.yolozs.com
# 商店地址
VITE_SHOP_URL=https://www.tkzyw.com

View File

@@ -1,70 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
yolo-web-frontend 是一个 Vue 3 Web 前端应用,主要作为 Electron 桌面应用的 UI 层运行,提供 TikTok 自动化管理功能。
## 常用命令
```bash
npm run dev # 启动开发服务器 (端口 5173)
npm run build # 生产环境构建 (输出到 dist/)
npm run preview # 预览生产构建
```
## 技术栈
- **框架**: Vue 3 + Composition API (`<script setup>`)
- **构建工具**: Vite 5
- **UI 组件库**: Element Plus
- **样式**: Tailwind CSS 3 + Less
- **状态管理**: Pinia
- **路由**: Vue Router 5 (Hash History 模式)
- **国际化**: Vue I18n (中/英文)
- **HTTP 请求**: Axios
- **实时通信**: GoEasy
## 架构说明
### 路径别名
`@` 映射到 `src/` 目录
### 目录结构
- `src/pages/` - 顶层页面组件 (LoginPage, ConfigPage, UpdateChecker)
- `src/views/` - 业务视图页面
- `src/views/tk/` - TikTok 相关主视图
- `src/views/tk-mini/` - TikTok Mini 程序视图
- `src/layout/` - 布局组件 (WorkbenchLayout, TkLayout)
- `src/components/` - 可复用组件
- `src/components/tk-mini/` - TikTok Mini 专用组件
- `src/stores/` - Pinia 状态存储
- `src/api/` - API 请求模块
- `src/utils/` - 工具函数
- `src/locales/` - 国际化翻译文件
### 应用入口流程
1. `main.js` 初始化 Vue 应用,注册 Pinia、Router、I18n、Element Plus
2. `App.vue` 作为根组件,管理页面状态 (login → config → browser)
3. 在 Electron 环境下会进行强制更新检查
### Electron 集成
应用通过 `window.electronAPI` 与 Electron 主进程通信,主要接口包括:
- 视图管理: `showViews()`, `hideViews()`
- 自动化控制: `startTikTokAutomation()`, `stopTikTokAutomation()`
- 状态同步: `getRotationStatus()`, `getGreetingStats()`
- 登录管理: `logout()`, `checkHealth()`
使用 `src/utils/electronBridge.js` 中的 `isElectron()` 判断运行环境。
### 本地存储键
- `user_data` - 用户登录信息
- `autoDm_runConfig` - 自动化运行配置
## 代码规范
- Vue 组件使用 `<script setup>` 语法
- 组件文件使用 PascalCase 命名
- 工具函数使用 camelCase 命名
- 样式优先使用 Tailwind CSS 类,复杂样式使用 Less

262
CLAUDE.md
View File

@@ -4,67 +4,229 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述
yolo-web-frontend 是一个 Vue 3 Web 前端应用,主要作为 Electron 桌面应用的 UI 层运行,提供 TikTok 自动化管理功能。
是一个基于 Vue 3 的 AI 助手 Web 前端应用Yolo可在浏览器和 Electron 环境中运行。项目主要用于自动化私信和内容管理,支持多账号管理、轮换策略和 AI 自动回复功能。
## 常用命令
## 核心技术栈
- **前端框架**: Vue 3使用 Composition API
- **构建工具**: Vite
- **样式**: Tailwind CSS
- **HTTP 请求**: Axios
- **运行环境**: 支持浏览器和 Electron 双环境
## 常用开发命令
```bash
npm run dev # 启动开发服务器 (端口 5173)
npm run build # 生产环境构建 (输出到 dist/)
npm run preview # 预览生产构建
# 安装依赖
npm install
# 本地开发(默认端口 5173
npm run dev
# 生产构建
npm run build
# 预览构建产物
npm run preview
```
## 技术栈
- **框架**: Vue 3 + Composition API (`<script setup>`)
- **构建工具**: Vite 5
- **UI 组件库**: Element Plus
- **样式**: Tailwind CSS 3 + Less
- **状态管理**: Pinia
- **路由**: Vue Router 5 (Hash History 模式)
- **国际化**: Vue I18n (中/英文)
- **HTTP 请求**: Axios
- **实时通信**: GoEasy
## 架构说明
### 路径别名
`@` 映射到 `src/` 目录
## 项目架构
### 目录结构
- `src/pages/` - 顶层页面组件 (LoginPage, ConfigPage, UpdateChecker)
- `src/views/` - 业务视图页面
- `src/views/tk/` - TikTok 相关主视图
- `src/views/tk-mini/` - TikTok Mini 程序视图
- `src/layout/` - 布局组件 (WorkbenchLayout, TkLayout)
- `src/components/` - 可复用组件
- `src/components/tk-mini/` - TikTok Mini 专用组件
- `src/stores/` - Pinia 状态存储
- `src/api/` - API 请求模块
- `src/utils/` - 工具函数
- `src/locales/` - 国际化翻译文件
### 应用入口流程
1. `main.js` 初始化 Vue 应用,注册 Pinia、Router、I18n、Element Plus
2. `App.vue` 作为根组件,管理页面状态 (login → config → browser)
3. 在 Electron 环境下会进行强制更新检查
```
src/
├── api/ # API 接口定义
├── assets/ # 静态资源(图片等)
├── components/ # Vue 组件
├── hooks/ # 可复用的组合式函数
├── layout/ # 布局组件
├── locales/ # 国际化配置
├── pages/ # 页面级组件
├── router/ # 路由配置
├── styles/ # 全局样式
├── types/ # TypeScript 类型定义
├── utils/ # 工具函数
└── views/ # 视图组件
```
### Electron 集成
应用通过 `window.electronAPI` 与 Electron 主进程通信,主要接口包括:
- 视图管理: `showViews()`, `hideViews()`
- 自动化控制: `startTikTokAutomation()`, `stopTikTokAutomation()`
- 状态同步: `getRotationStatus()`, `getGreetingStats()`
- 登录管理: `logout()`, `checkHealth()`
### 关键架构模式
使用 `src/utils/electronBridge.js` 中的 `isElectron()` 判断运行环境。
#### 双环境支持设计
### 本地存储键
- `user_data` - 用户登录信息
项目通过 `electronBridge.js` 实现 Web 和 Electron 环境的兼容:
- 使用 `isElectron()` 检测运行环境
- 通过 `window.electronAPI` 访问 Electron 功能
- 非 Electron 环境中 API 调用会安全降级
#### 页面状态管理
应用使用三个主要页面状态:
1. `login` - 登录页面
2. `config` - 配置页面账号、AI 设置、任务配置)
3. `browser` - 浏览器视图9 个视图分为 A/B/C 三组)
状态存储于 `localStorage`
- `user_data` - 用户认证信息
- `autoDm_runConfig` - 自动化运行配置
## 代码规范
#### 视图分组系统
- Vue 组件使用 `<script setup>` 语法
- 组件文件使用 PascalCase 命名
- 工具函数使用 camelCase 命名
- 样式优先使用 Tailwind CSS 类,复杂样式使用 Less
- 9 个视图被分为 3 组A/B/C每组 3 个视图
- 视图 ID 映射Tab A (1-3), Tab B (4-5), Tab C (7-9)
- 支持账号轮换和自动切换策略
### API 请求架构
#### 请求拦截器配置
- **开发环境**: `http://192.168.2.22:8101`
- **生产环境**: `https://crawlclient.api.yolozs.com`
- **认证**: 通过 `vvtoken` header登录和租户查询接口除外
- **超时时间**: 60 秒
- **错误码 40400**: 强制退出登录并停止脚本
#### 关键 API 端点
```javascript
// 认证相关
/api/tenant/get-id-by-name - ID
/api/user/doLogin -
/api/tenant/get-expired-time -
// 数据相关
/api/save_data/hosts_info -
/api/save_data/live_host_detail -
/api/common/country_info -
// 账号管理
/api/account/list -
/api/account/managerhosts -
```
### Electron 集成要点
#### 主要 IPC 通道
```javascript
// 视图控制
electronAPI.showViews() - 显示浏览器视图
electronAPI.hideViews() - 隐藏浏览器视图
electronAPI.switchTab(tabId) - 切换标签组
electronAPI.switchToView(viewId) - 切换到指定视图
// 自动化控制
electronAPI.stopTikTokAutomation(viewId) - 停止自动化任务
electronAPI.updateAutomationConfig(config) - 更新自动化配置
electronAPI.clearAllCache() - 清空所有缓存
// 状态监听
electronAPI.onRotationStatusChanged(callback) - 监听轮换状态变化
electronAPI.onGreetingStatsChanged(callback) - 监听问候统计变化
electronAPI.onAutomationLog(callback) - 监听自动化日志
// 系统功能
electronAPI.getAppVersion() - 获取应用版本
electronAPI.checkHealth() - 健康检查
```
#### 健康检查机制
每 5 秒执行一次健康检查:
- 检测到错误码 40400 时自动退出登录
- 仅在 Electron 环境且已登录状态下运行
## 样式和 UI 规范
### Tailwind 自定义配置
项目扩展了以下 Tailwind 主题:
**主色调**
- `primary-500` (#3b82f6) 用于主要操作按钮和强调元素
- 提供完整的 50-900 梯度色阶
**自定义动画**
- `fadeIn` - 淡入效果0.3s
- `slideIn` - 滑入效果0.3s,带 Y 轴位移)
### 设计原则
- 使用渐变背景增强视觉层次:`from-slate-100 to-slate-200`
- 卡片设计使用圆角和阴影:`rounded-2xl shadow-xl`
- 状态指示器使用脉动动画:`animate-pulse`
- 过渡效果统一使用 `transition-all`
## Vite 配置要点
### 路径别名
```javascript
'@' -> src/
```
### 开发服务器
- 端口5173
- 启用网络访问:`host: true`
### 构建配置
- 输出目录:`dist/`
- 禁用 sourcemap生产环境
## 重要约定
### 状态管理
不使用 Vuex/Pinia直接使用
- Composition API 的 `ref`/`reactive` 管理组件状态
- `localStorage` 持久化关键配置
- 事件监听器同步跨组件状态
### 组件通信
- 父子组件:通过 `props``emit` 事件
- 跨页面:通过 `storage` 事件监听 `localStorage` 变化
- Electron 通信:通过 `window.electronAPI` 的 IPC 通道
### 错误处理
- API 错误通过 Axios 拦截器统一处理
- 40400 错误码触发全局登出
- 使用 `try-catch` 保护可能失败的操作(带空 catch 块的是故意忽略错误)
### 代码风格
- 使用 ESLint 的 `eslint-disable-line no-empty` 标记故意为空的 catch 块
- 组件使用 `<script setup>` 语法
- 优先使用 `const` 和箭头函数
- 模板中使用 `:class` 动态绑定样式数组
## 调试和测试
### 环境检测
- 开发环境:`window.location.port === '5173'`
- Electron 环境:检查 `window.electronAPI` 是否存在
### 控制台日志规范
使用 `[组件名]` 前缀标识日志来源:
```javascript
console.log('[App] 收到轮换状态变化:', status)
console.error('[App] 健康检查失败:', error)
```
## 特殊注意事项
1. **更新检查**:应用启动时在 Electron 环境下会强制执行更新检查(`UpdateChecker` 组件)
2. **自动登出**:窗口关闭前(`beforeunload`)会自动清除 `localStorage` 中的用户数据
3. **并行停止任务**:停止所有任务时使用 `Promise.allSettled` 并行处理 9 个视图,避免串行等待
4. **配置持久化**:账号分组配置变化时通过 `storage` 事件跨组件同步
5. **视图映射**:视图 ID 与账号的映射关系通过 `viewAccountMap` 计算(每组 3 个账号)

View File

@@ -1,16 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yolo终端</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>YoloAI助手Web版</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1524
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"goeasy": "^2.14.9",
"vue": "^3.5.27"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.20",
"axios": "^1.13.4",
"element-plus": "^2.13.2",
"less": "^4.5.1",
"less-loader": "^12.3.0",
"pinia": "^3.0.4",
"postcss": "^8.4.49",
"qwebchannel": "^6.2.0",
"tailwindcss": "^3.4.17",
"vite": "^5.4.11",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.2"
"vite": "^5.4.11"
}
}

View File

@@ -4,36 +4,71 @@
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
<template v-else>
<!-- 滚动通知栏登录页和工作台都显示 -->
<NoticeBar />
<!-- 登录页面 -->
<LoginPage v-if="currentPage === 'login'" @login-success="handleLoginSuccess" class="animate-fadeIn" />
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'config'" class="animate-fadeIn" />
<template v-else>
<!-- 配置页面 - 使用 v-show 保持状态 -->
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'config'">
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="handleLogout" />
<!-- 更新通知组件启动时已在 UpdateChecker 检查此处暂不显示 -->
<!-- <UpdateNotification /> -->
<UpdateNotification />
</div>
<!-- 浏览器页面 -->
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'browser'">
<WorkbenchLayout :account-groups="accountGroups" :rotation-status="rotationStatus"
:greeting-stats="greetingStats" :automation-logs="automationLogs" @go-back="handleGoToConfig"
@stop-all="handleStopAll" @logout="handleLogout" />
<div class="flex h-full w-full bg-gradient-to-br from-gray-50 to-gray-100 animate-fadeIn"
v-show="currentPage === 'browser'">
<!-- 侧边栏 -->
<Sidebar :tabs="tabs" :current-tab="currentTab" @tab-switch="handleTabSwitch"
@go-back="handleGoToConfig" @stop-all="handleStopAll" :is-loading="isLoading"
:account-groups="accountGroups" :rotation-status="rotationStatus" :greeting-stats="greetingStats"
:automation-logs="automationLogs" />
<!-- 内容区域 -->
<main class="flex-1 flex flex-col relative">
<!-- 顶部视图切换栏 -->
<div class="h-12 bg-white border-b border-gray-200 flex items-center px-4 gap-2 shadow-sm">
<span class="text-gray-500 text-sm mr-2">视图:</span>
<button v-for="viewId in currentTabConfig.viewIds" :key="viewId" @click="handleViewSwitch(viewId)"
:class="[
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
(selectedViewId || currentTabConfig.viewIds[0]) === viewId
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'
]">
视图 {{ viewId }}
<span v-if="viewAccountMap[viewId]" class="ml-1.5 text-xs opacity-70">
({{ viewAccountMap[viewId].email.split('@')[0] }})
</span>
</button>
<div class="flex-1" />
<!-- 状态指示 -->
<span v-if="automationStatus[selectedViewId || currentTabConfig.viewIds[0]]"
class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded border border-gray-200">
{{ automationStatus[selectedViewId || currentTabConfig.viewIds[0]] }}
</span>
</div>
<!-- 单个视图显示区域 -->
<div class="flex-1 relative">
<ViewPlaceholder class="absolute inset-0" />
</div>
<div v-if="isLoading"
class="absolute inset-0 bg-slate-900/80 flex items-center justify-center z-50">
<div class="flex flex-col items-center gap-3">
<div class="w-10 h-10 border-3 border-t-primary-400 border-slate-600 rounded-full animate-spin" />
<span class="text-slate-400 text-sm">切换中...</span>
</div>
</div>
</main>
<!-- 更新通知 -->
<UpdateNotification />
</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>
@@ -42,12 +77,9 @@ import { isElectron, getAppVersion } from './utils/electronBridge'
import LoginPage from './pages/LoginPage.vue'
import ConfigPage from './pages/ConfigPage.vue'
import UpdateChecker from './pages/UpdateChecker.vue'
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
import Sidebar from './components/Sidebar.vue'
import ViewPlaceholder from './components/ViewPlaceholder.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import NoticeBar from './components/NoticeBar.vue'
import { useNoticeStore } from './stores/noticeStore'
import { ElMessage } from 'element-plus'
import { getPermissions } from './utils/storage'
// Constants
const USER_KEY = 'user_data'
@@ -56,9 +88,10 @@ const CONFIG_KEY = 'autoDm_runConfig'
// State
const updateReady = ref(false)
const currentPage = ref('login')
const currentTab = ref('A')
const isLoading = ref(false)
const isClosing = ref(false)
const automationStatus = ref({})
const selectedViewId = ref(null)
const accountGroups = ref([])
const viewAccountMap = ref({})
const rotationStatus = ref(null)
@@ -68,44 +101,31 @@ const automationLogs = ref([])
const isElectronEnv = isElectron()
const isDev = window.location.port === '5173'
// 公告通知
const noticeStore = useNoticeStore()
noticeStore.fetchNotices()
// Computed
const tabs = computed(() => [
{ id: 'A', label: accountGroups.value[0]?.name || 'Tab A', viewIds: [1, 2, 3] },
{ id: 'B', label: accountGroups.value[1]?.name || 'Tab B', viewIds: [4, 5, 6] },
{ id: 'C', label: accountGroups.value[2]?.name || 'Tab C', viewIds: [7, 8, 9] }
])
// 定期检查新通知
let noticeCheckInterval = null
const startNoticeCheck = () => {
// 每 5 分钟检查一次新通知
noticeCheckInterval = setInterval(() => {
noticeStore.fetchNotices()
}, 5 * 60 * 1000)
}
const stopNoticeCheck = () => {
if (noticeCheckInterval) {
clearInterval(noticeCheckInterval)
noticeCheckInterval = null
}
}
const currentTabConfig = computed(() => tabs.value.find(t => t.id === currentTab.value) || tabs.value[0])
// Lifecycle
onMounted(() => {
// Set Title
getAppVersion().then(version => {
document.title = `Yolo终端v${version}`
document.title = `YoloAI助手Web版v${version}`
}).catch(() => {
document.title = 'Yolo终端'
document.title = 'YoloAI助手Web版'
})
console.log('[App]', !isDev, isElectronEnv, !updateReady.value)
console.log('[App]',!isDev , isElectronEnv , !updateReady.value)
// Check Login
try {
const userData = localStorage.getItem(USER_KEY)
if (userData) {
const user = JSON.parse(userData)
if (user && user.tokenValue) {
currentPage.value = 'browser'
void syncAutotkPermissionLimit()
currentPage.value = 'config'
}
}
} catch { } // eslint-disable-line no-empty
@@ -117,25 +137,6 @@ 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
@@ -143,7 +144,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)
@@ -172,17 +173,9 @@ onMounted(() => {
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()
})
@@ -190,10 +183,6 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('config-updated', handleConfigUpdate)
window.removeEventListener('auth-expired', handleAuthExpired)
stopNoticeCheck()
console.log('[App] 已停止通知检查')
stopHealthCheck()
})
@@ -208,81 +197,17 @@ const handleStorageChange = (e) => {
}
}
const syncAutotkPermissionLimit = async () => {
if (!isElectron()) return
try {
const permissions = getPermissions()
const hasAutotkPermission = permissions?.autotk === 1
const hasWebAiPermission = permissions?.webAi === 1
if (hasAutotkPermission || hasWebAiPermission) return
await window.electronAPI.updateAutomationConfig({
filters: {
maxAnchorCount: 1
}
})
} catch (e) {
console.warn('[App] 同步 autotk 权限限制失败:', e)
}
}
const clearLoginState = () => {
localStorage.removeItem(USER_KEY)
localStorage.removeItem('user')
localStorage.removeItem('token')
}
const handleLoginSuccess = async () => {
currentPage.value = 'browser'
await syncAutotkPermissionLimit()
}
// 处理配置更新事件
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) {
await resetToLogin(result.message); return
// 隐藏所有 BrowserView 并停止自动化,防止视图悬浮在登录页上方
// try {
// await window.electronAPI.hideViews()
// await handleStopAll()
// } catch (e) {
// console.warn('[App] 清理视图失败:', e)
// }
// currentPage.value = 'login'
alert('当前账号已在其他地方登录,请重新登录')
localStorage.removeItem(USER_KEY)
currentPage.value = 'login'
}
} catch (error) {
console.error('[App] 健康检查失败:', error)
@@ -326,6 +251,31 @@ watch(currentPage, (newVal) => {
})
// Actions
const handleTabSwitch = async (tab) => {
if (tab === currentTab.value) return
if (isElectron()) {
try {
const result = await window.electronAPI.switchTab(tab)
if (result.success) {
currentTab.value = tab
selectedViewId.value = null
}
} catch (error) {
console.error('切换标签失败:', error)
}
} else {
currentTab.value = tab
selectedViewId.value = null
}
}
const handleViewSwitch = async (viewId) => {
selectedViewId.value = viewId
if (isElectron()) {
await window.electronAPI.switchToView(viewId)
}
}
const handleGoToBrowser = async () => {
if (isElectron()) {
@@ -342,19 +292,11 @@ const handleGoToConfig = async () => {
}
const handleLogout = async () => {
stopHealthCheck()
currentPage.value = 'login'
clearLoginState()
if (isElectron()) {
try { await window.electronAPI.logout() } catch (e) { console.warn('[App] logout失败:', e) }
try {
await window.electronAPI.hideViews()
await handleStopAll()
} catch (e) {
console.warn('[App] 清理视图失败:', e)
}
await window.electronAPI.logout()
}
localStorage.removeItem(USER_KEY)
currentPage.value = 'login'
}
const handleStopAll = async () => {

View File

@@ -18,14 +18,10 @@ export function getCountryinfo(data) {
export function tkaccountuseinfo(accountName) {
return getAxios({ url: `/api/common/accountCount?accountName=${accountName}` })
}
//查询主播列表
export function tkhostdata(data) {
return postAxios({ url: '/api/save_data/hosts_info', data })
}
//查询大哥列表
export function tkbigdata(data) {
return postAxios({ url: '/api/big-brother/page', data })
}
//获取到期时间
export function getExpiredTime(tenantId) {
@@ -84,12 +80,3 @@ export function liveHostDetail(data) {
export function revenueStats(hostId) {
return getAxios({ url: 'api/save_data/revenue_stats?displayId=' + hostId })
}
// 获取客服名片列表
export function getCustomServiceInfo() {
return getAxios({ url: '/api/common/custom_service_info' })
}
export function getCurrent() {
return getAxios({ url: '/api/user/current' })
}

View File

@@ -1,6 +0,0 @@
import { getAxios } from '@/utils/axios.js'
// 获取当前生效的公告列表
export function getActiveNotices() {
return getAxios({ url: '/api/common/notice' })
}

View File

@@ -1,237 +0,0 @@
/**
* PK Mini 模块 API
* 使用独立的 axios 实例,指向 pk.hanxiaokj.cn
*/
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { ENV } from '@/config'
// 创建独立的 axios 实例
const pkAxios = axios.create({
baseURL: ENV.PK_MINI_API_URL + '/',
timeout: 60000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - PK Mini 后端使用 vvtoken 请求头
pkAxios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers['vvtoken'] = token
} else {
// 兼容:尝试从 user_data 获取
const userData = JSON.parse(localStorage.getItem('user_data') || '{}')
if (userData.tokenValue) {
config.headers['vvtoken'] = userData.tokenValue
}
}
return config
}, (error) => {
return Promise.reject(error)
})
// 响应拦截器
pkAxios.interceptors.response.use((response) => {
return addPrefixToHeaderIcon(response.data)
}, (error) => {
return Promise.reject(error)
})
// 处理 headerIcon 的前缀和 country 的翻译
function addPrefixToHeaderIcon(data) {
const PREFIX = 'https://vv-1317974657.cos.ap-shanghai.myqcloud.com/headerIcon/'
if (Array.isArray(data)) {
data.forEach(item => addPrefixToHeaderIcon(item))
return data
}
if (typeof data === 'object' && data !== null) {
for (const key in data) {
if ((key === 'headerIcon' || key === 'anchorIcon') && data.hasOwnProperty(key)) {
const value = data[key]
// 如果已经是完整 URL不再添加前缀
if (typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'))) {
// 已经是完整 URL不处理
} else if (value) {
// 只有文件名,添加前缀
data[key] = PREFIX + String(value)
}
} else if (typeof data[key] === 'object' && data[key] !== null) {
addPrefixToHeaderIcon(data[key])
}
}
}
return data
}
// GET 请求
function getAxios({ url, params }) {
return new Promise((resolve, reject) => {
pkAxios.get(url, { params }).then(res => {
if (res.code == 200) {
resolve(res.data)
} else {
ElMessage.error(res.code + ' ' + res.msg)
reject(res)
}
}).catch(err => {
reject(err)
})
})
}
// POST 请求
function postAxios({ url, data }) {
return new Promise((resolve, reject) => {
pkAxios.post(url, data).then(res => {
if (res.code == 200) {
resolve(res.data)
} else {
ElMessage.error(res.code + ' ' + res.msg)
reject(res)
}
}).catch(err => {
reject(err)
})
})
}
// API 接口导出
export function getUserInfo(data) {
return postAxios({ url: 'user/getUserInfo', data })
}
export function editUserInfo(data) {
return postAxios({ url: 'user/updateUserInfo', data })
}
export function getPkList(data) {
return postAxios({ url: 'pk/pkList', data })
}
export function getNoticeList(data) {
return postAxios({ url: 'systemMessage/list', data })
}
export function getAnchorList(data) {
return postAxios({ url: 'anchor/list', data })
}
export function addAnchor(data) {
return postAxios({ url: 'anchor/add', data })
}
export function delAnchor(data) {
return postAxios({ url: 'anchor/deleteMyAnchor', data })
}
export function editAnchor(data) {
return postAxios({ url: 'anchor/updateAnchorInfo', data })
}
export function getAnchorAvatar(data) {
const url = 'https://python.yolojt.com/api/' + data.name
// 使用原生 fetch 避免全局 axios 拦截器干扰
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(result => {
if (result.code == 200) {
resolve(result.data)
} else {
reject(result)
}
})
.catch(err => {
reject(err)
})
})
}
export function getPkInfo(data) {
return postAxios({ url: 'user/queryMyAllPkData', data })
}
export function releasePkInfo(data) {
return postAxios({ url: 'pk/addPkData', data })
}
export function editPkInfo(data) {
return postAxios({ url: 'pk/updatePkInfoById', data })
}
export function delPkInfo(data) {
return postAxios({ url: 'pk/deletePkDataWithId', data })
}
export function topPkInfo(data) {
return postAxios({ url: 'user/pinToTop', data })
}
export function cancelTopPkInfo(data) {
return postAxios({ url: 'user/cancelPin', data })
}
export function getIntegralDetail(data) {
return postAxios({ url: 'user/pointsDetail', data })
}
export function getPkRecord(data) {
return postAxios({ url: 'user/handlePkInfo', data })
}
export function signIn(data) {
return postAxios({ url: 'user/signIn', data })
}
export function editEmail(data) {
return postAxios({ url: 'user/updateUserMail', data })
}
export function getOtp() {
return getAxios({ url: 'otp/getotp' })
}
export function getAnchorListById(data) {
return postAxios({ url: 'pk/listUninvitedPublishedAnchorsByUserId', data })
}
export function createPkRecord(data) {
return postAxios({ url: 'pk/createPkRecord', data })
}
export function queryPkRecord(data) {
return postAxios({ url: 'pk/singleRecord', data })
}
export function pkArticleDetail(data) {
return postAxios({ url: 'pk/pkInfoDetail', data })
}
export function updatePkRecordStatus(data) {
return postAxios({ url: 'pk/updatePkStatus', data })
}
export function queryPkDetail(data) {
return postAxios({ url: 'pk/fetchDetailPkDataWithId', data })
}
export function resendEmail(data) {
return postAxios({ url: 'user/resendMail', 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 })
}

View File

@@ -1,27 +0,0 @@
import axios from 'axios'
import { ENV } from '@/config'
// 创建独立的 axios 实例,避免被全局拦截器影响
const registerAxios = axios.create({
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
// 注册接口使用 tkNewAdmin 后端
const REGISTER_BASE_URL = ENV.REGISTER_API_URL
/**
* 租户注册
* 调用 tkNewAdmin 后端的 /admin-api/system/tenant/register 接口
*/
export function tenantRegister(data) {
return registerAxios.post(`${REGISTER_BASE_URL}/admin-api/system/tenant/register`, data)
.then(res => {
console.log('注册返回', res)
if (res.data && res.data.code === 0) {
return res.data.data
}
const msg = (res.data && res.data.msg) || (res.data && res.data.message) || '注册失败'
return Promise.reject(new Error(msg))
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 190 KiB

View File

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

@@ -14,19 +14,9 @@
<div class="flex items-center justify-between mb-3">
<span class="font-medium text-gray-800">源文本</span>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" v-model="inputMode" value="bulk" class="w-4 h-4" />
<span class="text-sm text-gray-700">批量导入</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" v-model="inputMode" value="individual" class="w-4 h-4" />
<span class="text-sm text-gray-700">单个编辑</span>
</label>
</div>
<button @click="addSentence"
class="px-3 py-1 text-sm bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200">
新增
新增一行
</button>
<button @click="fetchPrologue" :disabled="isFetching || !isElectronEnv"
class="px-3 py-1 text-sm bg-purple-100 text-purple-700 border border-purple-300 rounded hover:bg-purple-200 disabled:opacity-50">
@@ -39,43 +29,20 @@
</div>
</div>
<!-- 批量导入模式 -->
<div v-if="inputMode === 'bulk'">
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange"
@paste="handlePaste" placeholder="每行一句打招呼内容..."
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange" @paste="handlePaste"
placeholder="每行一句打招呼内容..."
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
<div class="text-xs text-gray-500 mt-2">
提示: 每行一句可直接粘贴多行文本
</div>
</div>
<!-- 单个编辑模式 -->
<div v-else class="space-y-3">
<div v-for="(sentence, index) in sentences" :key="index"
class="bg-white rounded border border-gray-200 p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">话术 {{ index + 1 }}</span>
<button @click="removeSentence(index)"
class="text-red-500 hover:text-red-700 text-sm">
删除
</button>
</div>
<textarea :value="sentence" @input="updateSentence($event.target.value, index)"
placeholder="输入打招呼内容(可包含多行)..."
class="w-full h-20 p-2 border border-gray-200 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
</div>
<div class="text-xs text-gray-500 mt-2">
提示: 每行一句可直接粘贴多行文本
</div>
</div>
<!-- 翻译开关 -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"
: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 class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="needTranslate" class="w-4 h-4" />
<span class="text-sm text-gray-700">启用翻译</span>
</label>
</div>
@@ -111,15 +78,13 @@
]">
{{ region }}
</div>
<div v-if="filteredRegions.length === 0"
class="col-span-full text-center py-8 text-gray-400">
<div v-if="filteredRegions.length === 0" class="col-span-full text-center py-8 text-gray-400">
未找到相关大区
</div>
</div>
<!-- 选中大区的语言预览 -->
<div v-if="selectedRegions.length > 0"
class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div v-if="selectedRegions.length > 0" class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div class="text-sm text-blue-800 font-medium mb-2">
选中 {{ selectedRegions.length }} 个大区将翻译以下 {{ selectedLanguages.length }} 种语言
</div>
@@ -147,8 +112,7 @@
<!-- 当前语言的翻译结果 -->
<div class="space-y-2 max-h-40 overflow-auto">
<template v-if="translations[activeTab]?.length">
<div v-for="(t, i) in translations[activeTab]" :key="i"
class="flex items-center gap-2">
<div v-for="(t, i) in translations[activeTab]" :key="i" class="flex items-center gap-2">
<input type="text" :value="t" @input="updateTranslation($event.target.value, i)"
class="flex-1 px-3 py-1.5 text-sm text-gray-900 border border-gray-300 rounded focus:border-blue-500 focus:outline-none" />
<div class="relative group/source">
@@ -177,23 +141,22 @@
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span class="text-xs text-gray-500">
{{ sentences.filter(Boolean).length }} · 选择 {{ selectedRegions.length }} 个大区 · {{
selectedLanguages.length }} 种语言
</span>
<div class="flex gap-3">
<button @click="onClose"
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleConfirm"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
确定
</button>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span class="text-xs text-gray-500">
{{ sentences.filter(Boolean).length }} · 选择 {{ selectedRegions.length }} 个大区 · {{
selectedLanguages.length }} 种语言
</span>
<div class="flex gap-3">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleConfirm"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
确定
</button>
</div>
</div>
</div>
@@ -215,20 +178,8 @@ 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('')
@@ -300,48 +251,17 @@ 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.modeData) modeData.value = data.modeData
if (data.sentences?.length) sentences.value = data.sentences
if (data.selectedRegions?.length) selectedRegions.value = data.selectedRegions
if (data.translations) translations.value = data.translations
if (typeof data.needTranslate === 'boolean') needTranslate.value = data.needTranslate
if (data.activeTab) activeTab.value = data.activeTab
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)
}
@@ -349,21 +269,12 @@ 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({
modeData: modeData.value,
sentences: sentences.value,
selectedRegions: selectedRegions.value,
translations: translations.value,
needTranslate: needTranslate.value,
activeTab: activeTab.value,
inputMode: inputMode.value,
}))
}
@@ -374,17 +285,6 @@ const addSentence = () => {
sentences.value.push('')
}
const updateSentence = (value, index) => {
sentences.value[index] = value
}
const removeSentence = (index) => {
sentences.value.splice(index, 1)
if (sentences.value.length === 0) {
sentences.value.push('')
}
}
const handlePaste = (e) => {
// e.preventDefault() handled by Vue if needed but standard logic applies
// We can rely on @paste event
@@ -497,7 +397,6 @@ const handleTranslate = async () => {
try {
const result = await window.electronAPI.translate(joinedText, lang)
if (result.success) {
console.log(`翻译结果完整 ${lang} 成功:`, result)
let translatedLines = result.result.split('\n').map(s => s.trim())
if (translatedLines.length > 0) {

View File

@@ -16,10 +16,6 @@
<!-- 工具栏 -->
<div class="p-4 border-b border-gray-100 space-y-3">
<div class="flex flex-wrap gap-2">
<button @click="showAddDialog = true"
class="px-3 py-1.5 text-sm bg-green-100 text-green-700 hover:bg-green-200 rounded border border-green-300">
+ 添加主播
</button>
<button @click="selectAll"
class="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 rounded border border-blue-300">全选</button>
<button @click="selectNone"
@@ -30,13 +26,17 @@
class="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50">
删除选中
</button>
<button v-if="user.tenantId == 13259" @click="showBatchImport = true"
class="px-3 py-1.5 text-sm bg-green-100 text-green-700 hover:bg-green-200 rounded border border-green-300">
批量导入
</button>
</div>
<!-- 筛选 -->
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.gold" class="w-4 h-4" />
<span class="text-yellow-600">进阶</span>
<span class="text-yellow-600"></span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.ordinary" class="w-4 h-4" />
@@ -68,7 +68,7 @@
<!-- 下拉菜单 -->
<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">
@@ -93,16 +93,10 @@
</div>
</div>
<div class="mt-2 pt-2 border-t border-gray-100 flex justify-between">
<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="updateLevelFilter(new Set())"
class="text-xs text-gray-500 hover:text-gray-700">
清空选择
</button>
<button @click="showLevelDropdown = false"
class="text-xs text-blue-600 hover:text-blue-700">
完成
@@ -111,23 +105,12 @@
</div>
</div>
<!-- 上下限模式 -->
<label class="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2 cursor-pointer">
<input type="checkbox" v-model="gateEnabled" class="w-4 h-4" />
<span class="text-gray-700 font-medium whitespace-nowrap">上下限模式</span>
</label>
<!-- 接收上下限 - 紧凑布局 -->
<!-- 接收上限 - 紧凑布局 -->
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2">
<span class="text-gray-700 font-medium whitespace-nowrap">接收上限</span>
<input type="number" min="0" placeholder="无限制" v-model.number="maxCount"
@change="updateMaxCount(maxCount)"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none" />
<template v-if="gateEnabled">
<span class="text-gray-700 font-medium whitespace-nowrap">主播下限</span>
<input type="number" min="0" placeholder="5" v-model.number="minCount"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none" />
</template>
</div>
</div>
</div>
@@ -135,10 +118,11 @@
<!-- 主播列表 -->
<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.anchorId" @click="toggleSelect(host.anchorId)"
:class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selected.has(host.anchorId) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
]">
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
{{ host.anchorId }}
@@ -158,7 +142,7 @@
'px-1.5 py-0.5 rounded border',
host.invitationType === 2 ? 'text-yellow-600 border-yellow-400' : 'border-gray-300'
]">
{{ host.invitationType === 2 ? '进阶票' : '普票' }}
{{ host.invitationType === 2 ? '票' : '普票' }}
</span>
</div>
</div>
@@ -182,83 +166,63 @@
</div>
</div>
</div>
</Teleport>
<!-- 添加主播弹窗 -->
<div v-if="showAddDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center"
<!-- 批量导入弹窗 -->
<Teleport to="body">
<div v-if="showBatchImport" class="fixed inset-0 bg-black/50 flex items-center justify-center"
style="z-index: 10000">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg flex flex-col mx-4">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-600">添加主播</h3>
<button @click="closeAddDialog" class="text-gray-700 hover:text-gray-700 text-xl"></button>
<h3 class="text-lg font-semibold text-gray-600">批量导入主播</h3>
<button @click="closeBatchImport" class="text-gray-700 hover:text-gray-900 text-xl"></button>
</div>
<div class="p-4 space-y-4">
<!-- 主播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"
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
<label class="block text-sm font-medium text-gray-700 mb-1">
主播ID每行一个
</label>
<textarea v-model="batchInput" rows="8" placeholder="123456&#10;789012&#10;345678"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:border-blue-500 focus:outline-none resize-none"></textarea>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">国家</span>
<select v-model="batchCountry"
class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none">
<option v-for="c in COUNTRY_OPTIONS" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">等级</span>
<select v-model="batchLevel"
class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none">
<option v-for="l in ALL_LEVELS" :key="l" :value="l">{{ l }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">票种</span>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" v-model.number="batchInvitationType" :value="1" class="w-4 h-4" />
<span class="text-sm">普票</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" v-model.number="batchInvitationType" :value="2" class="w-4 h-4" />
<span class="text-sm text-yellow-600">金票</span>
</label>
</div>
</div>
<!-- 邀请类型 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">邀请类型</label>
<div class="flex gap-3">
<button @click="addForm.invitationType = '1'"
:class="['px-4 py-2 rounded-lg text-sm border transition-all', addForm.invitationType === '1' ? 'bg-blue-500 text-white border-blue-500' : 'bg-white text-gray-600 border-gray-300 hover:border-blue-300']">
普票
</button>
<button @click="addForm.invitationType = '2'"
:class="['px-4 py-2 rounded-lg text-sm border transition-all', addForm.invitationType === '2' ? 'bg-yellow-500 text-white border-yellow-500' : 'bg-white text-gray-600 border-gray-300 hover:border-yellow-300']">
进阶票
</button>
</div>
</div>
<!-- 国家选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">国家</label>
<select v-model="addForm.country"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none">
<option v-for="c in COUNTRY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</div>
<!-- 等级选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">主播等级</label>
<select v-model="addForm.hostsLevel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none">
<optgroup v-for="parent in LEVEL_OPTIONS" :key="parent.value" :label="parent.label + '级'">
<option v-for="child in parent.children" :key="child.value" :value="child.value">
{{ child.label }}
</option>
</optgroup>
</select>
<div v-if="batchInput.trim()" class="text-sm p-3 bg-gray-50 rounded-lg">
<span class="text-green-600">可导入{{ batchParsed.valid.length }} </span>
</div>
</div>
<!-- 底部按钮 -->
<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']">
{{ addStatus.message }}
</span>
<span v-else></span>
<div class="flex gap-3">
<button @click="closeAddDialog"
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleAddHosts" :disabled="addLoading || parsedIds.length === 0"
class="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed">
{{ addLoading ? '导入中...' : `导入 ${parsedIds.length} 个主播` }}
</button>
</div>
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button @click="closeBatchImport"
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">取消</button>
<button @click="doBatchImport" :disabled="!batchParsed.valid.length || batchImporting"
class="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50">
{{ batchImporting ? '导入中...' : '导入' }}
</button>
</div>
</div>
</div>
@@ -266,22 +230,13 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
import { getPermissions } from '@/utils/storage'
import { usePythonBridge } from '@/utils/pythonBridge'
import { ensureHostListGateMonitorRunning, stopHostListGateMonitor, syncHostListGateDecision } from '@/utils/hostListGateMonitor'
const props = defineProps({
visible: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'save'])
const { controlCheckTask } = usePythonBridge()
const HOST_LIST_MIN_COUNT_KEY = 'host_list_dialog_min_count'
const HOST_LIST_GATE_ENABLED_SESSION_KEY = 'host_list_dialog_gate_enabled_session'
const HOST_LIST_GATE_STATE_SESSION_KEY = 'host_list_dialog_gate_state_session'
const HOST_LIST_MONITOR_INTERVAL_MS = 30000
// Level Options
const LEVEL_OPTIONS = [
@@ -334,101 +289,52 @@ const filters = ref({
minOnlineFans: '',
maxOnlineFans: '',
})
const gateEnabled = ref(false)
const minCount = ref(5)
const maxCount = ref()
const checkTaskGateState = ref(null)
const maxCount = ref(100)
const selectedLevels = ref(new Set())
const showLevelDropdown = ref(false)
const resolveRestrictedMaxAnchorCount = (fallbackValue = 9999999) => {
const permissions = getPermissions()
const hasAutotkPermission = permissions?.autotk === 1
const hasWebAiPermission = permissions?.webAi === 1
return hasAutotkPermission || hasWebAiPermission ? fallbackValue : 1
}
// 批量导入
const showBatchImport = ref(false)
const batchInput = ref('')
const batchCountry = ref('美国')
const batchLevel = ref('B3')
const batchInvitationType = ref(1)
const batchImporting = ref(false)
// 添加主播弹窗状态
const showAddDialog = ref(false)
const addLoading = ref(false)
const addStatus = ref(null)
const addForm = ref({
idsText: '',
invitationType: '1',
country: '美国',
hostsLevel: 'A1',
})
// 国家选项
const COUNTRY_OPTIONS = [
{ label: '美国', value: '美国', eng: 'United States' },
{ label: '英国', value: '英国', eng: 'United Kingdom' },
{ label: '加拿大', value: '加拿大', eng: 'Canada' },
{ label: '澳大利亚', value: '澳大利亚', eng: 'Australia' },
{ label: '德国', value: '德国', eng: 'Germany' },
{ label: '法国', value: '法国', eng: 'France' },
{ label: '日本', value: '日本', eng: 'Japan' },
{ label: '韩国', value: '韩国', eng: 'South Korea' },
{ label: '巴西', value: '巴西', eng: 'Brazil' },
{ label: '印度尼西亚', value: '印度尼西亚', eng: 'Indonesia' },
{ label: '墨西哥', value: '墨西哥', eng: 'Mexico' },
{ label: '菲律宾', value: '菲律宾', eng: 'Philippines' },
{ label: '越南', value: '越南', eng: 'Vietnam' },
{ label: '泰国', value: '泰国', eng: 'Thailand' },
{ label: '马来西亚', value: '马来西亚', eng: 'Malaysia' },
{ label: '沙特阿拉伯', value: '沙特阿拉伯', eng: 'Saudi Arabia' },
{ label: '西班牙', value: '西班牙', eng: 'Spain' },
{ label: '意大利', value: '意大利', eng: 'Italy' },
{ label: '土耳其', value: '土耳其', eng: 'Turkey' },
{ label: '埃及', value: '埃及', eng: 'Egypt' },
{ label: '尼日利亚', value: '尼日利亚', eng: 'Nigeria' },
{ label: '哥伦比亚', value: '哥伦比亚', eng: 'Colombia' },
{ label: '阿根廷', value: '阿根廷', eng: 'Argentina' },
{ label: '智利', value: '智利', eng: 'Chile' },
{ label: '秘鲁', value: '秘鲁', eng: 'Peru' },
{ label: '以色列', value: '以色列', eng: 'Israel' },
{ label: '伊拉克', value: '伊拉克', eng: 'Iraq' },
{ label: '约旦', value: '约旦', eng: 'Jordan' },
const COUNTRY_OPTIONS = ['美国', '英国', '加拿大', '澳大利亚', '德国', '法国', '日本', '韩国', '巴西', '印度尼西亚', '墨西哥', '菲律宾', '越南', '泰国', '马来西亚', '沙特阿拉伯', '西班牙', '意大利', '土耳其', '埃及', '尼日利亚', '哥伦比亚', '阿根廷', '智利', '秘鲁', '以色列', '伊拉克', '约旦']
const ALL_LEVELS = [
'A1', 'A2', 'A3',
'B1', 'B2', 'B3', 'B4', 'B5',
'C1', 'C2', 'C3', 'C4', 'C5',
'D1', 'D2', 'D3', 'D4', 'D5',
]
// 解析输入的主播ID列表
const parsedIds = computed(() => {
return addForm.value.idsText
.split('\n')
.map(line => line.trim())
.filter(id => id.length > 0)
})
// Lifecycle
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
loadLocalGateConfig()
if (gateEnabled.value) ensureHostListGateMonitorRunning()
loadHosts()
loadConfig()
} else {
document.body.style.overflow = ''
}
})
let user = ref()
onMounted(() => {
loadLocalGateConfig()
if (gateEnabled.value) ensureHostListGateMonitorRunning()
loadConfig()
if (props.visible) {
document.body.style.overflow = 'hidden'
loadHosts()
loadConfig()
}
if (gateEnabled.value) {
void syncHostListGateDecision(true)
}
})
onUnmounted(() => {
document.body.style.overflow = ''
// Load User Data
try {
const userData = localStorage.getItem('user_data')
if (userData) {
user.value = JSON.parse(userData)
console.log('user.value', user.value)
}
} catch { }
})
// 监听过滤器变化,同步到后端配置
@@ -441,7 +347,6 @@ watch(() => filters.value, async (newFilters) => {
ordinary: newFilters.ordinary,
minOnlineFans: newFilters.minOnlineFans ? parseInt(newFilters.minOnlineFans) : 0,
maxOnlineFans: newFilters.maxOnlineFans ? parseInt(newFilters.maxOnlineFans) : 0,
maxAnchorCount: resolveRestrictedMaxAnchorCount(maxCount.value || 9999999),
}
})
console.log('[HostListDialog] 过滤配置已同步:', newFilters)
@@ -455,7 +360,6 @@ const loadHosts = async () => {
if (!isElectron()) return
try {
const data = await window.electronAPI.loadAnchorData()
console.log('加载主播数据:', data)
hosts.value = data
selected.value = new Set()
} catch (e) {
@@ -467,18 +371,13 @@ const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI.getAutomationConfig()
if (config?.filters?.maxAnchorCount !== undefined) {
// 如果后端返回 9999999前端显示为空
maxCount.value = config.filters.maxAnchorCount === 9999999 ? '' : config.filters.maxAnchorCount
if (config?.maxAnchorCount !== undefined) {
maxCount.value = config.maxAnchorCount
}
if (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)
selectedLevels.value = new Set(config.filters.hostsLevelList)
}
// 加载进阶票/普票过滤配置
// 加载票/普票过滤配置
if (config?.filters?.gold !== undefined) {
filters.value.gold = config.filters.gold
}
@@ -486,117 +385,17 @@ const loadConfig = async () => {
filters.value.ordinary = config.filters.ordinary
}
// 加载在线人数过滤配置
filters.value.minOnlineFans = config?.filters?.minOnlineFans !== undefined && config.filters.minOnlineFans > 0
? config.filters.minOnlineFans
: ''
filters.value.maxOnlineFans = config?.filters?.maxOnlineFans !== undefined
&& config.filters.maxOnlineFans > 0
&& config.filters.maxOnlineFans < 9999999999999
? config.filters.maxOnlineFans
: ''
if (config?.filters?.minOnlineFans !== undefined && config.filters.minOnlineFans > 0) {
filters.value.minOnlineFans = config.filters.minOnlineFans
}
if (config?.filters?.maxOnlineFans !== undefined && config.filters.maxOnlineFans > 0 && config.filters.maxOnlineFans < 9999999999999) {
filters.value.maxOnlineFans = config.filters.maxOnlineFans
}
} catch (e) {
console.error('加载配置失败:', e)
}
}
const loadLocalGateConfig = () => {
try {
const savedGateEnabled = sessionStorage.getItem(HOST_LIST_GATE_ENABLED_SESSION_KEY)
gateEnabled.value = savedGateEnabled === 'true'
const savedMinCount = localStorage.getItem(HOST_LIST_MIN_COUNT_KEY)
if (savedMinCount !== null && savedMinCount !== '') {
const parsedMinCount = Number(savedMinCount)
if (Number.isFinite(parsedMinCount) && parsedMinCount >= 0) {
minCount.value = parsedMinCount
}
}
const savedGateState = sessionStorage.getItem(HOST_LIST_GATE_STATE_SESSION_KEY)
if (savedGateState === 'true') {
checkTaskGateState.value = true
} else if (savedGateState === 'false') {
checkTaskGateState.value = false
} else {
checkTaskGateState.value = null
}
} catch (e) {
console.error('[HostListDialog] 读取本地门控配置失败:', e)
}
}
const persistGateEnabled = () => {
try {
sessionStorage.setItem(HOST_LIST_GATE_ENABLED_SESSION_KEY, String(gateEnabled.value))
} catch (e) {
console.error('[HostListDialog] 保存上下限模式失败:', e)
}
}
const persistMinCount = () => {
try {
if (minCount.value === '' || minCount.value === null || minCount.value === undefined) {
localStorage.removeItem(HOST_LIST_MIN_COUNT_KEY)
return
}
localStorage.setItem(HOST_LIST_MIN_COUNT_KEY, String(minCount.value))
} catch (e) {
console.error('[HostListDialog] 保存主播下限失败:', e)
}
}
const persistGateState = (value) => {
try {
if (value === null || value === undefined) {
sessionStorage.removeItem(HOST_LIST_GATE_STATE_SESSION_KEY)
return
}
sessionStorage.setItem(HOST_LIST_GATE_STATE_SESSION_KEY, String(value))
} catch (e) {
console.error('[HostListDialog] 保存门控状态失败:', e)
}
}
const syncControlCheckTaskGate = async (forceSync = false) => {
if (!isElectron()) return
if (!gateEnabled.value) return
const hostCount = filteredHosts.value.length
const upperLimit = Number(maxCount.value)
const lowerLimit = Number(minCount.value)
let nextState = null
if (Number.isFinite(upperLimit) && upperLimit >= 0 && hostCount >= upperLimit) {
nextState = false
} else if (Number.isFinite(lowerLimit) && lowerLimit >= 0 && hostCount < lowerLimit) {
nextState = true
} else if (checkTaskGateState.value !== null) {
nextState = checkTaskGateState.value
} else if (forceSync) {
nextState = true
}
if (nextState === null) return
if (!forceSync && checkTaskGateState.value === nextState) return
try {
const result = await controlCheckTask(nextState, true)
if (result?.success === false) {
console.error('[HostListDialog] controlCheckTask 调用失败:', result.error)
return
}
checkTaskGateState.value = nextState
persistGateState(nextState)
console.log('[HostListDialog] 主播库门控状态已更新:', nextState, '当前数量:', hostCount)
} catch (e) {
console.error('[HostListDialog] controlCheckTask 异常:', e)
}
}
// Helpers
const getAllChildLevels = (parentValue) => {
const parent = LEVEL_OPTIONS.find(p => p.value === parentValue)
@@ -616,21 +415,10 @@ 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: excludedLevels }
filters: { hostsLevelList: Array.from(levels) }
})
console.log('[HostListDialog] 等级过滤已更新:', excludedLevels)
console.log('[HostListDialog] 等级过滤已更新:', Array.from(levels))
} catch (e) {
console.error('更新等级配置失败:', e)
}
@@ -659,62 +447,17 @@ 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 {
// 如果不填写,传 9999999 表示无限制
const maxAnchorCount = resolveRestrictedMaxAnchorCount(value || 9999999)
await window.electronAPI.updateAutomationConfig({
filters: { maxAnchorCount }
})
console.log('[HostListDialog] 主播数据上限已更新:', maxAnchorCount)
await window.electronAPI.updateAutomationConfig({ maxAnchorCount: value })
console.log('[HostListDialog] 主播数据上限已更新:', value)
} catch (e) {
console.error('更新配置失败:', e)
}
}
watch(minCount, () => {
persistMinCount()
})
watch(gateEnabled, async (enabled) => {
persistGateEnabled()
if (enabled) {
ensureHostListGateMonitorRunning()
void syncHostListGateDecision(true)
void syncControlCheckTaskGate(true)
return
}
checkTaskGateState.value = null
persistGateState(null)
stopHostListGateMonitor()
if (!isElectron()) return
try {
const result = await controlCheckTask(true, false)
if (result?.success === false) {
console.error('[HostListDialog] 关闭上下限模式时恢复放行失败:', result.error)
}
} catch (e) {
console.error('[HostListDialog] 关闭上下限模式时恢复放行异常:', e)
}
})
const filteredHosts = computed(() => {
return hosts.value.filter(h => {
if (!filters.value.gold && h.invitationType === 2) return false
@@ -732,15 +475,6 @@ const filteredHosts = computed(() => {
})
})
watch(
[() => filteredHosts.value.length, minCount, maxCount, gateEnabled],
() => {
if (gateEnabled.value) ensureHostListGateMonitorRunning()
void syncHostListGateDecision()
void syncControlCheckTaskGate()
}
)
const selectedCount = computed(() => selected.value.size)
const toggleSelect = (id) => {
@@ -751,7 +485,7 @@ const toggleSelect = (id) => {
}
const selectAll = () => {
selected.value = new Set(filteredHosts.value.map(h => h.id))
selected.value = new Set(filteredHosts.value.map(h => h.anchorId))
}
const selectNone = () => {
@@ -761,94 +495,67 @@ const selectNone = () => {
const invertSelect = () => {
const next = new Set()
filteredHosts.value.forEach(h => {
if (!selected.value.has(h.id)) next.add(h.id)
if (!selected.value.has(h.anchorId)) next.add(h.anchorId)
})
selected.value = next
}
const deleteSelected = async () => {
const deleteSelected = () => {
if (!selected.value.size) return
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
console.log(selected.value)
if (isElectron()) {
try {
const ids = Array.from(selected.value)
const result = await window.electronAPI.deleteAnchorData(ids)
if (result.success) {
console.log('[HostListDialog] 删除成功,重新加载数据')
await loadHosts()
} else {
console.error('[HostListDialog] 删除失败:', result.error)
// fallback: 前端本地删除
hosts.value = hosts.value.filter(h => !selected.value.has(h.id))
selected.value = new Set()
}
} catch (e) {
console.error('[HostListDialog] 删除异常:', e)
hosts.value = hosts.value.filter(h => !selected.value.has(h.id))
selected.value = new Set()
}
} else {
hosts.value = hosts.value.filter(h => !selected.value.has(h.id))
selected.value = new Set()
}
const remaining = hosts.value.filter(h => !selected.value.has(h.anchorId))
hosts.value = remaining
selected.value = new Set()
}
const onClose = () => emit('close')
// 批量导入 - 解析ID列表
const batchParsed = computed(() => {
const lines = batchInput.value.split('\n').map(l => l.trim()).filter(Boolean)
const valid = lines.map(id => ({ anchorId: id, country: batchCountry.value, hostsLevel: batchLevel.value }))
return { valid }
})
const closeBatchImport = () => {
showBatchImport.value = false
batchInput.value = ''
batchCountry.value = '美国'
batchLevel.value = 'B3'
batchInvitationType.value = 1
}
const doBatchImport = async () => {
const { valid } = batchParsed.value
if (!valid.length) return
if (!isElectron()) return
batchImporting.value = true
try {
const result = await window.electronAPI.addAnchorData({
anchors: valid,
invitationType: batchInvitationType.value
})
if (result.success) {
alert(`导入完成:新增 ${result.added} 个,跳过 ${result.skipped} 个重复`)
closeBatchImport()
await loadHosts()
} else {
alert(`导入失败:${result.error}`)
}
} catch (e) {
console.error('批量导入失败:', e)
alert('批量导入失败')
} finally {
batchImporting.value = false
}
}
const handleSave = async () => {
if (isElectron()) {
await window.electronAPI.saveAnchorData(JSON.parse(JSON.stringify(hosts.value)))
}
emit('save', hosts.value)
onClose()
}
// 关闭添加弹窗
const closeAddDialog = () => {
showAddDialog.value = false
addForm.value = { idsText: '', invitationType: '1', country: '美国', hostsLevel: 'A1' }
addStatus.value = null
addLoading.value = false
}
// 批量添加主播
const handleAddHosts = async () => {
const ids = parsedIds.value
if (ids.length === 0) return
addLoading.value = true
addStatus.value = null
// 查找国家英文名
const countryObj = COUNTRY_OPTIONS.find(c => c.value === addForm.value.country)
// 构造批量记录
const records = ids.map(hostsId => ({
hostsId,
invitationType: addForm.value.invitationType,
country: addForm.value.country || null,
countryEng: countryObj?.eng || null,
hostsLevel: addForm.value.hostsLevel || null,
createTime: Date.now(),
}))
if (isElectron()) {
try {
const result = await window.electronAPI.addAnchorData(records)
if (result.success) {
addStatus.value = { type: 'success', message: `成功导入 ${ids.length} 个主播` }
// 重新加载列表
await loadHosts()
// 延迟关闭弹窗
setTimeout(() => closeAddDialog(), 1000)
} else {
addStatus.value = { type: 'error', message: result.error || '导入失败' }
}
} catch (e) {
console.error('[HostListDialog] 添加主播失败:', e)
addStatus.value = { type: 'error', message: '导入异常: ' + String(e) }
}
} else {
addStatus.value = { type: 'error', message: '非 Electron 环境,无法添加' }
}
addLoading.value = false
}
</script>

View File

@@ -1,291 +0,0 @@
<template>
<!-- info / category 的公告滚动栏显示 title -->
<div v-if="infoNotices.length > 0" :class="['notice-bar', 'notice-bar--info']">
<!-- 图标 -->
<span class="material-icons-round notice-bar__icon">campaign</span>
<!-- 滚动内容区域 -->
<div class="notice-bar__content" ref="wrapRef">
<div class="notice-bar__text" ref="textRef" :style="animationStyle">
{{ currentNotice.title }}
</div>
</div>
<!-- 多条公告时显示计数 -->
<span v-if="infoNotices.length > 1" class="notice-bar__count">
{{ currentIndex + 1 }}/{{ infoNotices.length }}
</span>
<!-- 关闭按钮 -->
<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" align-center>
<div class="alert-notice__content" v-html="currentAlert?.content"></div>
<template #footer>
<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>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNoticeStore } from '@/stores/noticeStore'
const props = defineProps({
speed: { type: Number, default: 50 }, // 滚动速度 px/s
closable: { type: Boolean, default: true },
})
const { t } = useI18n()
const noticeStore = useNoticeStore()
// ========== info 滚动栏 ==========
const infoNotices = computed(() => noticeStore.infoNotices)
// 当前显示的公告索引(多条轮播)
const currentIndex = ref(0)
const currentNotice = computed(() => infoNotices.value[currentIndex.value] || {})
// 滚动动画
const wrapRef = ref(null)
const textRef = ref(null)
const animationDuration = ref(10)
const needScroll = ref(false)
const animationStyle = computed(() => {
if (!needScroll.value) return {}
return {
animationDuration: `${animationDuration.value}s`,
}
})
// 计算文本是否需要滚动
const calculateScroll = async () => {
await nextTick()
if (!wrapRef.value || !textRef.value) return
const wrapWidth = wrapRef.value.offsetWidth
const textWidth = textRef.value.scrollWidth
if (textWidth > wrapWidth) {
needScroll.value = true
// 基于文本宽度和速度计算持续时间
animationDuration.value = (textWidth + wrapWidth) / props.speed
} else {
needScroll.value = false
}
}
// 多条公告自动轮播
let rotateTimer = null
const startRotate = () => {
stopRotate()
if (infoNotices.value.length <= 1) return
rotateTimer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % infoNotices.value.length
}, 8000) // 每 8 秒切换
}
const stopRotate = () => {
if (rotateTimer) {
clearInterval(rotateTimer)
rotateTimer = null
}
}
// 关闭当前公告
const handleClose = () => {
const notice = currentNotice.value
if (notice && notice.id) {
noticeStore.dismissNotice(notice.id)
// 调整索引
if (currentIndex.value >= infoNotices.value.length) {
currentIndex.value = 0
}
}
}
// 监听公告变化重新计算
watch(currentNotice, () => {
needScroll.value = false
calculateScroll()
}, { flush: 'post' })
watch(() => infoNotices.value.length, (len) => {
if (len > 1) {
startRotate()
} else {
stopRotate()
}
})
// ========== danger / warning 弹窗 ==========
const alertNotices = computed(() => noticeStore.alertNotices)
const dialogVisible = ref(false)
const alertIndex = ref(0)
const currentAlert = computed(() => alertNotices.value[alertIndex.value] || null)
// 下一条弹窗公告
const nextAlert = () => {
// 关闭当前这条
if (currentAlert.value) {
noticeStore.dismissNotice(currentAlert.value.id)
}
alertIndex.value++
if (alertIndex.value >= alertNotices.value.length) {
dialogVisible.value = false
}
}
// 关闭全部弹窗公告
const closeAlert = () => {
alertNotices.value.forEach(n => noticeStore.dismissNotice(n.id))
dialogVisible.value = false
}
// 有弹窗类公告时自动弹出
watch(alertNotices, (list) => {
if (list.length > 0 && !dialogVisible.value) {
alertIndex.value = 0
dialogVisible.value = true
}
}, { immediate: true })
// ========== 生命周期 ==========
onMounted(() => {
calculateScroll()
if (infoNotices.value.length > 1) {
startRotate()
}
})
onUnmounted(() => {
stopRotate()
})
</script>
<style scoped>
.notice-bar {
display: flex;
align-items: center;
padding: 0 16px;
height: 44px;
font-size: 15px;
line-height: 44px;
flex-shrink: 0;
}
/* 类型样式 */
.notice-bar--info {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #eff6ff;
color: #1e40af;
}
.notice-bar--info .notice-bar__icon {
color: #3b82f6;
}
/* 图标 */
.notice-bar__icon {
font-size: 22px;
margin-right: 10px;
flex-shrink: 0;
}
/* 内容区域 */
.notice-bar__content {
flex: 1;
overflow: hidden;
white-space: nowrap;
position: relative;
}
.notice-bar__text {
display: inline-block;
white-space: nowrap;
}
/* 需要滚动时添加动画类 */
.notice-bar__content .notice-bar__text {
animation: none;
}
.notice-bar__text[style*="animationDuration"] {
animation-name: noticeScroll;
animation-timing-function: linear;
animation-iteration-count: infinite;
padding-left: 100%;
}
@keyframes noticeScroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
/* 计数 */
.notice-bar__count {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
flex-shrink: 0;
}
/* 关闭按钮 */
.notice-bar__close {
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
padding: 4px;
border: none;
background: none;
cursor: pointer;
opacity: 0.6;
color: inherit;
border-radius: 4px;
flex-shrink: 0;
transition: opacity 0.2s;
}
.notice-bar__close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.06);
}
/* 弹窗公告内容v-html 渲染,需要用 :deep 穿透 scoped */
.alert-notice__content {
font-size: 14px;
line-height: 1.8;
color: #333;
}
.alert-notice__content :deep(p) {
margin: 0 0 8px;
}
.alert-notice__content :deep(p:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -4,75 +4,29 @@
<template v-if="hasAccess">
<slot></slot>
</template>
<!-- 无权限时显示遮罩和占位内容 -->
<template v-else>
<!-- 占位背景 -->
<!-- 占位背景 - 显示工作台截图作为假界面 -->
<div class="permission-placeholder">
<img v-if="placeholderImage" :src="placeholderImage" alt="" class="placeholder-image" />
<div v-else class="placeholder-pattern"></div>
</div>
<!-- 权限遮罩层 -->
<div class="permission-mask" ref="maskRef" :data-permission-guard="guardKey">
<!-- 上方锁提示区域 -->
<button @click="refreshPage" style="
position: absolute; top: 20px; left: 20px;
padding: 10px 20px;
background: #fff;
color: #000;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 59, 48, 0.3);
transition: all 0.3s ease;
backdrop-filter: blur(5px);
">
刷新权限
</button> <div class="mask-top">
<div class="mask-content">
<div class="lock-icon-wrapper">
<svg class="lock-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 11H5C3.89543 11 3 11.8954 3 13V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V13C21 11.8954 20.1046 11 19 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 11V7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7V11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="mask-title">{{ title }}</h3>
<p class="mask-description">{{ description }}</p>
<div class="mask-hint">
<span class="material-icons-round hint-icon">info</span>
<span>请联系下方客服开通权限</span>
</div>
<div class="mask-content">
<div class="lock-icon-wrapper">
<svg class="lock-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 11H5C3.89543 11 3 11.8954 3 13V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V13C21 11.8954 20.1046 11 19 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 11V7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7V11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<!-- 下方名片区域 -->
<div v-if="contacts && contacts.length" class="cards-area">
<div class="cards-header">
<img :src="exchangeIcon" class="refresh-btn" @click="shuffleContacts" alt="换一批" />
</div>
<div class="cards-row">
<div
v-for="(contact, index) in visibleContacts"
:key="index"
class="contact-card"
>
<div class="card-avatar-wrapper">
<img :src="contact.avatar" class="card-avatar" alt="" />
</div>
<img :src="cardBg" class="card-bg" alt="" />
<div class="card-body">
<div class="card-name">{{ contact.name }}</div>
<div class="card-desc">{{ contact.desc }}</div>
<img :src="contact.qrcode" class="card-qrcode" alt="二维码" />
<div class="card-phone">
<img :src="phoneIcon" class="phone-icon" alt="" />
{{ contact.phone }}
</div>
</div>
</div>
<h3 class="mask-title">{{ title }}</h3>
<p class="mask-description">{{ description }}</p>
<div class="mask-hint">
<span class="material-icons-round hint-icon">info</span>
<span>请联系管理员开通权限</span>
</div>
</div>
</div>
@@ -82,134 +36,78 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { getPermissions,setPermissions } from '@/utils/storage'
import cardBg from '@/assets/nav/card.png'
import phoneIcon from '@/assets/nav/phone.png'
import exchangeIcon from '@/assets/nav/exchange.png'
import { getCurrent } from '@/api/account'
import { setUser } from '@/utils/storage'
import { getPermissions } from '@/utils/storage'
const props = defineProps({
/**
* 权限类型: 'bigBrother' | 'crawl' | 'webAi'
*/
permissionKey: {
type: String,
required: true,
validator: (value) => ['bigBrother', 'crawl', 'webAi', 'autotk'].includes(value)
validator: (value) => ['bigBrother', 'crawl', 'webAi'].includes(value)
},
/**
* 遮罩标题
*/
title: {
type: String,
default: '功能未开通'
},
/**
* 遮罩描述
*/
description: {
type: String,
default: '您当前没有使用此功能的权限'
},
/**
* 占位图片路径(工作台截图)
*/
placeholderImage: {
type: String,
default: ''
},
// 名片数据,每项: { avatar, name, desc, qrcode, phone }
contacts: {
type: Array,
default: () => []
}
})
const wrapperRef = ref(null)
const maskRef = ref(null)
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[effectivePermissionKey.value] === 1
return permissionsData.value[props.permissionKey] === 1
})
const pickRandomIndices = (sourceIndices, count) => {
const arr = [...sourceIndices]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr.slice(0, count)
}
const updateVisibleContacts = (excludeCurrent = false) => {
const total = props.contacts.length
if (total === 0) {
visibleIndices.value = []
visibleContacts.value = []
return
}
if (total <= 3) {
const all = [...Array(total).keys()]
visibleIndices.value = pickRandomIndices(all, total)
visibleContacts.value = visibleIndices.value.map(i => props.contacts[i])
return
}
const all = [...Array(total).keys()]
let candidates = all
if (excludeCurrent && visibleIndices.value.length) {
const current = new Set(visibleIndices.value)
candidates = all.filter(i => !current.has(i))
if (candidates.length < 3) {
candidates = all
}
}
visibleIndices.value = pickRandomIndices(candidates, 3)
visibleContacts.value = visibleIndices.value.map(i => props.contacts[i])
}
const refreshPage = async () => {
const res = await getCurrent()
if (res) {
setUser(res)
setPermissions({
bigBrother: res.bigBrother,
crawl: res.crawl,
webAi: res.webAi,
autotk: res.autotk ?? res.autoTK,
});
}
}
const shuffleContacts = () => {
updateVisibleContacts(true)
}
// 定时刷新权限状态防止localStorage被篡改后状态不同步
let permissionCheckInterval = null
const refreshPermissions = () => {
permissionsData.value = getPermissions()
}
// MutationObserver 监测DOM篡改
let observer = null
const setupDOMProtection = () => {
if (hasAccess.value || !wrapperRef.value) return
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// 检测遮罩是否被删除
if (mutation.type === 'childList') {
const maskExists = wrapperRef.value?.querySelector('.permission-mask')
if (!maskExists && !hasAccess.value) {
console.warn('[PermissionMask] 检测到权限遮罩被非法移除,正在重载页面...')
// 强制刷新页面
window.location.reload()
}
}
// 检测遮罩样式是否被修改如display:none, visibility:hidden等
if (mutation.type === 'attributes' && mutation.target.classList?.contains('permission-mask')) {
const mask = mutation.target
const style = window.getComputedStyle(mask)
@@ -220,7 +118,7 @@ const setupDOMProtection = () => {
}
}
})
observer.observe(wrapperRef.value, {
childList: true,
subtree: true,
@@ -230,27 +128,30 @@ const setupDOMProtection = () => {
}
onMounted(() => {
updateVisibleContacts(false)
// 定时检查权限每2秒
permissionCheckInterval = setInterval(refreshPermissions, 2000)
// 延迟设置DOM保护确保元素已渲染
setTimeout(setupDOMProtection, 100)
})
onUnmounted(() => {
if (permissionCheckInterval) clearInterval(permissionCheckInterval)
if (observer) observer.disconnect()
if (permissionCheckInterval) {
clearInterval(permissionCheckInterval)
}
if (observer) {
observer.disconnect()
}
})
watch(
() => props.contacts,
() => {
updateVisibleContacts(false)
},
{ deep: true }
)
// 权限变化时重新设置保护
watch(hasAccess, (newVal) => {
if (observer) observer.disconnect()
if (!newVal) setTimeout(setupDOMProtection, 100)
if (observer) {
observer.disconnect()
}
if (!newVal) {
setTimeout(setupDOMProtection, 100)
}
})
</script>
@@ -261,6 +162,7 @@ watch(hasAccess, (newVal) => {
height: 100%;
}
/* 占位背景 - 无权限时显示,防止删除遮罩后看到内容 */
.permission-placeholder {
position: absolute;
inset: 0;
@@ -281,7 +183,7 @@ watch(hasAccess, (newVal) => {
.placeholder-pattern {
width: 100%;
height: 100%;
background-image:
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
@@ -296,21 +198,11 @@ watch(hasAccess, (newVal) => {
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
justify-content: center;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(8px);
overflow-y: auto;
}
/* 上方锁提示区域 */
.mask-top {
display: flex;
justify-content: center;
padding-top: 6vh;
width: 100%;
}
.mask-content {
@@ -327,8 +219,14 @@ watch(hasAccess, (newVal) => {
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.lock-icon-wrapper {
@@ -378,136 +276,4 @@ watch(hasAccess, (newVal) => {
.hint-icon {
font-size: 1.125rem;
}
/* 名片区域 */
.cards-area {
width: 100%;
padding: 3vh 0 2rem;
animation: slideUp 0.4s ease-out 0.1s both;
}
.cards-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
padding-right: 10%;
}
.refresh-btn {
height: 2.2vw;
cursor: pointer;
display: block;
transition: opacity 0.2s;
}
.refresh-btn:hover {
opacity: 0.8;
}
.cards-row {
display: flex;
justify-content: space-evenly;
}
/* 单张名片 */
.contact-card {
width: 17%;
flex: none;
max-width: unset;
aspect-ratio: 2 / 3;
background: transparent;
border-radius: 1.25rem;
padding-top: 0;
position: relative;
box-shadow: none;
display: flex;
flex-direction: column;
align-items: center;
}
.contact-card::before {
display: none;
}
.card-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
border-radius: 1.25rem;
z-index: 0;
pointer-events: none;
}
.card-avatar-wrapper {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 40%;
aspect-ratio: 1;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
overflow: hidden;
background: #e2e8f0;
z-index: 2;
}
.card-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 50% 8% 6%;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
justify-content: flex-start;
box-sizing: border-box;
}
.card-name {
font-size: 1.1vw;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.3vw;
text-align: center;
}
.card-desc {
font-size: 0.8vw;
color: #64748b;
text-align: center;
margin-bottom: 0.5vw;
line-height: 1.4;
}
.card-qrcode {
width: 60%;
aspect-ratio: 1;
object-fit: contain;
margin-bottom: 0.5vw;
}
.card-phone {
display: flex;
align-items: center;
gap: 0.3vw;
font-size: 0.85vw;
color: #2563eb;
font-weight: 500;
}
.phone-icon {
width: 0.9vw;
height: 0.9vw;
object-fit: contain;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<aside :style="{ width: sidebarWidth + 'px', minWidth: '96px', maxWidth: '400px' }"
class="h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm flex-shrink-0">
<aside class="w-[200px] h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm">
<!-- 返回和停止按钮 -->
<div class="m-3 mb-0 flex gap-2">
<button @click="onGoBack"
@@ -72,8 +71,7 @@
<!-- 详细统计 -->
<div class="flex-1 min-h-0 border-t border-gray-200 flex flex-col">
<div
class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50 flex justify-between items-center">
<div class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50 flex justify-between items-center">
<span>详细统计</span>
<span class="text-[10px] font-normal text-gray-400">招呼/邀请/回复</span>
</div>
@@ -83,17 +81,14 @@
暂无统计数据
</div>
<template v-else>
<div v-for="(groupStats, groupName) in statsByGroup" :key="groupName"
class="border-b border-gray-100 last:border-0">
<div v-for="(groupStats, groupName) in statsByGroup" :key="groupName" class="border-b border-gray-100 last:border-0">
<div class="px-3 py-1.5 bg-gray-100/50 text-xs font-medium text-gray-600">
{{ groupName }}
</div>
<div v-for="stat in groupStats" :key="stat.viewId"
class="px-3 py-1.5 flex items-center justify-between hover:bg-white transition-colors text-xs">
<div v-for="stat in groupStats" :key="stat.viewId" class="px-3 py-1.5 flex items-center justify-between hover:bg-white transition-colors text-xs">
<div class="flex items-center gap-1">
<span class="text-gray-500">视图 {{ stat.viewId }}</span>
<span v-if="stat.unread > 0" class="w-1.5 h-1.5 rounded-full bg-red-500"
:title="`${stat.unread} 条未读消息`"></span>
<span v-if="stat.unread > 0" class="w-1.5 h-1.5 rounded-full bg-red-500" :title="`${stat.unread} 条未读消息`"></span>
</div>
<div class="flex items-center gap-3 font-mono text-gray-700">
<span class="text-blue-600 w-6 text-right">{{ stat.greeting }}</span>
@@ -128,114 +123,18 @@
未启动任务
</div>
<!-- 统计数据 -->
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1 relative">
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已打招呼</span>
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已发邀请</span>
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已回复</span>
<button @click="showReplyList"
class="text-blue-500 hover:text-blue-600 hover:underline cursor-pointer" title="查看回复列表">
历史回复列表
</button>
<div class="flex items-center gap-1">
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
</div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已邀请</span>
<button @click="showInviteList"
class="text-purple-500 hover:text-purple-600 hover:underline cursor-pointer" title="查看邀请列表">
回复邀请列表
</button>
<div class="flex items-center gap-1">
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount || 0 }} </span>
</div>
</div>
<!-- 回复列表弹出框 -->
<div v-if="replyListVisible"
class="absolute left-0 right-0 bottom-full mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-48 overflow-hidden"
:style="{ maxWidth: sidebarWidth + 'px' }">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 bg-gray-50">
<span class="text-xs font-semibold text-gray-600">历史回复列表</span>
<button @click="replyListVisible = false" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto max-h-36">
<div v-if="repliedSessions.length === 0" class="text-gray-400 text-xs text-center py-3">
暂无回复记录
</div>
<div v-els e>
<div v-for="(session, index) in repliedSessions" :key="index"
class="px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 border-b border-gray-50 last:border-0 flex items-center justify-between">
<div class="truncate w-16" :title="session.name">
{{ session.name }}
</div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-[10px]">
视图: {{ session.viewId }}
</span>
<button @click="copyAnchorId(session.anchorId)"
class="text-blue-500 hover:text-blue-600 p-1 rounded hover:bg-blue-50 transition-colors"
title="复制 ID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 邀请列表弹出框 -->
<div v-if="inviteListVisible"
class="absolute left-0 right-0 bottom-full mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-48 overflow-hidden"
:style="{ maxWidth: sidebarWidth + 'px' }">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 bg-gray-50">
<span class="text-xs font-semibold text-purple-600">邀请列表</span>
<button @click="inviteListVisible = false" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto max-h-36">
<div v-if="invitedSessions.length === 0" class="text-gray-400 text-xs text-center py-3">
暂无邀请记录
</div>
<div v-else>
<div v-for="(session, index) in invitedSessions" :key="index"
class="px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 border-b border-gray-50 last:border-0 flex items-center justify-between">
<div class="truncate w-16" :title="session.name">
{{ session.name }}
</div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-[10px]">
视图: {{ session.viewId }}
</span>
<button @click="copyAnchorId(session.anchorId)"
class="text-purple-500 hover:text-purple-600 p-1 rounded hover:bg-purple-50 transition-colors"
title="复制 ID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
</div>
</div>
</div>
@@ -244,7 +143,7 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
tabs: { type: Array, required: true },
currentTab: { type: String, required: true },
@@ -255,106 +154,11 @@ const props = defineProps({
type: Object,
default: () => ({ greetingCount: 0, inviteCount: 0 })
},
automationLogs: { type: Array, default: () => [] },
sidebarWidth: { type: Number, default: 144 }
automationLogs: { type: Array, default: () => [] }
})
const emit = defineEmits(['tabSwitch', 'goBack', 'stopAll'])
// 回复列表相关
const replyListVisible = ref(false)
const inviteListVisible = ref(false)
/** @type {import('vue').Ref<Array<{name: string; anchorId: string; viewId: number; invited: boolean}>>} */
const repliedSessions = ref([])
/** @type {import('vue').Ref<Array<{name: string; anchorId: string; viewId: number; invited: boolean}>>} */
const invitedSessions = ref([])
// 显示回复列表
const showReplyList = async () => {
replyListVisible.value = !replyListVisible.value
inviteListVisible.value = false
if (replyListVisible.value && window.electronAPI?.getRepliedSessions) {
try {
const result = await window.electronAPI.getRepliedSessions()
console.log("回复列表里是", result)
// 按倒序展示,最新的在最前面
// 过滤出未邀请的会话
repliedSessions.value = (result || []).filter(session => !session.invited).reverse()
} catch (e) {
console.error('获取回复列表失败:', e)
repliedSessions.value = []
}
}
}
// 显示邀请列表
const showInviteList = async () => {
inviteListVisible.value = !inviteListVisible.value
replyListVisible.value = false
if (inviteListVisible.value && window.electronAPI?.getRepliedSessions) {
try {
const result = await window.electronAPI.getRepliedSessions()
console.log("邀请列表里是", result)
// 按倒序展示,最新的在最前面
// 过滤出已邀请的会话
invitedSessions.value = (result || []).filter(session => session.invited).reverse()
} catch (e) {
console.error('获取邀请列表失败:', e)
invitedSessions.value = []
}
}
}
// 复制主播 ID
const copyAnchorId = (id) => {
// 去除前面的 @ 符号
const cleanId = id.startsWith('@') ? id.substring(1) : id
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器使用 Clipboard API
navigator.clipboard.writeText(cleanId).then(() => {
showCopySuccess()
}).catch(err => {
console.error('复制失败:', err)
// 回退到传统方法
fallbackCopyTextToClipboard(cleanId)
})
} else {
// 传统方法
fallbackCopyTextToClipboard(cleanId)
}
}
// 回退复制方法(兼容旧浏览器)
const fallbackCopyTextToClipboard = (text) => {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
showCopySuccess()
} else {
console.error('复制命令执行失败')
}
} catch (err) {
console.error('回退复制失败:', err)
} finally {
document.body.removeChild(textArea)
}
}
// 复制成功提示
const showCopySuccess = () => {
// 回退到 alert
ElMessage.success('复制成功!')
}
// Event handlers
const onTabSwitch = (id) => emit('tabSwitch', id)
const onGoBack = () => emit('goBack')

View File

@@ -38,31 +38,10 @@
<p v-if="updateInfo.releaseNotes" class="text-gray-500 text-xs bg-gray-50 p-2 rounded-lg line-clamp-3">
{{ updateInfo.releaseNotes }}
</p>
<!-- Mac 用户跳转下载页面 -->
<template v-if="isMac">
<p class="text-amber-600 text-sm bg-amber-50 p-2 rounded-lg">
Mac 版本请前往官网下载最新安装包
</p>
<div class="flex gap-2">
<button @click="openDownloadPage"
class="flex-1 py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg">
前往下载
</button>
<button @click="dismissUpdate"
class="px-4 py-2 bg-gray-100 text-gray-600 rounded-lg font-medium hover:bg-gray-200 transition-all">
稍后提醒
</button>
</div>
</template>
<!-- Windows 用户正常下载更新 -->
<template v-else>
<button @click="downloadUpdate"
class="w-full py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg">
下载更新
</button>
</template>
<button @click="downloadUpdate"
class="w-full py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg">
下载更新
</button>
</div>
<!-- 下载中 -->
@@ -128,7 +107,6 @@ const {
progress,
error,
currentVersion,
isMac,
checkForUpdates,
downloadUpdate,
installUpdate,
@@ -142,13 +120,4 @@ function formatBytes(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
// 打开下载页面
const openDownloadPage = () => {
if (isElectronEnv) {
window.electronAPI.openExternal?.('https://yolozs.com/')
} else {
window.open('https://yolozs.com/', '_blank')
}
}
</script>

View File

@@ -1,252 +0,0 @@
<template>
<div class="app-aside">
<!-- Logo -->
<div class="logo">
<div class="logo-icon">PK</div>
</div>
<!-- 导航菜单 -->
<div class="navigation">
<div
v-for="item in navigationModule"
:key="item.id"
class="nav-card"
:class="{ active: item.id === activeId }"
@click="handleClick(item.id)"
>
<span class="material-icons-round nav-icon">{{ item.icon }}</span>
<div class="nav-name">{{ item.name }}</div>
<div v-if="item.id === 'message' && unreadCount > 0" class="red-dot">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</div>
</div>
</div>
<!-- 签到按钮 -->
<div class="sign-in-section">
<div class="sign-in-btn" @click="handleSignIn">
<span class="material-icons-round sign-icon">event_available</span>
<div class="sign-text">签到</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { getMainUserData, setStorage, getStorage } from '@/utils/pk-mini/storage'
import { goEasyGetConversations, getPkGoEasy, GoEasy } from '@/utils/pk-mini/goeasy'
import { pkUnreadStore } from '@/stores/pk-mini/notice.js'
import { signIn } from '@/api/pk-mini'
import { ElMessage } from 'element-plus'
const emit = defineEmits(['navigate'])
const props = defineProps({
active: {
type: String,
default: 'pk'
}
})
const defaultAvatar = 'https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/default-avatar.png'
const userInfo = ref({})
const unreadStore = pkUnreadStore()
const unreadCount = computed(() => unreadStore.count)
const activeId = ref('pk')
const navigationModule = [
{ id: 'pk', name: 'PK', icon: 'sports_esports' },
// { id: 'forum', name: '站内信', icon: 'mail' },
{ id: 'message', name: '消息', icon: 'chat' },
{ id: 'mine', name: '我的', icon: 'person' }
]
function handleClick(id) {
activeId.value = id
setStorage('activeId', id)
emit('navigate', id)
}
function handleSignIn() {
if (!userInfo.value.id) return
signIn({ userId: userInfo.value.id }).then(() => {
ElMessage.success('签到成功')
}).catch(() => {})
}
function handleSettings() {
ElMessage.info('设置功能开发中')
}
function getChatList() {
goEasyGetConversations().then((res) => {
unreadStore.setCount(res?.content?.unreadTotal || 0)
}).catch(() => {})
}
function onMessageReceived() {
unreadStore.setCount(unreadStore.count + 1)
}
onMounted(() => {
const userData = getMainUserData()
if (userData) {
userInfo.value = userData
}
const savedId = getStorage('activeId')
if (savedId) {
activeId.value = savedId
}
setTimeout(() => {
getChatList()
const goeasy = getPkGoEasy()
if (goeasy) {
goeasy.im.on(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
}
}, 2000)
})
onUnmounted(() => {
const goeasy = getPkGoEasy()
if (goeasy) {
try {
goeasy.im.off(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
} catch (e) {}
}
})
</script>
<style scoped lang="less">
.app-aside {
width: 100%;
height: 95%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 20px 0;
user-select: none;
}
.logo {
width: 50px;
height: 50px;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6, #2563eb); // from-blue-500 to-blue-600
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); // shadow-blue-500/40
}
.logo-icon {
color: white;
font-size: 18px;
font-weight: bold;
}
.navigation {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
margin-top: 30px;
}
.nav-card {
position: relative;
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #f8fafc; // slate-50
}
.nav-card:hover {
background-color: #ffffff;
transform: scale(1.05);
}
.nav-card.active {
background-color: #ffffff;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); // shadow-md
}
.nav-icon {
font-size: 24px;
color: #64748b; // slate-500
}
.nav-card.active .nav-icon {
color: #2563eb; // blue-600
}
.nav-name {
font-size: 10px;
color: #64748b; // slate-500
margin-top: 4px;
}
.nav-card.active .nav-name {
color: #2563eb; // blue-600
}
.red-dot {
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background-color: #ef4444; // red-500
color: white;
font-size: 10px;
text-align: center;
line-height: 18px;
}
.sign-in-section {
margin-top: auto;
}
.sign-in-btn {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #f8fafc; // slate-50
}
.sign-in-btn:hover {
background-color: #ffffff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
transform: scale(1.05);
}
.sign-icon {
font-size: 24px;
color: #2563eb; // blue-600
}
.sign-text {
font-size: 10px;
color: #2563eb; // blue-600
margin-top: 4px;
font-weight: bold;
}
</style>

View File

@@ -1,434 +0,0 @@
<template>
<div class="chat-message-mini-pk" :class="{ compact: compact }">
<!-- 卡片区域相对定位容器VS 绝对叠在中间 -->
<div class="pk-cards">
<!-- 用户A -->
<div class="userA">
<div class="genderAndCountry">
<div class="gender" :style="{ background: ArticleDetailsA.sex == 1 ? '#59D8DB' : '#F3876F' }">
{{ ArticleDetailsA.sex == 1 ? $t('pkMini.man') : $t('pkMini.woman') }}
</div>
<div class="Country">{{ ArticleDetailsA.country }}</div>
</div>
<div class="time">
{{ $t('pkMini.PKTime') + TimestamptolocalTime(PkIDInfodata.pkTime * 1000) }}
</div>
<div class="PKinformation">
<div class="gold">
<img class="gold-img" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<div class="sessions-content">
{{ $t('pkMini.GoldCoin') }}
<div class="gold-num">{{ ArticleDetailsA.coin }}K</div>
</div>
</div>
<div class="sessions">
<img class="sessions-img" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<div class="sessions-content">
{{ $t('pkMini.session') }}
<div class="gold-num">{{ PkIDInfodata.pkNumber + $t('pkMini.match') }}</div>
</div>
</div>
</div>
<div class="Remarks">{{ $t('pkMini.Note') + ArticleDetailsA.remark }}</div>
</div>
<!-- VS 叠在两卡片中间缝 -->
<div class="messageVS">
<img class="messageVS-img" src="@/assets/pk-mini/messageVS.png" alt="" />
</div>
<!-- 用户B -->
<div class="userB">
<div class="genderAndCountry">
<div class="gender" :style="{ background: ArticleDetailsB.sex == 1 ? '#59D8DB' : '#F3876F' }">
{{ ArticleDetailsB.sex == 1 ? $t('pkMini.man') : $t('pkMini.woman') }}
</div>
<div class="Country">{{ ArticleDetailsB.country }}</div>
</div>
<div class="time">
{{ $t('pkMini.PKTime') + TimestamptolocalTime(PkIDInfodata.pkTime * 1000) }}
</div>
<div class="PKinformation">
<div class="gold">
<img class="gold-img" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<div class="sessions-content">
{{ $t('pkMini.GoldCoin') }}
<div class="gold-num">{{ ArticleDetailsB.coin }}K</div>
</div>
</div>
<div class="sessions">
<img class="sessions-img" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<div class="sessions-content">
{{ $t('pkMini.session') }}
<div class="gold-num">{{ PkIDInfodata.pkNumber + $t('pkMini.match') }}</div>
</div>
</div>
</div>
<div class="Remarks">{{ $t('pkMini.Note') + ArticleDetailsB.remark }}</div>
</div>
</div>
<!-- 按钮 -->
<div class="btn" v-if="PkIDInfodata.pkStatus === 0 && ArticleDetailsB.senderId != info.id">
<div class="messagebtn-left" @click="agree()">{{ $t('pkMini.agree') }}</div>
<div class="messagebtn-right" @click="refuse()">{{ $t('pkMini.Refuse') }}</div>
</div>
<div v-if="PkIDInfodata.pkStatus === 1" class="messageHint">{{ $t('pkMini.HaveAgreedToTheInvitation') }}</div>
<div v-if="PkIDInfodata.pkStatus === 2" class="messageHint">{{ $t('pkMini.HaveRefusedTheInvitation') }}</div>
<div v-if="PkIDInfodata.pkStatus === 0 && ArticleDetailsB.senderId == info.id" class="messageHint">
{{ $t('pkMini.WaitForTheOtherPartyResponse') }}
</div>
</div>
<!-- 同意邀请提示弹窗 -->
<el-dialog v-model="agreedialog" center :title="$t('pkMini.Hint')" width="400" align-center>
<div class="dialog-content">
<div class="dialog-content-text">
<div>{{ $t('pkMini.AfterASuccessfulInvitationThePKCannotBeModifiedOrDeletedPleaseOperateWithCaution') }}</div>
</div>
<div class="myanchor-dialog-btn">
<div class="remindermyAnchorDialogReset" @click="agreedialog = false">{{ $t('pkMini.Cancel') }}</div>
<div class="remindermyAnchorDialogConfirm" @click="agreedialogConfirm">{{ $t('pkMini.Confirm') }}</div>
</div>
</div>
</el-dialog>
<!-- 拒绝邀请提示弹窗 -->
<el-dialog v-model="refusedialog" center :title="$t('pkMini.Hint')" width="400" align-center>
<div class="dialog-content">
<div class="dialog-content-text">
<div>{{ $t('pkMini.AreYouSureYouWantToDeclineThisInvitation') }}</div>
</div>
<div class="myanchor-dialog-btn">
<div class="remindermyAnchorDialogReset" @click="refusedialog = false">{{ $t('pkMini.Cancel') }}</div>
<div class="remindermyAnchorDialogConfirm" @click="refusedialogConfirm">{{ $t('pkMini.Confirm') }}</div>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { queryPkRecord, pkArticleDetail, updatePkRecordStatus } from '@/api/pk-mini'
import { getPromiseStorage } from '@/utils/pk-mini/storage'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
import { ElMessage } from 'element-plus'
const props = defineProps({
item: {
type: Object,
required: true
},
compact: {
type: Boolean,
default: false
}
})
const info = ref({})
const PkIDInfodata = ref({})
const ArticleDetailsA = ref({})
const ArticleDetailsB = ref({})
const agreedialog = ref(false)
const refusedialog = ref(false)
const newValitem = ref({})
function agreedialogConfirm() {
updatePkRecordStatus({
id: newValitem.value.payload.customData.id,
pkStatus: 1
}).then(() => {
ElMessage.success('同意成功')
PkIDInfodata.value.pkStatus = 1
agreedialog.value = false
}).catch(() => {})
}
function refusedialogConfirm() {
updatePkRecordStatus({
id: newValitem.value.payload.customData.id,
pkStatus: 2
}).then(() => {
ElMessage.success('拒绝成功')
PkIDInfodata.value.pkStatus = 2
refusedialog.value = false
}).catch(() => {})
}
function agree() {
agreedialog.value = true
}
function refuse() {
refusedialog.value = true
}
watch(() => props.item, (newVal) => {
newValitem.value = newVal
queryPkRecord({ id: newVal.payload.customData.id }).then((res) => {
PkIDInfodata.value = res
}).catch(() => {})
pkArticleDetail({
id: newVal.payload.customData.pkIdA,
userId: info.value.id,
from: 2
}).then((res) => {
ArticleDetailsA.value = res
}).catch(() => {})
pkArticleDetail({
id: newVal.payload.customData.pkIdB,
userId: info.value.id,
from: 2
}).then((res) => {
ArticleDetailsB.value = res
}).catch(() => {})
}, { immediate: true })
onMounted(() => {
getPromiseStorage('user').then((res) => {
info.value = res
}).catch(() => {})
})
</script>
<style scoped>
.chat-message-mini-pk {
width: 560px;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
background-color: #ffffff;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
padding: 15px 0;
}
.pk-cards {
position: relative;
width: 90%;
display: flex;
flex-direction: column;
}
.messageVS {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 67px;
height: 67px;
z-index: 2;
pointer-events: none;
}
.messageVS-img {
width: 67px;
height: 67px;
}
.userA {
width: 100%;
background-color: #c0e8e8;
border-radius: 20px 20px 0 0;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 15px;
}
.genderAndCountry {
width: 90%;
height: 30px;
margin-top: 15px;
display: flex;
align-items: center;
}
.gender {
font-size: 14px;
padding: 5px 20px;
background-color: #999;
border-radius: 20px;
color: #fff;
}
.Country {
font-size: 14px;
padding: 5px 20px;
background-color: #e4f9f9;
border-radius: 20px;
color: #03aba8;
margin-left: 10px;
}
.time {
width: 90%;
height: 20px;
margin-top: 10px;
font-size: 14px;
color: #999999;
}
.PKinformation {
width: 90%;
height: 50px;
margin-top: 10px;
display: flex;
justify-content: space-around;
align-items: center;
}
.gold, .sessions {
display: flex;
align-items: center;
}
.gold-img, .sessions-img {
width: 20px;
height: 20px;
}
.sessions-content {
display: flex;
align-items: center;
color: #999;
font-size: 14px;
}
.gold-num {
font-size: 14px;
font-weight: bold;
color: #000000;
margin-left: 5px;
}
.Remarks {
width: 90%;
height: 90px;
font-size: 12px;
color: #999;
margin-top: 10px;
}
.userB {
width: 100%;
background-color: #f8e4e0;
border-radius: 0 0 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 15px;
}
.btn {
margin-top: 20px;
width: 90%;
height: 50px;
display: flex;
justify-content: space-around;
}
.messageHint {
margin-top: 20px;
width: 90%;
height: 50px;
font-size: 20px;
color: #999;
text-align: center;
line-height: 50px;
font-weight: bold;
}
.messagebtn-left {
width: 100px;
height: 50px;
background-color: #f0836c;
border-radius: 10px;
color: #fff;
font-size: 16px;
text-align: center;
line-height: 50px;
cursor: pointer;
transition: all 0.4s ease;
}
.messagebtn-left:hover {
transform: scale(1.05);
box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.2);
}
.messagebtn-right {
width: 100px;
height: 50px;
background-color: #4fcacd;
border-radius: 10px;
color: #fff;
font-size: 16px;
text-align: center;
line-height: 50px;
cursor: pointer;
transition: all 0.4s ease;
}
.messagebtn-right:hover {
transform: scale(1.05);
box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.2);
}
.dialog-content {
width: 100%;
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog-content-text {
width: 90%;
height: 200px;
background-color: #c0e8e8;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
font-size: 16px;
font-weight: bold;
color: #03aba8;
border: 1px solid #03aba8;
}
.myanchor-dialog-btn {
width: 100%;
display: flex;
justify-content: space-around;
margin-top: 20px;
}
.remindermyAnchorDialogReset {
width: 150px;
height: 40px;
margin-top: 30px;
text-align: center;
line-height: 40px;
background: linear-gradient(0deg, #e4ffff, #ffffff);
color: #03aba8;
font-size: 18px;
border-radius: 100px;
border: 1px solid #4fcacd;
cursor: pointer;
}
.remindermyAnchorDialogConfirm {
width: 150px;
height: 40px;
margin-top: 30px;
text-align: center;
line-height: 40px;
background: linear-gradient(0deg, #4fcacd, #5fdbde);
color: #ffffff;
font-size: 18px;
border-radius: 100px;
cursor: pointer;
}
/* 紧凑模式:适配 PK 大厅 350px 窄聊天框 */
.compact.chat-message-mini-pk {
width: 300px;
padding: 10px 0;
}
.compact .pk-cards {
width: 92%;
}
.compact .userA {
border-radius: 12px 12px 0 0;
padding-bottom: 10px;
}
.compact .userB {
border-radius: 0 0 12px 12px;
padding-bottom: 10px;
}
.compact .messageVS {
width: 44px;
height: 44px;
}
.compact .messageVS-img {
width: 44px;
height: 44px;
}
</style>

View File

@@ -1,116 +0,0 @@
<template>
<div class="pk-message-card" @click="showDetail">
<div class="pk-message-header">
<img class="pk-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/pk.png" alt="PK" />
<span class="pk-title">PK 邀请</span>
</div>
<div class="pk-message-body">
<div class="pk-status" :class="statusClass">{{ statusText }}</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { queryPkRecord } from '@/api/pk-mini'
import { getPromiseStorage } from '@/utils/pk-mini/storage'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const pkInfo = ref({})
const info = ref({})
const statusText = computed(() => {
switch (pkInfo.value.pkStatus) {
case 0: return '等待响应'
case 1: return '已同意'
case 2: return '已拒绝'
default: return '查看详情'
}
})
const statusClass = computed(() => {
switch (pkInfo.value.pkStatus) {
case 0: return 'pending'
case 1: return 'accepted'
case 2: return 'rejected'
default: return ''
}
})
function showDetail() {
// 可以扩展为打开详情弹窗
}
watch(() => props.item, (newVal) => {
if (newVal?.payload?.customData?.id) {
queryPkRecord({ id: newVal.payload.customData.id }).then((res) => {
pkInfo.value = res
}).catch(() => {})
}
}, { immediate: true })
onMounted(() => {
getPromiseStorage('user').then((res) => {
info.value = res
}).catch(() => {})
})
</script>
<style scoped>
.pk-message-card {
width: 200px;
padding: 15px;
background: linear-gradient(135deg, #e4f9f9, #ffffff);
border-radius: 12px;
border: 1px solid #4fcacd;
cursor: pointer;
transition: all 0.3s ease;
}
.pk-message-card:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(79, 202, 205, 0.3);
}
.pk-message-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.pk-icon {
width: 30px;
height: 30px;
margin-right: 10px;
}
.pk-title {
font-size: 16px;
font-weight: bold;
color: #03aba8;
}
.pk-message-body {
display: flex;
justify-content: center;
}
.pk-status {
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.pk-status.pending {
background-color: #fff3e0;
color: #f57c00;
}
.pk-status.accepted {
background-color: #e8f5e9;
color: #43a047;
}
.pk-status.rejected {
background-color: #ffebee;
color: #e53935;
}
</style>

View File

@@ -1,54 +0,0 @@
<template>
<div class="picture-message" @click="dialogVisibleClick">
<img class="picture-message-img" :src="item.payload.url" alt="">
</div>
<el-dialog v-model="dialogVisible" fullscreen>
<div class="dialog-content">
<img class="dialog-img" :src="item.payload.url" alt="">
</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const dialogVisible = ref(false)
function dialogVisibleClick() {
dialogVisible.value = true
}
</script>
<style scoped>
.picture-message {
padding: 0;
margin: 0;
max-width: 100%;
border-radius: 10px;
}
.picture-message-img {
max-width: 100%;
height: auto;
display: block;
border-radius: 10px;
}
.dialog-content {
width: 98vw;
height: 95vh;
display: flex;
justify-content: center;
align-items: center;
}
.dialog-img {
max-width: 100%;
height: auto;
display: block;
}
</style>

View File

@@ -1,91 +0,0 @@
<template>
<div class="voice-message" @click="playAudio">
<div class="voice-icon">
<span class="material-icons-round">{{ isPlaying ? 'pause' : 'play_arrow' }}</span>
</div>
<div class="voice-duration">{{ size }}s</div>
<div class="voice-bar" :style="{ width: barWidth }"></div>
</div>
<audio ref="audioRef" :src="item" @ended="onAudioEnded" style="display: none;"></audio>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
item: {
type: String,
required: true
},
size: {
type: Number,
default: 0
},
senderId: {
type: [String, Number],
default: ''
}
})
const audioRef = ref(null)
const isPlaying = ref(false)
const barWidth = computed(() => {
const minWidth = 60
const maxWidth = 200
const width = Math.min(maxWidth, minWidth + props.size * 5)
return width + 'px'
})
function playAudio() {
if (audioRef.value) {
if (isPlaying.value) {
audioRef.value.pause()
isPlaying.value = false
} else {
audioRef.value.play()
isPlaying.value = true
}
}
}
function onAudioEnded() {
isPlaying.value = false
}
</script>
<style scoped>
.voice-message {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: #e4f9f9;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.voice-message:hover {
background-color: #d0f0f0;
}
.voice-icon {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #03aba8;
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-right: 10px;
}
.voice-duration {
font-size: 14px;
color: #666;
margin-right: 10px;
}
.voice-bar {
height: 4px;
background: linear-gradient(90deg, #03aba8, #4fcacd);
border-radius: 2px;
}
</style>

View File

@@ -1,442 +0,0 @@
<template>
<!-- 主播库 -->
<div class="anchor-library">
<el-splitter>
<el-splitter-panel :size="75">
<div class="demo-panel">
<!-- 主播列表 -->
<div class="anchor-list" v-if="list.length > 0">
<div class="anchor-card" v-for="(item, index) in list" :key="index">
<div class="card-content">
<div class="card-avatar">
<img :src="item.headerIcon" alt="" />
</div>
<div class="personal-info">
<div class="name">{{ item.anchorId }}</div>
<div class="info-row">
<div class="gender" :class="item.gender == 1 ? 'male' : 'female'">
{{ item.gender == 1 ? '男' : '女' }}
</div>
<div class="country">{{ item.country }}</div>
</div>
</div>
<div class="card-actions">
<div class="action-btn" @click="handleEdit(item)">
<img :src="iconEditor" alt="编辑" />
</div>
<div class="action-btn" @click="handleDelete(item)">
<img :src="iconDelete" alt="删除" />
</div>
</div>
</div>
</div>
</div>
<div class="empty-tip" v-else>您还没有主播快去添加吧</div>
</div>
</el-splitter-panel>
<!-- 右侧添加主播表单 -->
<el-splitter-panel :size="25" :resizable="false">
<div class="form-panel">
<div class="form-title">
<img class="title-icon" :src="iconEmbellish" alt="" />
<span>{{ isEditing ? '修改主播' : '添加我的主播' }}</span>
<img class="title-icon" :src="iconEmbellish" alt="" />
</div>
<div class="form-content">
<!-- 主播名称 -->
<div class="form-row">
<el-input v-model="formData.anchorName" placeholder="请输入主播名称" @blur="handleBlur" />
</div>
<!-- 国家 -->
<div class="form-row">
<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%"
/>
</div>
<!-- 按钮 -->
<div class="confirm-btn" @click="handleSubmit">确认</div>
<div class="reset-btn" @click="handleReset">重置</div>
<div class="reset-btn" v-if="isEditing" @click="handleCancel">取消</div>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<!-- 删除确认弹窗 -->
<el-dialog v-model="showDeleteDialog" title="提示" width="300" align-center>
<span>确认删除此主播</span>
<template #footer>
<el-button @click="showDeleteDialog = false">取消</el-button>
<el-button type="primary" @click="confirmDelete">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAnchorList, addAnchor, delAnchor, editAnchor, getAnchorAvatar } from '@/api/pk-mini'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { getCountryNamesArray } from '@/utils/pk-mini/countryUtil'
import { ElMessage, ElLoading } from 'element-plus'
// 导入本地图片
import iconEditor from '@/assets/pk-mini/Editor.png'
import iconDelete from '@/assets/pk-mini/Delete.png'
import iconEmbellish from '@/assets/pk-mini/embellish.png'
function getUserId(user) {
return user?.id || user?.userId || user?.uid || null
}
const currentUser = ref({})
const list = ref([])
// 表单
const formData = ref({
anchorName: '',
country: null,
gender: null,
anchorIcon: ''
})
const isEditing = ref(false)
const editingId = ref(null)
// 弹窗
const showDeleteDialog = ref(false)
const deleteItem = ref(null)
// 选项
const countryOptions = ref([])
const genderOptions = [
{ value: 1, label: '男' },
{ value: 2, label: '女' }
]
// 加载主播列表
async function loadAnchorList() {
const userId = getUserId(currentUser.value)
if (!userId) return
try {
const res = await getAnchorList({ id: userId })
list.value = res || []
} catch (e) {
console.error('加载主播库失败', e)
}
}
// 主播名称失焦查询头像
async function handleBlur() {
if (!formData.value.anchorName) return
const loading = ElLoading.service({
lock: true,
text: '正在查询主播...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
const res = await getAnchorAvatar({ name: formData.value.anchorName })
formData.value.anchorIcon = res
ElMessage.success('查询成功')
} catch (e) {
console.error('查询失败', e)
} finally {
loading.close()
}
}
// 提交
async function handleSubmit() {
const userId = getUserId(currentUser.value)
if (!userId) return
if (!formData.value.anchorName) {
ElMessage.error('请输入主播名称')
return
}
if (!formData.value.gender) {
ElMessage.error('请选择性别')
return
}
if (!formData.value.country) {
ElMessage.error('请选择国家')
return
}
const data = {
anchorId: formData.value.anchorName,
headerIcon: formData.value.anchorIcon,
gender: formData.value.gender,
country: formData.value.country,
createUserId: userId
}
try {
if (isEditing.value) {
await editAnchor({ ...data, id: editingId.value })
ElMessage.success('修改成功')
} else {
await addAnchor(data)
ElMessage.success('添加成功')
}
loadAnchorList()
handleReset()
} catch (e) {
console.error('提交失败', e)
}
}
// 重置
function handleReset() {
formData.value = { anchorName: '', country: null, gender: null, anchorIcon: '' }
isEditing.value = false
editingId.value = null
}
// 取消
function handleCancel() {
handleReset()
}
// 编辑
function handleEdit(item) {
isEditing.value = true
editingId.value = item.id
formData.value = {
anchorName: item.anchorId,
country: item.country,
gender: item.gender,
anchorIcon: item.headerIcon?.split('/').pop() || ''
}
}
// 删除
function handleDelete(item) {
deleteItem.value = item
showDeleteDialog.value = true
}
async function confirmDelete() {
try {
await delAnchor({ id: deleteItem.value.id })
ElMessage.success('删除成功')
showDeleteDialog.value = false
loadAnchorList()
handleReset()
} catch (e) {
console.error('删除失败', e)
}
}
onMounted(() => {
countryOptions.value = getCountryNamesArray()
currentUser.value = getMainUserData() || {}
const userId = getUserId(currentUser.value)
if (userId) {
loadAnchorList()
}
})
</script>
<style scoped lang="less">
.anchor-library {
width: 100%;
height: 100%;
}
.demo-panel {
width: 100%;
height: 100%;
}
.anchor-list {
width: 100%;
height: 100%;
overflow: auto;
padding: 15px;
}
.anchor-card {
margin-bottom: 15px;
}
.card-content {
display: flex;
align-items: center;
padding: 20px;
background: url('https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/PKbackground.png') no-repeat center/cover;
border-radius: 12px;
transition: all 0.3s;
}
.card-content:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
transform: scale(1.02);
}
.card-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
margin-right: 20px;
}
.card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.personal-info {
flex: 1;
}
.name {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.info-row {
display: flex;
gap: 15px;
}
.gender {
padding: 2px 15px;
border-radius: 20px;
font-size: 14px;
color: white;
}
.gender.male { background: #59d8db; }
.gender.female { background: #f3876f; }
.country {
padding: 2px 15px;
background: #fff;
border-radius: 20px;
font-size: 14px;
color: #666;
}
.card-actions {
display: flex;
gap: 20px;
}
.action-btn {
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover { transform: scale(1.2); }
.action-btn img {
width: 28px;
height: 28px;
}
.empty-tip {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #03aba8;
}
.form-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-left: 1px solid #e0f0f0;
}
.form-title {
display: flex;
align-items: center;
gap: 15px;
font-size: 20px;
font-weight: bold;
margin-bottom: 30px;
}
.title-icon {
width: 40px;
height: 28px;
}
.form-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.form-row {
width: 80%;
margin-bottom: 20px;
}
.confirm-btn {
width: 80%;
padding: 12px;
background: linear-gradient(to top, #4fcacd, #5fdbde);
color: white;
border-radius: 25px;
text-align: center;
font-size: 18px;
cursor: pointer;
margin-top: 80px;
transition: all 0.3s;
}
.confirm-btn:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
transform: scale(1.02);
}
.reset-btn {
width: 80%;
padding: 12px;
background: linear-gradient(to top, #e4ffff, #ffffff);
border: 1px solid #4fcacd;
color: #03aba8;
border-radius: 25px;
text-align: center;
font-size: 18px;
cursor: pointer;
margin-top: 20px;
transition: all 0.3s;
}
.reset-btn:hover {
box-shadow: 0 0 10px rgba(0,0,0,0.2);
transform: scale(1.02);
}
</style>

View File

@@ -1,501 +0,0 @@
<template>
<!-- 我的PK记录 -->
<div class="pk-record">
<div class="pk-layout">
<div class="left-panel">
<div class="demo-panel">
<!-- 选项卡 -->
<div class="tab-header">
<div
class="tab-item"
v-for="item in tabOptions"
:key="item.value"
@click="switchTab(item.value)"
:class="{ active: activeTab === item.value }"
>
<img class="tab-icon" :src="activeTab === item.value ? item.selectedIcon : item.icon" alt="" />
<span class="tab-label">{{ item.label }}</span>
</div>
</div>
<!-- 列表 -->
<div class="record-list" v-if="list.length > 0">
<div
v-for="(item, index) in list"
:key="index"
class="record-item"
:class="{ selected: selectedData === item }"
@click="selectRecord(item)"
>
<!-- 左侧信息 -->
<div class="record-info">
<img class="record-avatar" :src="item.anchorIconA" alt="" />
<div class="record-detail">
<div class="record-name">{{ item.anchorIdA }}</div>
<div class="record-time">PK时间: {{ formatTime(item.pkTime * 1000) }}</div>
<div class="record-coins" v-if="item.userACoins != null">
<img class="coin-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<span>实际金币数: {{ formatCoin(item.userACoins) }}</span>
</div>
</div>
</div>
<!-- VS 图标 -->
<div class="vs-icon">
<img src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
</div>
<!-- 右侧信息 -->
<div class="record-info right">
<div class="record-detail">
<div class="record-name">{{ item.anchorIdB }}</div>
<div class="record-time">PK时间: {{ formatTime(item.pkTime * 1000) }}</div>
<div class="record-coins" v-if="item.userBCoins != null">
<img class="coin-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/gold.png" alt="" />
<span>实际金币数: {{ formatCoin(item.userBCoins) }}</span>
</div>
</div>
<img class="record-avatar" :src="item.anchorIconB" alt="" />
</div>
</div>
</div>
<div class="empty-tip" v-else>您还没有PK记录</div>
</div>
</div>
<!-- 右侧详情 -->
<div class="right-panel">
<div class="detail-panel" v-if="selectedData">
<!-- 双方头像 -->
<div class="detail-avatars">
<img class="detail-avatar" :src="selectedData.anchorIconA" alt="" />
<img class="detail-avatar" :src="selectedData.anchorIconB" alt="" />
</div>
<!-- 总计 -->
<div class="detail-total">
<div class="total-card">
<span class="total-num">总共{{ formatCoin(selectedData.userACoins) }}</span>
<img class="total-icon" src="https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/session.png" alt="" />
<span class="total-num">总共{{ formatCoin(selectedData.userBCoins) }}</span>
</div>
</div>
<!-- 每局详情 -->
<div class="detail-rounds">
<div class="rounds-column left">
<div
v-for="(item, index) in roundDetails"
:key="'a-' + index"
class="round-item"
:class="item.anchorCoinA > item.anchorCoinB ? 'win' : 'lose'"
>
{{ index + 1 }}: {{ formatCoin(item.anchorCoinA) }}
</div>
</div>
<div class="rounds-column right">
<div
v-for="(item, index) in roundDetails"
:key="'b-' + index"
class="round-item"
:class="item.anchorCoinB > item.anchorCoinA ? 'win' : 'lose'"
>
{{ index + 1 }}: {{ formatCoin(item.anchorCoinB) }}
</div>
</div>
</div>
</div>
<div class="empty-detail" v-else>
<span>选择右侧的记录可立即查看详细信息</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getPkRecord, queryPkDetail } from '@/api/pk-mini'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
// 导入本地图片
import iconPublish from '@/assets/pk-mini/Publish.png'
import iconPublishSelected from '@/assets/pk-mini/PublishSelected.png'
import iconInvitation from '@/assets/pk-mini/Invitation.png'
import iconInvitationSelected from '@/assets/pk-mini/InvitationSelected.png'
// 获取用户 ID兼容不同的字段名
function getUserId(user) {
return user?.id || user?.userId || user?.uid || null
}
const currentUser = ref({})
const activeTab = ref(1)
const list = ref([])
const postedList = ref([]) // 发布的PK
const invitedList = ref([]) // 邀请的PK
const selectedData = ref(null)
const roundDetails = ref([])
const tabOptions = [
{
label: '发布的PK',
value: 1,
icon: iconPublish,
selectedIcon: iconPublishSelected
},
{
label: '邀请的PK',
value: 2,
icon: iconInvitation,
selectedIcon: iconInvitationSelected
}
]
const formatTime = TimestamptolocalTime
function formatCoin(value) {
if (value == null) return '0'
if (value >= 10000) {
return (value / 10000).toFixed(1) + 'w'
} else if (value >= 1000) {
return (value / 1000).toFixed(1) + 'k'
}
return String(value)
}
function switchTab(value) {
activeTab.value = value
selectedData.value = null
roundDetails.value = []
list.value = value === 1 ? postedList.value : invitedList.value
}
async function selectRecord(item) {
selectedData.value = item
try {
const res = await queryPkDetail({ id: item.id })
roundDetails.value = res || []
} catch (e) {
console.error('获取PK详情失败', e)
}
}
async function loadRecords(type) {
const userId = getUserId(currentUser.value)
if (!userId) return
try {
const res = await getPkRecord({
type: type,
userId: userId,
page: 0,
size: 50
})
if (type === 1) {
postedList.value = res || []
if (activeTab.value === 1) {
list.value = postedList.value
}
} else {
invitedList.value = res || []
if (activeTab.value === 2) {
list.value = invitedList.value
}
}
} catch (e) {
console.error('加载PK记录失败', e)
}
}
onMounted(() => {
currentUser.value = getMainUserData() || {}
const userId = getUserId(currentUser.value)
console.log('[PKRecord] 当前用户数据:', currentUser.value)
console.log('[PKRecord] 解析的用户 ID:', userId)
if (userId) {
loadRecords(1)
loadRecords(2)
} else {
console.warn('[PKRecord] 未找到用户 ID无法加载数据')
}
})
</script>
<style scoped lang="less">
.pk-record {
width: 100%;
height: 100%;
}
.pk-layout {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
}
.left-panel {
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
}
.right-panel {
width: 380px;
flex-shrink: 0;
height: 100%;
overflow: hidden;
border-left: 1px solid #03aba82f;
}
.demo-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tab-header {
display: flex;
padding: 20px 30px;
gap: 80px;
}
.tab-item {
display: flex;
align-items: center;
gap: 12px;
padding: 15px 30px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-item.active {
border-bottom-color: #03aba8;
}
.tab-icon {
width: 28px;
height: 28px;
}
.tab-label {
font-size: 20px;
color: #636363;
}
.tab-item.active .tab-label {
color: #03aba8;
}
.record-list {
flex: 1;
overflow: auto;
padding: 0 20px;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-around;
padding: 20px;
margin-bottom: 15px;
background: url('https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/PKbackground.png') no-repeat center/cover;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.3s;
}
.record-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.record-item.selected {
background-color: #fffbfa;
border: 1px solid #f4d0c9;
}
.record-info {
display: flex;
align-items: center;
gap: 15px;
width: 40%;
}
.record-info.right {
justify-content: flex-end;
}
.record-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.record-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-info.right .record-detail {
align-items: flex-end;
}
.record-name {
font-size: 16px;
font-weight: bold;
}
.record-time {
font-size: 13px;
color: #999;
}
.record-coins {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.coin-icon {
width: 24px;
height: 24px;
}
.vs-icon {
width: 40px;
height: 40px;
}
.vs-icon img {
width: 100%;
height: 100%;
}
.empty-tip {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #03aba8;
}
// 右侧详情
.detail-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 15px;
box-sizing: border-box;
overflow: hidden;
}
.detail-avatars {
display: flex;
gap: 20px;
margin-bottom: 12px;
}
.detail-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
}
.detail-total {
width: 100%;
margin-bottom: 12px;
}
.total-card {
display: flex;
align-items: center;
justify-content: space-around;
padding: 10px;
background: linear-gradient(90deg, #e4ffff, #fff, #e4ffff);
border-radius: 30px;
}
.total-num {
font-size: 13px;
font-weight: bold;
color: #333;
}
.total-icon {
width: 28px;
height: 22px;
}
.detail-rounds {
flex: 1;
width: 100%;
display: flex;
gap: 10px;
overflow: hidden;
}
.rounds-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 12px;
overflow: auto;
}
.rounds-column.left {
background: #dffefc;
border: 1px solid #86e1e3;
}
.rounds-column.right {
background: #fbece9;
border: 1px solid #f4d0c9;
}
.round-item {
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
font-weight: bold;
color: #03aba8;
text-align: center;
}
.round-item.win {
background: #d1f6f7;
}
.round-item.lose {
background: #f9dfd9;
}
.empty-detail {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #03aba82f;
font-size: 18px;
color: #03aba8;
}
</style>

View File

@@ -1,891 +0,0 @@
<template>
<!-- PK信息 -->
<div class="pk-message">
<el-splitter>
<el-splitter-panel :size="70" :min="50">
<div class="demo-panel">
<!-- PK信息列表 -->
<div class="pk-list" v-infinite-scroll="loadMore" v-if="list.length > 0">
<div class="pk-card" v-for="(item, index) in list" :key="index">
<div class="card-content">
<div class="card-avatar">
<img :src="item.anchorIcon" alt="" />
</div>
<div class="personal-info">
<div class="name">{{ item.anchorId }}</div>
<div class="info-row">
<div class="gender" :class="item.sex == 1 ? 'male' : 'female'">
{{ item.sex == 1 ? '男' : '女' }}
</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="" />
<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="" />
<span>场次: <b>{{ item.pkNumber }}</b></span>
</div>
</div>
<div class="pk-time">PK时间本地时间: {{ formatTime(item.pkTime * 1000) }}</div>
</div>
<div class="card-actions">
<div class="action-btn" @click="handleTop(item)">
<img v-if="!item.isPin" :src="iconTopPosition" alt="置顶" />
<img v-else :src="iconUnpinned" alt="取消置顶" />
</div>
<div class="action-btn" @click="handleEdit(item)">
<img :src="iconEditor" alt="编辑" />
</div>
<div class="action-btn" @click="handleDelete(item)">
<img :src="iconDelete" alt="删除" />
</div>
</div>
</div>
</div>
</div>
<div class="empty-tip" v-else>您还没有PK信息快去添加吧</div>
</div>
</el-splitter-panel>
<!-- 右侧发布新PK表单 -->
<el-splitter-panel :size="25" :resizable="false">
<div class="form-panel">
<div class="form-title">
<img class="title-icon" :src="iconEmbellish" alt="" />
<span>{{ isEditing ? '修改PK信息' : '发布新PK' }}</span>
<img class="title-icon" :src="iconEmbellish" alt="" />
</div>
<div class="form-content">
<!-- 主播名称 -->
<div class="form-row">
<el-input v-model="formData.anchorName" placeholder="请输入主播名称" @blur="handleAnchorBlur" />
<div class="select-anchor-btn" @click="showAnchorDialog = true">选择我的主播</div>
</div>
<!-- 国家 -->
<div class="form-row">
<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%" />
</div>
<!-- PK时间 -->
<div class="form-row">
<el-date-picker v-model="formData.pkTime" type="datetime" placeholder="请选择PK时间北京时间" style="width: 100%"
:formatter="formatBeijingTime" :parser="parseBeijingTime" value-format="x" />
</div>
<!-- 金币和场次 -->
<div class="form-row two-col">
<div class="col">
<div class="label">金币数单位为K</div>
<el-input-number v-model="formData.coin" :min="0" controls-position="right" />
</div>
<div class="col">
<div class="label">场次</div>
<el-input-number v-model="formData.pkNumber" :min="1" controls-position="right" />
</div>
</div>
<!-- 备注 -->
<div class="form-row">
<textarea v-model="formData.remark" placeholder="请输入备注(选填)" maxlength="50"></textarea>
</div>
<!-- 按钮 -->
<div class="confirm-btn" @click="handleSubmit">确认</div>
<div class="reset-btn" @click="handleReset">重置</div>
<div class="reset-btn" v-if="isEditing" @click="handleCancel">取消</div>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<!-- 删除确认弹窗 -->
<el-dialog v-model="showDeleteDialog" title="提示" width="300" align-center>
<span>确认删除该主播的PK信息</span>
<template #footer>
<el-button @click="showDeleteDialog = false">取消</el-button>
<el-button type="primary" @click="confirmDelete">确认</el-button>
</template>
</el-dialog>
<!-- 选择主播弹窗 -->
<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">
<img class="anchor-avatar" :src="item.headerIcon" alt="" />
<div class="anchor-info">
<div class="anchor-name">{{ item.anchorId }}</div>
<div class="anchor-meta">
<span class="gender" :class="item.gender == 1 ? 'male' : 'female'">
{{ item.gender == 1 ? '男' : '女' }}
</span>
<span class="country">{{ item.country }}</span>
</div>
</div>
</div>
<div v-if="anchorLibrary.length === 0" class="empty-anchor">暂无主播</div>
</div>
<div class="dialog-btns">
<div class="reset-btn" @click="showAnchorDialog = false">取消</div>
<div class="confirm-btn" @click="confirmSelectAnchor">确认</div>
</div>
</div>
</el-dialog>
<!-- 置顶弹窗 -->
<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%" />
<div class="dialog-btns">
<div class="reset-btn" @click="showTopDialog = false">取消</div>
<div class="confirm-btn" @click="confirmTop">确认置顶</div>
</div>
</div>
</el-dialog>
<!-- 取消置顶弹窗 -->
<el-dialog v-model="showCancelTopDialog" title="取消置顶" width="400" align-center>
<div class="cancel-top-content">
<p>确认取消置顶取消后您的PK信息将不再优先展示</p>
<div class="dialog-btns">
<div class="reset-btn" @click="showCancelTopDialog = false">取消</div>
<div class="confirm-btn" @click="confirmCancelTop">确认取消</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import {
getPkInfo,
releasePkInfo,
editPkInfo,
delPkInfo,
topPkInfo,
cancelTopPkInfo,
getAnchorList,
getAnchorAvatar
} from '@/api/pk-mini'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { getCountryNamesArray } from '@/utils/pk-mini/countryUtil'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
import { ElMessage, ElLoading } from 'element-plus'
// 导入本地图片
import iconEditor from '@/assets/pk-mini/Editor.png'
import iconDelete from '@/assets/pk-mini/Delete.png'
import iconEmbellish from '@/assets/pk-mini/embellish.png'
import iconTopPosition from '@/assets/pk-mini/topPosition.png'
import iconUnpinned from '@/assets/pk-mini/unpinned.png'
// 获取用户 ID
function getUserId(user) {
return user?.id || user?.userId || user?.uid || null
}
const currentUser = ref({})
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: '',
country: null,
gender: null,
pkTime: null,
coin: null,
pkNumber: null,
remark: '',
anchorIcon: ''
})
const isEditing = ref(false)
const editingId = ref(null)
// 弹窗状态
const showDeleteDialog = ref(false)
const showAnchorDialog = ref(false)
const showTopDialog = ref(false)
const showCancelTopDialog = ref(false)
const deleteItem = ref(null)
const topItem = ref(null)
const selectedAnchor = ref(null)
const topDuration = ref(null)
const topDurationOptions = ref([])
// 主播库
const anchorLibrary = ref([])
// 选项
const countryOptions = ref([])
const genderOptions = [
{ value: 1, label: '男' },
{ value: 2, label: '女' }
]
// 加载PK信息列表
async function loadPkList() {
const userId = getUserId(currentUser.value)
if (!userId) return
try {
const res = await getPkInfo({
userId: userId,
page: page.value,
size: 10
})
if (res && res.length > 0) {
list.value.push(...res)
}
} catch (e) {
console.error('加载PK信息失败', e)
}
}
// 加载更多
function loadMore() {
page.value++
loadPkList()
}
// 加载主播库
async function loadAnchorLibrary() {
const userId = getUserId(currentUser.value)
if (!userId) return
try {
const res = await getAnchorList({ id: userId })
anchorLibrary.value = res || []
} catch (e) {
console.error('加载主播库失败', e)
}
}
// 主播名称失焦时查询头像
async function handleAnchorBlur() {
if (!formData.value.anchorName) return
const loading = ElLoading.service({
lock: true,
text: '正在查询主播...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
const res = await getAnchorAvatar({ name: formData.value.anchorName })
formData.value.anchorIcon = res
ElMessage.success('查询成功')
} catch (e) {
console.error('查询主播失败', e)
} finally {
loading.close()
}
}
// 选择主播确认
function confirmSelectAnchor() {
if (!selectedAnchor.value) {
ElMessage.warning('请选择一个主播')
return
}
formData.value.anchorName = selectedAnchor.value.anchorId
formData.value.gender = selectedAnchor.value.gender
formData.value.country = selectedAnchor.value.country
formData.value.anchorIcon = selectedAnchor.value.headerIcon?.split('/').pop() || ''
showAnchorDialog.value = false
}
// 提交表单
async function handleSubmit() {
const userId = getUserId(currentUser.value)
if (!userId) return
// 验证
if (!formData.value.anchorName) {
ElMessage.error('请输入主播名称')
return
}
if (!formData.value.gender) {
ElMessage.error('请选择性别')
return
}
if (!formData.value.pkTime) {
ElMessage.error('请选择PK时间')
return
}
if (formData.value.pkTime < Date.now()) {
ElMessage.error('PK时间不能早于当前时间')
console.log(formData.value.pkTime)
return
}
if (!formData.value.country) {
ElMessage.error('请选择国家')
return
}
if (!formData.value.coin) {
ElMessage.error('请输入金币数')
return
}
if (!formData.value.pkNumber) {
ElMessage.error('请输入场次')
return
}
const data = {
anchorId: formData.value.anchorName,
pkTime: formData.value.pkTime / 1000,
sex: formData.value.gender,
country: formData.value.country,
coin: formData.value.coin,
remark: formData.value.remark || '',
status: 0,
senderId: userId,
anchorIcon: formData.value.anchorIcon,
pkNumber: formData.value.pkNumber
}
try {
if (isEditing.value) {
await editPkInfo({ ...data, id: editingId.value })
ElMessage.success('修改成功')
} else {
await releasePkInfo(data)
ElMessage.success('发布成功')
}
// 刷新列表
list.value = []
page.value = 0
loadPkList()
handleReset()
} catch (e) {
console.error('提交失败', e)
}
}
// 重置表单
function handleReset() {
formData.value = {
anchorName: '',
country: null,
gender: null,
pkTime: null,
coin: null,
pkNumber: null,
remark: '',
anchorIcon: ''
}
isEditing.value = false
editingId.value = null
}
// 取消编辑
function handleCancel() {
handleReset()
}
// 编辑
function handleEdit(item) {
isEditing.value = true
editingId.value = item.id
formData.value = {
anchorName: item.anchorId,
country: item.country,
gender: item.sex,
pkTime: item.pkTime * 1000,
coin: item.coin,
pkNumber: item.pkNumber,
remark: item.remark || '',
anchorIcon: item.anchorIcon?.split('/').pop() || ''
}
}
// 删除
function handleDelete(item) {
deleteItem.value = item
showDeleteDialog.value = true
}
async function confirmDelete() {
try {
await delPkInfo({ id: deleteItem.value.id })
ElMessage.success('删除成功')
showDeleteDialog.value = false
// 刷新列表
list.value = []
page.value = 0
loadPkList()
} catch (e) {
console.error('删除失败', e)
}
}
// 置顶
function handleTop(item) {
topItem.value = item
if (!item.isPin) {
// 计算置顶时长选项
const currentTime = Math.floor(Date.now() / 1000)
const timeDiff = item.pkTime - currentTime
if (timeDiff <= 0) {
topDurationOptions.value = [{ value: 0, label: '已过期' }]
} else {
const hours = Math.ceil(timeDiff / 3600)
topDurationOptions.value = Array.from({ length: Math.min(hours, 24) }, (_, i) => ({
value: currentTime + (i + 1) * 3600,
label: `${i + 1}小时`
}))
}
showTopDialog.value = true
} else {
showCancelTopDialog.value = true
}
}
async function confirmTop() {
if (!topDuration.value) {
ElMessage.warning('请选择置顶时长')
return
}
try {
await topPkInfo({
articleId: topItem.value.id,
pinExpireTime: topDuration.value
})
ElMessage.success('置顶成功')
showTopDialog.value = false
// 刷新列表
list.value = []
page.value = 0
loadPkList()
} catch (e) {
console.error('置顶失败', e)
}
}
async function confirmCancelTop() {
try {
await cancelTopPkInfo({ articleId: topItem.value.id })
ElMessage.success('已取消置顶')
showCancelTopDialog.value = false
// 刷新列表
list.value = []
page.value = 0
loadPkList()
} catch (e) {
console.error('取消置顶失败', e)
}
}
onMounted(() => {
countryOptions.value = getCountryNamesArray()
currentUser.value = getMainUserData() || {}
const userId = getUserId(currentUser.value)
console.log('[PKmessage] 当前用户:', currentUser.value)
console.log('[PKmessage] 用户ID:', userId)
if (userId) {
loadPkList()
loadAnchorLibrary()
}
})
</script>
<style scoped lang="less">
.pk-message {
width: 100%;
height: 100%;
}
.demo-panel {
width: 100%;
height: 100%;
}
.pk-list {
width: 100%;
height: 100%;
overflow: auto;
padding: 15px;
}
.pk-card {
margin-bottom: 15px;
}
.card-content {
display: flex;
align-items: center;
padding: 20px;
background: url('https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/PKbackground.png') no-repeat center/cover;
border-radius: 12px;
transition: all 0.3s;
}
.card-content:hover {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
.card-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
margin-right: 20px;
flex-shrink: 0;
}
.card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.personal-info {
flex: 1;
}
.name {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.info-row {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 8px;
}
.gender {
padding: 2px 15px;
border-radius: 20px;
font-size: 14px;
color: white;
}
.gender.male {
background: #59d8db;
}
.gender.female {
background: #f3876f;
}
.country {
padding: 2px 15px;
background: #e4f9f9;
border-radius: 20px;
font-size: 14px;
color: #03aba8;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
color: #666;
}
.stat-icon {
width: 18px;
height: 18px;
}
.pk-time {
font-size: 13px;
color: #999;
}
.card-actions {
display: flex;
gap: 15px;
}
.action-btn {
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover {
transform: scale(1.2);
}
.action-btn img {
width: 28px;
height: 28px;
}
.empty-tip {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #03aba8;
}
// 右侧表单
.form-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-left: 1px solid #e0f0f0;
}
.form-title {
display: flex;
align-items: center;
gap: 15px;
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
}
.title-icon {
width: 40px;
height: 28px;
}
.form-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.form-row {
width: 90%;
margin-bottom: 15px;
}
.form-row.two-col {
display: flex;
justify-content: space-between;
}
.col {
width: 48%;
}
.col .label {
font-size: 12px;
color: #999;
margin-bottom: 5px;
}
.form-row textarea {
width: 100%;
height: 80px;
border: 1px solid #4fcacd;
border-radius: 8px;
padding: 10px;
resize: none;
outline: none;
font-size: 14px;
}
.select-anchor-btn {
margin-top: 10px;
padding: 8px 15px;
background: linear-gradient(to top, #4fcacd, #5fdbde);
color: white;
border-radius: 4px;
text-align: center;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.select-anchor-btn:hover {
transform: scale(1.02);
opacity: 0.9;
}
.confirm-btn {
width: 80%;
padding: 12px;
background: linear-gradient(to top, #4fcacd, #5fdbde);
color: white;
border-radius: 25px;
text-align: center;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
margin-top: 20px;
}
.confirm-btn:hover {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
.reset-btn {
width: 80%;
padding: 12px;
background: linear-gradient(to top, #e4ffff, #ffffff);
border: 1px solid #4fcacd;
color: #03aba8;
border-radius: 25px;
text-align: center;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
margin-top: 15px;
}
.reset-btn:hover {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
// 弹窗内容
.anchor-dialog-content {
max-height: 500px;
}
.anchor-list {
max-height: 400px;
overflow: auto;
background: #e0f4f1;
border-radius: 12px;
padding: 15px;
}
.anchor-item {
display: flex;
align-items: center;
padding: 15px;
background: white;
border-radius: 10px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s;
}
.anchor-item:hover {
transform: scale(1.02);
}
.anchor-item.selected {
background: #fffbfa;
border: 1px solid #f4d0c9;
}
.anchor-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 15px;
}
.anchor-info {
flex: 1;
}
.anchor-name {
font-weight: bold;
margin-bottom: 5px;
}
.anchor-meta {
display: flex;
gap: 10px;
}
.empty-anchor {
text-align: center;
padding: 30px;
color: #999;
}
.dialog-btns {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 20px;
}
.dialog-btns .confirm-btn,
.dialog-btns .reset-btn {
width: 150px;
margin-top: 0;
}
.top-dialog-content {
padding: 20px;
}
.top-tip {
color: #999;
margin-bottom: 20px;
}
.cancel-top-content {
padding: 20px;
text-align: center;
}
.cancel-top-content p {
color: #666;
margin-bottom: 20px;
}
</style>

View File

@@ -1,459 +0,0 @@
<template>
<!-- 积分列表 -->
<div class="points-container">
<div class="points-header">
<img class="points-icon" src="@/assets/pk-mini/Points.png" alt="" />
<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">
<div class="points-item" v-for="(item, index) in pointsList" :key="index">
<div class="item-content" :class="item.status == 1 ? 'positive' : 'negative'">
<div class="event">{{ item.info }}</div>
<div class="number">{{ item.number }}</div>
<div class="time">{{ formatTime(item.time * 1000) }}</div>
</div>
</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, 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
}
const currentUser = ref({})
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
try {
const res = await getIntegralDetail({
page: page.value,
size: 30,
userId: userId
})
if (res && res.length > 0) {
pointsList.value.push(...res)
}
} catch (e) {
console.error('加载积分记录失败', e)
}
}
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()
}
})
</script>
<style scoped lang="less">
.points-container {
width: 100%;
height: 100%;
}
.points-header {
width: 100%;
height: 70px;
display: flex;
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 {
width: 52px;
height: 52px;
margin-right: 18px;
}
.points-text {
font-size: 24px;
color: #666;
}
.points-num {
font-size: 24px;
font-weight: bold;
color: #333;
}
.points-list {
width: 100%;
height: calc(100% - 70px);
overflow: auto;
padding: 10px;
}
.points-item {
margin-bottom: 10px;
display: flex;
justify-content: center;
}
.item-content {
width: 90%;
height: 60px;
border-radius: 10px;
display: flex;
justify-content: space-around;
align-items: center;
}
.item-content.positive {
background: #dffefc;
}
.item-content.negative {
background: #fbece9;
}
.event {
color: #03aba8;
font-size: 16px;
}
.number {
color: #333;
font-size: 18px;
font-weight: bold;
}
.time {
color: #999;
font-size: 16px;
}
.empty-tip {
height: calc(100% - 70px);
display: flex;
align-items: center;
justify-content: center;
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,190 +0,0 @@
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCountryName } from '@/utils/countryUtil'
/**
* 独立的国家信息管理 composable
* 每次调用都会创建独立的状态,适合需要独立国家设置的页面
*/
export function useCountryInfo() {
// 状态 - 每次调用都是独立的
const countryData = ref('') // 中文国家名
const countryDataEN = ref('') // 英文国家名
const isLoading = ref(false) // 是否正在获取
const hasInitialized = ref(false) // 是否已初始化
/**
* 获取 IP 国家信息
* @param {Function} t - 国际化函数
* @param {boolean} showDialog - 获取失败时是否显示弹窗
*/
const fetchCountryInfo = async (t, showDialog = true) => {
if (isLoading.value) return
isLoading.value = true
try {
const response = await fetch('https://ipapi.co/json/')
if (!response.ok) {
throw new Error('请求失败')
}
const data = await response.json()
countryDataEN.value = data.country_name
countryData.value = getCountryName(data.country)
hasInitialized.value = true
return { success: true }
} catch (error) {
console.error('获取国家信息失败:', error)
if (showDialog && !hasInitialized.value) {
showCountryInputDialog(t)
}
return { success: false, error }
} finally {
isLoading.value = false
}
}
/**
* 刷新国家信息
* @param {Function} t - 国际化函数
*/
const refreshCountry = async (t) => {
if (isLoading.value) return
try {
const result = await fetchCountryInfo(t, false)
if (result.success) {
ElMessage.success(t('workbenchesSetup.refreshSuccess') || t('hostsList.refreshSuccess') || '刷新成功')
} else {
ElMessageBox.confirm(
t('workbenchesSetup.refreshFailed') || t('hostsList.refreshFailed') || '刷新失败,是否手动输入国家?',
t('workbenchesSetup.enterCountryTitle') || t('hostsList.enterCountryTitle') || '提示',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '手动输入',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
type: 'warning',
}
).then(() => {
showCountryInputDialog(t)
}).catch(() => {
// 用户取消
})
}
} catch (error) {
ElMessage.error(t('workbenchesSetup.refreshFailed') || t('hostsList.refreshFailed') || '刷新失败')
}
}
/**
* 显示手动输入国家的弹窗
* @param {Function} t - 国际化函数
*/
const showCountryInputDialog = (t) => {
ElMessageBox.prompt(
t('workbenchesSetup.enterCountryPrompt') || t('hostsList.enterCountryPrompt') || '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
t('workbenchesSetup.enterCountryTitle') || t('hostsList.enterCountryTitle') || '获取国家失败',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '确定',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
inputPlaceholder: t('workbenchesSetup.countryPlaceholder') || t('hostsList.countryPlaceholder') || '例如:美国、日本、英国',
inputValidator: (value) => {
if (!value || value.trim() === '') {
return t('workbenchesSetup.countryRequired') || t('hostsList.countryRequired') || '请输入国家名称'
}
return true
}
}
).then(({ value }) => {
countryData.value = value.trim()
countryDataEN.value = value.trim()
hasInitialized.value = true
ElMessage.success(t('workbenchesSetup.countrySetSuccess') || t('hostsList.countrySetSuccess') || '国家设置成功')
}).catch(() => {
// 用户取消输入
if (!hasInitialized.value) {
countryData.value = t('workbenchesSetup.unknown') || t('hostsList.unknown') || '未知'
countryDataEN.value = 'Unknown'
hasInitialized.value = true
}
})
}
/**
* 初始化国家信息
* @param {Function} t - 国际化函数
*/
const initCountryInfo = async (t) => {
if (hasInitialized.value) {
return
}
await fetchCountryInfo(t, true)
}
/**
* 手动设置国家信息
* @param {string} countryName - 国家名称
* @param {Function} t - 国际化函数(可选)
*/
const setCountryManually = (countryName, t = null) => {
if (!countryName || countryName.trim() === '') {
return false
}
countryData.value = countryName.trim()
countryDataEN.value = countryName.trim()
hasInitialized.value = true
if (t) {
ElMessage.success(t('workbenchesSetup.countrySetSuccess') || t('hostsList.countrySetSuccess') || '国家设置成功')
}
return true
}
/**
* 显示编辑国家的弹窗
* @param {Function} t - 国际化函数
* @returns {Promise} 确认时 resolve 新的国家名称,取消时 reject
*/
const showEditCountryDialog = (t) => {
return ElMessageBox.prompt(
t('workbenchesSetup.editCountryPrompt') || t('hostsList.editCountryPrompt') || '请输入国家名称(中文)',
t('workbenchesSetup.editCountryTitle') || t('hostsList.editCountryTitle') || '编辑国家',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '确定',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
inputPlaceholder: t('workbenchesSetup.countryPlaceholder') || t('hostsList.countryPlaceholder') || '例如:美国、日本、英国',
inputValue: countryData.value,
inputValidator: (value) => {
if (!value || value.trim() === '') {
return t('workbenchesSetup.countryRequired') || t('hostsList.countryRequired') || '请输入国家名称'
}
return true
}
}
).then(({ value }) => {
setCountryManually(value, t)
return value.trim()
})
}
return {
// 状态
countryData,
countryDataEN,
isLoading,
hasInitialized,
// 方法
fetchCountryInfo,
refreshCountry,
showCountryInputDialog,
initCountryInfo,
setCountryManually,
showEditCountryDialog,
}
}

View File

@@ -1,10 +0,0 @@
export const ENV = {
// 主 API 地址
API_BASE_URL: import.meta.env.VITE_API_BASE_URL,
// 注册 API 地址tkNewAdmin 后端)
REGISTER_API_URL: import.meta.env.VITE_REGISTER_API_URL,
// PK Mini API 地址
PK_MINI_API_URL: import.meta.env.VITE_PK_MINI_API_URL,
// YOLO 商店 iframe 地址
SHOP_URL: import.meta.env.VITE_SHOP_URL,
}

View File

@@ -1,28 +0,0 @@
/**
* PK Mini 模块配置
*
* GoEasy 续费后,将 GOEASY_ENABLED 改为 true 即可开启消息功能
*/
import { ENV } from '@/config'
export const PK_MINI_CONFIG = {
// GoEasy 开关 - 续费后改为 true
GOEASY_ENABLED: true,
// GoEasy 配置
GOEASY: {
HOST: 'singapore.goeasy.io',
APP_KEY: 'PC-cfd3ebc8401447be8562b00d243ea82f',
},
// API 基础地址(从中心配置读取,随环境自动切换)
get API_BASE_URL() {
return ENV.PK_MINI_API_URL + '/'
},
// 头像 CDN 地址
AVATAR_CDN_PREFIX: 'https://vv-1317974657.cos.ap-shanghai.myqcloud.com/headerIcon/',
}
// 快捷访问
export const isGoEasyEnabled = () => PK_MINI_CONFIG.GOEASY_ENABLED

View File

@@ -1,141 +1,85 @@
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
// ============== 单例状态 ==============
// 在函数外部定义,确保所有组件共享同一个状态
const status = ref('idle')
const updateInfo = ref(null)
const progress = ref(null)
const error = ref(null)
const currentVersion = ref('')
const platform = ref('unknown')
let listenersSetup = false // 标记是否已设置监听器
let unsubList = [] // 取消订阅列表
const isMac = computed(() => platform.value === 'mac')
const isWindows = computed(() => platform.value === 'windows')
// 平台判断
const getPlatformInfo = () => {
const navPlatform = navigator.platform || ''
const ua = navigator.userAgent || ''
const appVersion = navigator.appVersion || ''
console.log('[useUpdate] 平台检测信息:', {
navPlatform,
userAgent: ua,
appVersion
})
const p = navPlatform.toLowerCase()
if (p.includes('mac')) return 'mac'
if (p.includes('win')) return 'windows'
if (p.includes('linux')) return 'linux'
const u = ua.toLowerCase()
if (u.includes('macintosh') || u.includes('mac os') || u.includes('macintel')) return 'mac'
if (u.includes('windows') || u.includes('win32') || u.includes('win64')) return 'windows'
if (u.includes('linux')) return 'linux'
const av = appVersion.toLowerCase()
if (av.includes('mac')) return 'mac'
if (av.includes('win')) return 'windows'
if (av.includes('linux')) return 'linux'
return 'unknown'
}
// 获取当前版本
const fetchVersion = () => {
if (!isElectron()) {
currentVersion.value = 'web'
return
}
window.electronAPI.getAppVersion().then(v => {
currentVersion.value = v
}).catch(console.error)
}
// 设置监听器(只执行一次)
const setupListeners = () => {
if (!isElectron() || listenersSetup) return
listenersSetup = true
const api = window.electronAPI
// 正在检查
if (api.onUpdateChecking) {
unsubList.push(api.onUpdateChecking(() => {
status.value = 'checking'
}))
}
// 发现新版本
unsubList.push(api.onUpdateAvailable((info) => {
updateInfo.value = info
status.value = 'available'
error.value = null
}))
// 无可用更新
if (api.onUpdateNotAvailable) {
unsubList.push(api.onUpdateNotAvailable(() => {
status.value = 'idle'
}))
}
unsubList.push(api.onUpdateProgress((prog) => {
progress.value = prog
status.value = 'downloading'
}))
unsubList.push(api.onUpdateDownloaded(() => {
status.value = 'downloaded'
progress.value = null
}))
unsubList.push(api.onUpdateError((err) => {
error.value = err.message
status.value = 'error'
}))
// macOS 手动安装提示
if (api.onUpdateManualInstall) {
unsubList.push(api.onUpdateManualInstall((info) => {
console.log('macOS 需要手动安装更新:', info.path)
alert(`更新已下载完成!\n\n请手动安装更新文件:\n${info.path}\n\n应用将退出。`)
}))
}
}
// 清理监听器
const cleanupListeners = () => {
unsubList.forEach(unsub => unsub && unsub())
unsubList = []
listenersSetup = false
}
// NOTE: Since we are using JS, we don't have interfaces, but structure remains same.
/**
* 应用更新 Hook (Vue Composable)
* 单例模式:所有组件共享同一个状态和监听器
* 管理更新状态、进度和操作
* 注意:此 Composable 仅在 Electron 环境中有效
*/
export function useUpdate() {
// 每个组件挂载时的初始化逻辑
onMounted(() => {
// 检测平台(确保只设置一次)
if (isElectron() && platform.value === 'unknown') {
platform.value = getPlatformInfo()
console.log('[useUpdate] 平台检测结果:', platform.value)
const status = ref('idle')
const updateInfo = ref(null)
const progress = ref(null)
const error = ref(null)
const currentVersion = ref('')
// 获取当前版本
const fetchVersion = () => {
if (!isElectron()) {
currentVersion.value = 'web'
return
}
window.electronAPI.getAppVersion().then(v => {
currentVersion.value = v
}).catch(console.error)
}
let unsubList = []
// 监听更新事件
const setupListeners = () => {
if (!isElectron()) return
const api = window.electronAPI
// 正在检查
if (api.onUpdateChecking) {
unsubList.push(api.onUpdateChecking(() => {
status.value = 'checking'
}))
}
// 发现新版本
unsubList.push(api.onUpdateAvailable((info) => {
updateInfo.value = info
status.value = 'available'
error.value = null
}))
// 无可用更新
if (api.onUpdateNotAvailable) {
unsubList.push(api.onUpdateNotAvailable(() => {
status.value = 'idle'
}))
}
unsubList.push(api.onUpdateProgress((prog) => {
progress.value = prog
status.value = 'downloading'
}))
unsubList.push(api.onUpdateDownloaded(() => {
status.value = 'downloaded'
progress.value = null
}))
unsubList.push(api.onUpdateError((err) => {
error.value = err.message
status.value = 'error'
}))
}
onMounted(() => {
fetchVersion()
setupListeners()
})
// 注意:不在这里清理监听器,因为是单例模式
// 只在最后一个组件卸载时才清理(这里简化处理,不清理)
// 如果需要严格清理,可以使用引用计数
onUnmounted(() => {
unsubList.forEach(unsub => unsub && unsub())
unsubList = []
})
const checkForUpdates = () => {
if (!isElectron()) return
@@ -179,9 +123,6 @@ export function useUpdate() {
progress,
error,
currentVersion,
platform,
isMac,
isWindows,
checkForUpdates,
downloadUpdate,
installUpdate,

View File

@@ -1,168 +1,146 @@
<template>
<div class="flex h-screen w-screen overflow-hidden bg-white">
<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%;">
<div>
<img :src="yoloIcon" class="yolo-logo" />
<!-- Left Navigation Sidebar -->
<div class="w-16 flex flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-50">
<div class="mb-6">
<!-- Logo or Brand -->
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-900/50">
<span class="material-icons-round text-white">grid_view</span>
</div>
</div>
<div class="flex-1 flex flex-col w-full px-2" style="gap: 2vh;">
<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="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>
<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="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>
<div class="flex-1 flex flex-col gap-4 w-full px-2">
<!-- Auto DM Workbench Tab -->
<button @click="currentView = 'auto_dm'"
class="w-full 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>
<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>
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'auto_dm' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">chat</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
自动私信工作台
</div>
</button>
<!-- Fan Workbench Tab -->
<button @click="currentView = 'FanWorkbench'"
class="w-full 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>
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'FanWorkbench' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">supervised_user_circle</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
大哥工作台
</div>
</button>
<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="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>
<!-- TK Workbench Tab -->
<button @click="currentView = 'tk'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'tk' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">tiktok</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
TK 工作台
</div>
</button>
<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="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>
<!-- Hosts List Tab -->
<button @click="currentView = 'hosts'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'hosts' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">people</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
主播列表
</div>
</button>
</div>
<div class="mt-auto w-full px-2">
<div class="mt-auto">
<!-- Logout or Back -->
<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" />
<span class="text-base font-medium" style="color: #ED4949;">退出登录</span>
class="w-10 h-10 rounded-xl flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-red-400 transition-all">
<span class="material-icons-round">logout</span>
</button>
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 h-full relative">
<!-- Tab 1: Auto DM Workbench (Config + Browser) - 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"
>
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="$emit('logout')"
@config-updated="handleConfigUpdated" />
<ConfigPage
@go-to-browser="handleGoToBrowser"
@logout="$emit('logout')"
/>
</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"
@go-back="handleBackToConfig"
@stop-all="handleStopAll"
/>
</div>
</PermissionMask>
</div>
<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>
<!-- 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 :key="tkWorkbenchKey" />
<PermissionMask
permission-key="crawl"
title="TK工作台未开通"
description="您当前没有使用TK工作台功能的权限"
:placeholder-image="placeholderTk"
>
<TkWorkbenches />
</PermissionMask>
</div>
<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">
<!-- Tab 3: Hosts List - crawl 权限 -->
<div v-show="currentView === 'hosts'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<PermissionMask
permission-key="crawl"
title="主播列表未开通"
description="您当前没有使用主播列表功能的权限"
:placeholder-image="placeholderHosts"
>
<HostsList />
</PermissionMask>
</div>
<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">
<!-- Tab 4: Fan Workbench - bigBrother 权限 -->
<div v-show="currentView === 'FanWorkbench'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<PermissionMask
permission-key="bigBrother"
title="大哥工作台未开通"
description="您当前没有使用大哥工作台功能的权限"
:placeholder-image="placeholderBigBrother"
>
<FanWorkbench />
</PermissionMask>
</div>
<div v-show="currentView === 'pk_mini'" class="absolute inset-0 z-20 h-full overflow-hidden">
<PkMiniWorkbench />
</div>
<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>
<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" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { ref, watch } from 'vue'
import { isElectron } from '@/utils/electronBridge'
import YoloBrowser from '@/views/YoloBrowser.vue'
import TkWorkbenches from '@/views/tk/Workbenches.vue'
import HostsList from '@/views/tk/HostsList.vue'
import ConfigPage from '@/pages/ConfigPage.vue'
import FanWorkbench from '@/views/tk/FanWorkbench.vue'
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'
import nav2 from '@/assets/nav/nav2.png'
import nav22 from '@/assets/nav/nav22.png'
import nav3 from '@/assets/nav/nav3.png'
import nav33 from '@/assets/nav/nav33.png'
import nav4 from '@/assets/nav/nav4.png'
import nav44 from '@/assets/nav/nav44.png'
import nav5 from '@/assets/nav/nav5.png'
import nav55 from '@/assets/nav/nav55.png'
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'
@@ -170,142 +148,64 @@ import placeholderBigBrother from '@/assets/placeholder-bigbrother.png'
const emit = defineEmits(['logout', 'go-back', 'stop-all'])
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)
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()
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)
}
}
let resizeObserver = null
const notifySidebarWidth = (width) => {
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)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
const currentView = ref('auto_dm') // Default Tab
const autoDmMode = ref('config') // Default Sub-state: 'config' or 'browser'
const handleGoToBrowser = async () => {
autoDmMode.value = 'browser'
if (isElectron()) {
await window.electronAPI.showViews()
}
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')
}
const handleConfigUpdated = () => {
window.dispatchEvent(new CustomEvent('config-updated'))
emit('stop-all')
}
// Watch for view changes to manage native Electron BrowserViews
watch(currentView, async (newVal, oldVal) => {
if (newVal === 'shop' && !adminLoaded.value && !isElectron()) {
adminLoaded.value = true
}
if (!isElectron()) return
if (newVal === 'shop') {
try {
shopOpened.value = true
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
} else if (oldVal === 'shop') {
try {
await window.electronAPI.hideShop()
} catch (e) {
console.error('隐藏商店失败:', e)
}
}
const shouldShowAutoDmViews =
newVal === 'auto_dm' && autoDmMode.value === 'browser'
if (shouldShowAutoDmViews) {
if (newVal === 'auto_dm' && autoDmMode.value === 'browser') {
// Switching TO Auto DM tab AND we are in browser mode: Show views
try {
await window.electronAPI.showViews()
} catch (e) {
console.error('Failed to show views:', e)
}
} else {
// Switching AWAY from Auto DM tab OR we are in config mode: Hide views
try {
await window.electronAPI.hideViews()
} catch (e) {
console.error('Failed to hide views:', e)
// 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 {
width: 70%;
}
</style>

View File

@@ -48,20 +48,6 @@ export default {
prompt: 'Stop crawling specified number',
setHostNum: 'Set crawling quantity',
unlimitedQuantity: 'Unlimited crawling quantity',
refreshCountry: 'Refresh Country',
refreshSuccess: 'Refresh Successful',
refreshFailed: 'Refresh Failed',
enterCountryPrompt: 'Unable to automatically obtain country information due to network issues. Please manually enter the country name (in Chinese)',
enterCountryTitle: 'Failed to Get Country',
confirm: 'Confirm',
cancel: 'Cancel',
countryPlaceholder: 'e.g., 美国, 日本, 英国',
countryRequired: 'Please enter country name',
countrySetSuccess: 'Country Set Successfully',
unknown: 'Unknown',
editCountry: 'Edit Country',
editCountryPrompt: 'Please enter country name (in Chinese)',
editCountryTitle: 'Edit Country',
},
hostList: {
placeCountry: 'Select country',
@@ -93,7 +79,6 @@ export default {
invitationType: 'invitationType',
invitationType1: 'Regular',
invitationType2: 'Golden',
invitationType3: 'Linked Account',
liveSessions: 'Live Sessions',
viewSessions: 'View Sessions',
liveRevenue: 'Live Revenue',
@@ -108,16 +93,6 @@ export default {
revenueTime: 'Time',
close: 'Close',
selectPlaceholder: 'Please select',
searchPlaceholder: 'Search...',
onlyAbnormal: 'Only Abnormal',
total: 'Total',
totalLikes: 'Total Likes',
zeroLikes: 'Zero Likes',
startTime: 'Start Time',
endTime: 'End Time',
duration: 'Duration',
likeCount: 'Likes',
createTime: 'Create Time',
},
hostsList: {
filterPrivateUsers: 'Filter Private Users',
@@ -181,18 +156,6 @@ export default {
starting: 'Starting...',
pleaseEnterCountryName: 'Please enter the country name in Chinese',
getCountryFailed: 'Failed to get country',
refreshCountry: 'Refresh Country',
refreshSuccess: 'Refresh Successful',
refreshFailed: 'Refresh Failed',
enterCountryPrompt: 'Unable to automatically obtain country information due to network issues. Please manually enter the country name (in Chinese)',
enterCountryTitle: 'Failed to Get Country',
countryPlaceholder: 'e.g., 美国, 日本, 英国',
countryRequired: 'Please enter country name',
countrySetSuccess: 'Country Set Successfully',
unknown: 'Unknown',
editCountry: 'Edit Country',
editCountryPrompt: 'Please enter country name (in Chinese)',
editCountryTitle: 'Edit Country',
},
countries: {
// ... (truncated common countries for brevity, or include all if critical. I'll include a subset or all if possible. The file read showed all.)
@@ -216,65 +179,5 @@ export default {
SA: "Saudi Arabia", SB: "Solomon Islands", SC: "Seychelles", SD: "Sudan", SE: "Sweden", SG: "Singapore", SI: "Slovenia", SJ: "Svalbard and Jan Mayen", SK: "Slovakia", SL: "Sierra Leone", SM: "San Marino", SN: "Senegal", SO: "Somalia", SR: "Suriname", SS: "South Sudan", ST: "Sao Tome and Principe", SV: "El Salvador", SX: "Sint Maarten", SY: "Syria", SZ: "Eswatini",
TC: "Turks and Caicos Islands", TD: "Chad", TF: "French Southern Territories", TG: "Togo", TH: "Thailand", TJ: "Tajikistan", TK: "Tokelau", TL: "Timor-Leste", TM: "Turkmenistan", TN: "Tunisia", TO: "Tonga", TR: "Turkey", TT: "Trinidad and Tobago", TV: "Tuvalu", TW: "Taiwan", TZ: "Tanzania", UA: "Ukraine", UG: "Uganda", UM: "United States Minor Outlying Islands", US: "United States", UY: "Uruguay", UZ: "Uzbekistan",
VA: "Vatican City", VC: "Saint Vincent and the Grenadines", VE: "Venezuela", VG: "British Virgin Islands", VI: "U.S. Virgin Islands", VN: "Vietnam", VN1: "Vietnam", VU: "Vanuatu", WS: "Samoa", YE: "Yemen", YT: "Mayotte", ZA: "South Africa", ZM: "Zambia", ZW: "Zimbabwe"
},
notice: {
close: 'Close notice',
},
// PK Mini module translations
pkMini: {
// Navigation
pkHall: 'PK Hall',
todayPK: 'Today PK',
forum: 'In-site Message',
message: 'Message',
mine: 'Mine',
// PK Hall
selectCountry: 'Select Country',
selectGender: 'Select Gender',
male: 'Male',
female: 'Female',
minCoin: 'Min Coins',
maxCoin: 'Max Coins',
search: 'Search',
reset: 'Reset',
pkTime: 'PK Time',
goldCoin: 'Gold Coins',
session: 'Session',
send: 'Send',
selectHostToChat: 'Select a host to start chatting',
// Mine page
anchorLibrary: 'Anchor Library',
pkInfo: 'PK Info',
pkRecord: 'PK Record',
pointsList: 'Points List',
myPoints: 'My Points',
totalPoints: 'Total Points',
addAnchor: 'Add Anchor',
deleteAnchor: 'Delete',
noAnchor: 'No anchors yet',
noPkInfo: 'No PK info yet',
noPkRecord: 'No PK record yet',
noPoints: 'No points record yet',
// Status
pending: 'Pending',
matched: 'Matched',
closed: 'Closed',
accepted: 'Accepted',
rejected: 'Rejected',
waitConfirm: 'Wait Confirm',
// Message
noConversation: 'No conversations',
selectConversation: 'Select a conversation to start chatting',
inputMessage: 'Enter message...',
sendImage: 'Send Image',
// Common
confirm: 'Confirm',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
save: 'Save',
loading: 'Loading...',
noData: 'No data',
noNotice: 'No in-site messages'
}
}

View File

@@ -48,20 +48,6 @@ export default {
prompt: '达到数量后停止爬取',
setHostNum: '设置爬取数量',
unlimitedQuantity: '不限爬取数量',
refreshCountry: '刷新国家',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失败',
enterCountryPrompt: '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
enterCountryTitle: '获取国家失败',
confirm: '确定',
cancel: '取消',
countryPlaceholder: '例如:美国、日本、英国',
countryRequired: '请输入国家名称',
countrySetSuccess: '国家设置成功',
unknown: '未知',
editCountry: '编辑国家',
editCountryPrompt: '请输入国家名称(中文)',
editCountryTitle: '编辑国家',
},
hostList: {
placeCountry: '选择国家',
@@ -92,8 +78,7 @@ export default {
sure: '确定',
invitationType: '邀请类型',
invitationType1: '普票',
invitationType2: '进阶票',
invitationType3: '关联账号',
invitationType2: '票',
liveSessions: '直播场次',
viewSessions: '查看场次',
liveRevenue: '直播收益',
@@ -108,16 +93,6 @@ export default {
revenueTime: '时间',
close: '关闭',
selectPlaceholder: '请选择',
searchPlaceholder: '搜索...',
onlyAbnormal: '只看异常',
total: '总条数',
totalLikes: '点赞合计',
zeroLikes: '无点赞',
startTime: '开始时间',
endTime: '结束时间',
duration: '时长',
likeCount: '点赞',
createTime: '入库时间',
},
hostsList: {
filterPrivateUsers: '过滤隐私用户',
@@ -181,97 +156,19 @@ export default {
stopping: '正在停止...',
starting: '正在启动...',
enterRoomId: '请输入直播间id',
refreshCountry: '刷新国家',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失败',
enterCountryPrompt: '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
enterCountryTitle: '获取国家失败',
countryPlaceholder: '例如:美国、日本、英国',
countryRequired: '请输入国家名称',
countrySetSuccess: '国家设置成功',
unknown: '未知',
editCountry: '编辑国家',
editCountryPrompt: '请输入国家名称(中文)',
editCountryTitle: '编辑国家',
},
countries: {
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
},
notice: {
close: '关闭通知',
},
// PK Mini 模块翻译
pkMini: {
// 导航
pkHall: 'PK大厅',
todayPK: '今日PK',
forum: '站内信',
message: '消息',
mine: '我的',
// PK 大厅
selectCountry: '选择国家',
selectGender: '选择性别',
male: '男',
female: '女',
minCoin: '最小金币数',
maxCoin: '最大金币数',
search: '搜索',
reset: '重置',
pkTime: 'PK时间',
goldCoin: '金币',
session: '场次',
send: '发送',
selectHostToChat: '选择左侧主播开始聊天',
// 我的页面
anchorLibrary: '主播库',
pkInfo: 'PK信息',
pkRecord: 'PK记录',
pointsList: '积分列表',
myPoints: '我的积分',
totalPoints: '总积分',
addAnchor: '添加主播',
deleteAnchor: '删除',
noAnchor: '暂无主播',
noPkInfo: '暂无发布的 PK',
noPkRecord: '暂无 PK 记录',
noPoints: '暂无积分记录',
// 状态
pending: '等待匹配',
matched: '已匹配',
closed: '已关闭',
accepted: '已同意',
rejected: '已拒绝',
waitConfirm: '待确认',
// 消息
noConversation: '暂无会话',
selectConversation: '选择左侧会话开始聊天',
inputMessage: '输入消息...',
sendImage: '发送图片',
// 通用
confirm: '确认',
cancel: '取消',
delete: '删除',
edit: '编辑',
save: '保存',
loading: '加载中...',
noData: '暂无数据',
noNotice: '暂无站内信',
// MiniPKMessage 邀请卡片
man: '男',
woman: '女',
PKTime: 'PK时间',
GoldCoin: '金币:',
match: '场',
Note: '备注:',
agree: '同意',
Refuse: '拒绝',
HaveAgreedToTheInvitation: '已同意邀请',
HaveRefusedTheInvitation: '已拒绝邀请',
WaitForTheOtherPartyResponse: '等待对方响应',
Hint: '提示',
AfterASuccessfulInvitationThePKCannotBeModifiedOrDeletedPleaseOperateWithCaution: '同意后PK邀请将无法修改或删除请谨慎操作',
AreYouSureYouWantToDeclineThisInvitation: '确定要拒绝此邀请吗?',
Cancel: '取消',
Confirm: '确认'
BA: "波斯尼亚和黑塞哥维那", BB: "巴巴多斯", BD: "孟加拉国", BE: "比利时", BF: "布基纳法索", BG: "保加利亚", BH: "巴林", BI: "布隆迪", BJ: "贝宁", BL: "圣巴泰勒米", BM: "百慕大群岛", BN: "文莱达鲁萨兰国", BO: "玻利维亚", BQ: "博奈尔、圣尤斯特歇斯和萨巴", BR: "巴西", BS: "巴哈马", BT: "不丹", BV: "布韦岛", BW: "博茨瓦纳", BY: "白俄罗斯", BZ: "伯利兹",
CA: "加拿大", CA1: "加拿大", CC: "科科斯(基林)群岛", CD: "刚果民主共和国", CF: "中非共和国", CG: "刚果共和国", CH: "瑞士", CI: "科特迪瓦", CK: "库克群岛", CL: "智利", CM: "喀麦隆", CN: "中国", CO: "哥伦比亚", CR: "哥斯达黎加", CU: "古巴", CV: "佛得角", CW: "库拉索", CX: "圣诞岛", CY: "塞浦路斯", CZ: "捷克共和国",
DE: "德国", DG: "迪戈加西亚岛", DJ: "吉布提", DK: "丹麦", DM: "多米尼克", DO: "多米尼加共和国", DZ: "阿尔及利亚", EC: "厄瓜多尔", EE: "爱沙尼亚", EG: "埃及", EH: "西撒哈拉", ER: "厄立特里亚", ES: "西班牙", ET: "埃塞俄比亚", FI: "芬兰", FJ: "斐济", FK: "福克兰群岛", FM: "密克罗尼西亚", FO: "法罗群岛", FR: "法国",
GA: "加蓬", GB: "英国", GD: "格林纳达", GE: "格鲁吉亚", GF: "法属圭亚那", GG: "根西岛", GH: "加纳", GI: "直布罗陀", GL: "格陵兰", GM: "冈比亚", GN: "几内亚", GP: "瓜德罗普", GQ: "赤道几内亚", GR: "希腊", GS: "南乔治亚和南桑德威奇群岛", GT: "危地马拉", GU: "关岛", GW: "几内亚比绍", GY: "圭亚那",
HK: "中国香港特别行政区", HM: "赫德岛和麦克唐纳群岛", HN: "洪都拉斯", HR: "克罗地亚", HT: "海地", HU: "匈牙利", ID: "印度尼西亚", IE: "爱尔兰", IL: "以色列", IM: "马恩岛", IN: "印度", IO: "英属印度洋领地", IQ: "伊拉克", IR: "伊朗", IS: "冰岛", IT: "意大利",
JE: "泽西岛", JM: "牙买加", JO: "约旦", JP: "日本", JP1: "日本", KE: "肯尼亚", KG: "吉尔吉斯斯坦", KH: "柬埔寨", KI: "基里巴斯", KM: "科摩罗", KN: "圣基茨和尼维斯", KP: "朝鲜", KR: "韩国", KR1: "韩国", KW: "科威特", KY: "开曼群岛", KZ: "哈萨克斯坦",
LA: "老挝", LB: "黎巴嫩", LC: "圣卢西亚", LI: "列支敦士登", LK: "斯里兰卡", LR: "利比里亚", LS: "莱索托", LT: "立陶宛", LU: "卢森堡", LV: "拉脱维亚", LY: "利比亚", MA: "摩洛哥", MC: "摩纳哥", MD: "摩尔多瓦", ME: "黑山", MF: "圣马丁", MG: "马达加斯加", MH: "马绍尔群岛", MK: "北马其顿", ML: "马里", MM: "缅甸", MN: "蒙古", MO: "中国澳门特别行政区", MP: "北马里亚纳群岛", MQ: "马提尼克", MR: "毛里塔尼亚", MS: "蒙特塞拉特", MT: "马耳他", MU: "毛里求斯", MV: "马尔代夫", MW: "马拉维", MX: "墨西哥", MY: "马来西亚", MZ: "莫桑比克",
NA: "纳米比亚", NC: "新喀里多尼亚", NE: "尼日尔", NF: "诺福克岛", NG: "尼日利亚", NI: "尼加拉瓜", NL: "荷兰", NO: "挪威", NP: "尼泊尔", NR: "瑙鲁", NU: "纽埃", NZ: "新西兰", OM: "阿曼", PA: "巴拿马", PE: "秘鲁", PF: "法属玻利尼西亚", PG: "巴布亚新几内亚", PH: "菲律宾", PK: "巴基斯坦", PL: "波兰", PM: "圣皮埃尔和密克隆群岛", PN: "皮特凯恩群岛", PR: "波多黎各", PS: "巴勒斯坦", PT: "葡萄牙", PW: "帕劳", PY: "巴拉圭", QA: "卡塔尔", RE: "留尼汪", RO: "罗马尼亚", RS: "塞尔维亚", RU: "俄罗斯", RW: "卢旺达",
SA: "沙特阿拉伯", SB: "索罗门群岛", SC: "塞舌尔", SD: "苏丹", SE: "瑞典", SG: "新加坡", SI: "斯洛文尼亚", SJ: "斯瓦尔巴和扬马延", SK: "斯洛伐克", SL: "塞拉利昂", SM: "圣马利诺", SN: "塞内加尔", SO: "索马里", SR: "苏里南", SS: "南苏丹", ST: "圣多美和普林西比", SV: "萨尔瓦多", SX: "荷属圣马丁", SY: "叙利亚", SZ: "斯威士兰",
TC: "特克斯和凯科斯群岛", TD: "乍得", TF: "法属南部领地", TG: "多哥", TH: "泰国", TJ: "塔吉克斯坦", TK: "托克劳群岛", TL: "东帝汶", TM: "土库曼斯坦", TN: "突尼斯", TO: "汤加", TR: "土耳其", TT: "特立尼达和多巴哥", TV: "图瓦卢", TW: "台湾", TZ: "坦桑尼亚", UA: "乌克兰", UG: "乌干达", UM: "美国本土外小岛屿", US: "美国", UY: "乌拉圭", UZ: "乌兹别克斯坦",
VA: "梵蒂冈", VC: "圣文森特", VE: "委内瑞拉", VG: "英属维尔京群岛", VI: "美属维尔京群岛", VN: "越南", VN1: "越南", VU: "瓦努阿图", WS: "萨摩亚", YE: "也门", YT: "马约特岛", ZA: "南非", ZM: "赞比亚", ZW: "津巴布韦"
}
}

View File

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

View File

@@ -326,6 +326,7 @@
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false"
@confirm="handleGreetingConfirm" />
<!-- 预热 Loading 遮罩 -->
<transition name="fade">
<div v-if="warmingUp"
@@ -363,7 +364,7 @@ import GreetingDialog from '../components/GreetingDialog.vue'
import AIConfigDialog from '../components/AIConfigDialog.vue'
import { isElectron } from '../utils/electronBridge'
const emit = defineEmits(['goToBrowser', 'logout', 'configUpdated'])
const emit = defineEmits(['goToBrowser', 'logout'])
const CONFIG_KEY = 'autoDm_runConfig'
@@ -383,9 +384,7 @@ const defaultConfig = {
switchMinutes: 60,
prologueList: {},
needTranslate: false,
filters: {
maxAnchorCount: 99999
},
maxAnchorCount: 100,
lang: 'en'
}
@@ -519,18 +518,24 @@ const loadConfig = async () => {
if (isElectronEnv) {
const saved = await window.electronAPI.loadRunConfig()
if (saved) {
config.value = { ...config.value, ...saved }
localStorage.setItem(CONFIG_KEY, JSON.stringify(saved))
// 排除 filters 字段,因为过滤器配置由 HostListDialog 独立管理
const { filters, ...runConfig } = saved
config.value = { ...config.value, ...runConfig }
localStorage.setItem(CONFIG_KEY, JSON.stringify(runConfig))
} else {
const localSaved = localStorage.getItem(CONFIG_KEY)
if (localSaved) {
config.value = { ...config.value, ...JSON.parse(localSaved) }
const parsed = JSON.parse(localSaved)
const { filters, ...runConfig } = parsed
config.value = { ...config.value, ...runConfig }
}
}
} else {
const localSaved = localStorage.getItem(CONFIG_KEY)
if (localSaved) {
config.value = { ...config.value, ...JSON.parse(localSaved) }
const parsed = JSON.parse(localSaved)
const { filters, ...runConfig } = parsed
config.value = { ...config.value, ...runConfig }
}
}
} catch (e) {
@@ -547,9 +552,9 @@ const saveToLocalStorage = () => {
const saveToFile = async () => {
if (!isElectronEnv) return
try {
// 只保存运行配置,不包含过滤器相关配置(过滤器由 HostListDialog 独立管理)
const configToSave = JSON.parse(JSON.stringify(config.value))
// ConfigPage 不管理 filtersHostListDialog 会单独管理
// 删除 filters 避免用 ConfigPage 中可能过期的状态覆盖后端
// 确保不覆盖 filters 配置
delete configToSave.filters
await window.electronAPI.saveRunConfig(configToSave)
} catch (e) {
@@ -666,6 +671,7 @@ const handleSleepTimeInput = (val) => {
}
}
const warmingUp = ref(false)
// Start/Stop
const handleStart = async (specificGroupIndex) => {
const activeGroupIndex = specificGroupIndex ?? 0
@@ -704,12 +710,11 @@ const handleStart = async (specificGroupIndex) => {
inviteThreshold: config.value.inviteThreshold,
prologueList,
needTranslate: config.value.needTranslate, // 添加翻译开关配置
filters: {
maxAnchorCount: config.value.filters?.maxAnchorCount !== undefined ? config.value.filters.maxAnchorCount : 100
},
maxAnchorCount: config.value.maxAnchorCount,
rotationEnabled: config.value.rotateEnabled,
rotationIntervalMinutes: config.value.switchMinutes,
currentActiveGroup: activeGroupName,
// 这里只是保存配置到 configStore不会直接传给 automation
})
const groupsToStart = config.value.rotateEnabled
@@ -752,6 +757,8 @@ const handleStart = async (specificGroupIndex) => {
console.warn('[ConfigPage] 视图预热失败,继续启动:', e)
}
const results = await Promise.allSettled(
startTasks.map(async ({ viewId, account, delay }) => {
await new Promise(r => setTimeout(r, delay))
@@ -777,7 +784,6 @@ const handleStart = async (specificGroupIndex) => {
} else if (firstError.result.status === 'fulfilled') {
errorMsg = firstError.result.value.error || '启动失败'
}
warmingUp.value = false
alert(`启动失败:${errorMsg}`)
return
}
@@ -793,8 +799,6 @@ const handleStart = async (specificGroupIndex) => {
rotationStatus.value = status
handleStatusChange(status)
warmingUp.value = false //关闭遮罩
// 触发自定义事件通知配置已更新
emit('configUpdated')
emit('goToBrowser')
}

View File

@@ -1,22 +1,6 @@
<template>
<div
class="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
<div class="absolute top-8 right-8 flex gap-4 z-20">
<!-- Language Selector -->
<el-dropdown>
<div
class="bg-white/95 border border-slate-200 rounded-2xl px-4 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
<span class="text-sm font-semibold text-slate-700">{{ locale === 'zh' ? '中文' : 'English' }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="switchLanguage('zh')">中文</el-dropdown-item>
<el-dropdown-item @click="switchLanguage('en')">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- Background Shapes -->
<div class="absolute top-[-200px] right-[-200px] w-[800px] h-[800px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
style="background: radial-gradient(circle, rgba(79, 129, 230, 0.2) 0%, rgba(79, 129, 230, 0) 70%)" />
@@ -29,265 +13,100 @@
<!-- Left Side: Form -->
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
<!-- Header / Logo注册页隐藏避免表单被挤出屏幕 -->
<div v-show="mode === 'login'" class="flex justify-center">
<!-- Header / Logo -->
<div class="flex justify-center">
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
</div>
<!-- ==================== 登录表单 ==================== -->
<template v-if="mode === 'login'">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- 租户号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- 租户号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="credentials.username" placeholder="请输入账号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="credentials.username" placeholder="请输入账号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="credentials.password" placeholder="请输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
<input type="password" v-model="credentials.password" placeholder="请输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
登录中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</form>
<!-- 切换到注册 -->
<div class="mt-6 text-center">
<span class="text-gray-500 text-sm">还没有账号</span>
<button @click="switchMode('register')"
class="text-[#4F81E6] text-sm font-medium hover:text-blue-700 ml-1 transition-colors">
立即注册
登录中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</template>
</form>
<!-- ==================== 注册表单 ==================== -->
<template v-else>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-2">注册新账号</h1>
<p class="text-gray-500 text-sm">填写以下信息创建您的租户账号</p>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<!-- 租户名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户名称</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="registerForm.name" placeholder="2-20个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 联系人 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系人</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="registerForm.contactName" placeholder="请输入联系人姓名"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 联系手机 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系手机</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<input type="tel" v-model="registerForm.contactMobile" placeholder="请输入手机号码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<input type="text" v-model="registerForm.username" placeholder="4-30个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="registerForm.password" placeholder="5-20个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 确认密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<input type="password" v-model="registerForm.confirmPassword" placeholder="请再次输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- Turnstile 人机验证 -->
<div class="flex justify-center">
<div id="turnstile-container"></div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 注册按钮 -->
<div class="pt-1">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
注册中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</form>
<!-- 切换到登录 -->
<div class="mt-4 text-center">
<span class="text-gray-500 text-sm">已有账号</span>
<button @click="switchMode('login')"
class="text-[#4F81E6] text-sm font-medium hover:text-blue-700 ml-1 transition-colors">
返回登录
</button>
</div>
</template>
<div class="mt-6 text-center">
<div class="mt-8 text-center">
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
</div>
</div>
@@ -315,58 +134,40 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { ref, onMounted } from 'vue'
import { isElectron, getAppVersion } from '../utils/electronBridge'
import { setUser, setToken, setUserPass, getUserPass, setPermissions } from '@/utils/storage'
import { tenantRegister } from '@/api/register'
import logo from '../assets/logo.png'
import illustration from '../assets/illustration.webp'
import illustration from '../assets/illustration.png'
const emit = defineEmits(['loginSuccess'])
const { locale } = useI18n()
const STORAGE_KEY = 'login_credentials'
const USER_KEY = 'user_data'
// 当前模式login / register
const mode = ref('login')
// Language Switcher
const switchLanguage = (lang) => {
locale.value = lang
localStorage.setItem('lang', lang)
}
// 切换登录/注册
const switchMode = (target) => {
mode.value = target
error.value = ''
}
// ==================== 通用状态 ====================
const isLoading = ref(false)
const error = ref('')
const version = ref('')
// ==================== 登录相关 ====================
const credentials = ref({
tenantName: '',
username: '',
password: '',
})
const isLoading = ref(false)
const error = ref('')
const version = ref('')
onMounted(() => {
// 获取应用版本
getAppVersion().then(v => {
version.value = v
})
// 加载保存的凭据
try {
const saved = getUserPass()
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const data = JSON.parse(saved)
credentials.value = {
tenantName: saved.tenantName || '',
username: saved.username || saved.userId || '',
password: saved.password || '',
tenantName: data.tenantName || '',
username: data.username || data.userId || '',
password: data.password || '',
}
}
} catch { } // eslint-disable-line no-empty
@@ -382,7 +183,8 @@ const handleSubmit = async () => {
error.value = ''
try {
setUserPass(credentials.value)
// 保存凭据
localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials.value))
console.log('[LoginPage] 开始登录...', credentials.value)
@@ -396,16 +198,7 @@ const handleSubmit = async () => {
console.log('[LoginPage] 登录结果:', result)
if (result.success && result.user) {
setToken(result.user.tokenValue);
setUser(result.user);
setPermissions({
bigBrother: result.user.bigBrother,
crawl: result.user.crawl,
webAi: result.user.webAi,
autotk: result.user.autoTk
});
localStorage.setItem(USER_KEY, JSON.stringify(result.user))
emit('loginSuccess')
} else {
error.value = result.error || '登录失败'
@@ -416,156 +209,6 @@ const handleSubmit = async () => {
isLoading.value = false
}
}
// ==================== 注册相关 ====================
const registerForm = ref({
name: '',
contactName: '',
contactMobile: '',
username: '',
password: '',
confirmPassword: '',
})
// Turnstile 人机验证
const TURNSTILE_SITE_KEY = '0x4AAAAAACYSAf0bQMQ347Pz'
const turnstileToken = ref('')
const turnstileWidgetId = ref('')
const initTurnstile = () => {
const waitForTurnstile = (retries = 0) => {
if (retries > 50) {
console.error('[Turnstile] SDK 加载超时')
return
}
if (window.turnstile && !turnstileWidgetId.value) {
const container = document.getElementById('turnstile-container')
if (!container) return
try {
turnstileWidgetId.value = window.turnstile.render('#turnstile-container', {
sitekey: TURNSTILE_SITE_KEY,
theme: 'light',
callback: (token) => {
turnstileToken.value = token
},
'error-callback': () => {
turnstileToken.value = ''
},
'expired-callback': () => {
turnstileToken.value = ''
},
})
} catch (err) {
console.error('[Turnstile] 渲染错误:', err)
}
} else if (!window.turnstile) {
setTimeout(() => waitForTurnstile(retries + 1), 100)
}
}
waitForTurnstile()
}
const resetTurnstile = () => {
if (window.turnstile && turnstileWidgetId.value) {
window.turnstile.reset(turnstileWidgetId.value)
turnstileToken.value = ''
}
}
const destroyTurnstile = () => {
if (window.turnstile && turnstileWidgetId.value) {
window.turnstile.remove(turnstileWidgetId.value)
turnstileWidgetId.value = ''
turnstileToken.value = ''
}
}
// 切换到注册页时初始化 Turnstile切回登录时销毁
watch(mode, (newMode) => {
if (newMode === 'register') {
nextTick(() => initTurnstile())
} else {
destroyTurnstile()
}
})
const handleRegister = async () => {
const form = registerForm.value
// 表单校验
if (!form.name || !form.contactName || !form.contactMobile || !form.username || !form.password || !form.confirmPassword) {
error.value = '请填写所有字段'
return
}
if (form.name.length < 2 || form.name.length > 20) {
error.value = '租户名称长度必须介于 2 和 20 之间'
return
}
if (!/^1[3-9]\d{9}$/.test(form.contactMobile)) {
error.value = '请输入正确的手机号码'
return
}
if (form.username.length < 4 || form.username.length > 30) {
error.value = '账号长度必须介于 4 和 30 之间'
return
}
if (form.password.length < 5 || form.password.length > 20) {
error.value = '密码长度必须介于 5 和 20 之间'
return
}
if (/[<>"'|\\]/.test(form.password)) {
error.value = '密码不能包含非法字符:< > " \' \\ |'
return
}
if (form.password !== form.confirmPassword) {
error.value = '两次输入的密码不一致'
return
}
if (!turnstileToken.value) {
error.value = '请完成人机验证'
return
}
isLoading.value = true
error.value = ''
try {
await tenantRegister({
name: form.name,
contactName: form.contactName,
contactMobile: form.contactMobile,
username: form.username,
password: form.password,
turnstileToken: turnstileToken.value,
})
ElMessage.success('注册成功,请登录')
// 将租户名和账号回填到登录表单
credentials.value.tenantName = form.name
credentials.value.username = form.username
credentials.value.password = ''
// 清空注册表单
registerForm.value = {
name: '',
contactName: '',
contactMobile: '',
username: '',
password: '',
confirmPassword: '',
}
// 切换回登录
mode.value = 'login'
} catch (err) {
error.value = err.message || '注册失败,请稍后重试'
resetTurnstile()
} finally {
isLoading.value = false
}
}
</script>
<style>

View File

@@ -76,27 +76,10 @@
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
</div>
<!-- Mac 用户复制下载链接 -->
<template v-if="isMac">
<p class="text-amber-600 text-sm bg-amber-50 p-3 rounded-lg text-center">
Mac 版本请复制下方链接在浏览器中打开下载最新安装包
</p>
<div class="bg-gray-50 rounded-lg p-3 text-center">
<span class="text-blue-600 text-sm break-all select-all">{{ downloadUrl }}</span>
</div>
<button @click="copyDownloadUrl"
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
{{ copySuccess ? '已复制' : '复制链接' }}
</button>
</template>
<!-- Windows 用户正常下载更新 -->
<template v-else>
<button @click="downloadUpdate"
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
立即下载更新
</button>
</template>
<button @click="downloadUpdate"
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
立即下载更新
</button>
<p class="text-center text-gray-400 text-xs">
必须更新后才能使用程序
@@ -202,7 +185,6 @@ const {
progress,
error,
currentVersion,
isMac,
checkForUpdates,
downloadUpdate,
installUpdate
@@ -215,8 +197,6 @@ const retryCount = ref(0)
const isTimeout = ref(false)
const showTimeoutError = ref(false)
const countdown = ref(AUTO_INSTALL_DELAY)
const downloadUrl = 'https://yolozs.com/'
const copySuccess = ref(false)
let timeoutTimer = null
let hasStarted = false
@@ -324,14 +304,6 @@ function formatBytes(bytes) {
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
// 复制下载链接Mac 用户)
function copyDownloadUrl() {
navigator.clipboard.writeText(downloadUrl).then(() => {
copySuccess.value = true
setTimeout(() => { copySuccess.value = false }, 2000)
})
}
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
if (countdownTimer) clearInterval(countdownTimer)

View File

@@ -1,194 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCountryName } from '@/utils/countryUtil'
export const useCountryStore = defineStore('country', () => {
// 状态
const countryData = ref('') // 中文国家名
const countryDataEN = ref('') // 英文国家名
const isLoading = ref(false) // 是否正在获取
const hasInitialized = ref(false) // 是否已初始化
const lastFetchTime = ref(null) // 上次获取时间
/**
* 获取 IP 国家信息
* @param {Function} t - 国际化函数
* @param {boolean} showDialog - 获取失败时是否显示弹窗(默认 true只在第一次获取时显示
*/
const fetchCountryInfo = async (t, showDialog = true) => {
if (isLoading.value) return
isLoading.value = true
try {
const response = await fetch('https://ipapi.co/json/')
if (!response.ok) {
throw new Error('请求失败')
}
const data = await response.json()
countryDataEN.value = data.country_name
countryData.value = getCountryName(data.country)
lastFetchTime.value = Date.now()
hasInitialized.value = true
return { success: true }
} catch (error) {
console.error('获取国家信息失败:', error)
// 只在允许显示弹窗且未初始化时才显示
if (showDialog && !hasInitialized.value) {
showCountryInputDialog(t)
}
return { success: false, error }
} finally {
isLoading.value = false
}
}
/**
* 刷新国家信息
* @param {Function} t - 国际化函数
*/
const refreshCountry = async (t) => {
if (isLoading.value) return
try {
const result = await fetchCountryInfo(t, false) // 刷新时不自动弹窗
if (result.success) {
ElMessage.success(t('workbenchesSetup.refreshSuccess') || t('hostsList.refreshSuccess') || '刷新成功')
} else {
// 刷新失败时,给用户选择是否手动输入
ElMessageBox.confirm(
t('workbenchesSetup.refreshFailed') || t('hostsList.refreshFailed') || '刷新失败,是否手动输入国家?',
t('workbenchesSetup.enterCountryTitle') || t('hostsList.enterCountryTitle') || '提示',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '手动输入',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
type: 'warning',
}
).then(() => {
showCountryInputDialog(t)
}).catch(() => {
// 用户取消
})
}
} catch (error) {
ElMessage.error(t('workbenchesSetup.refreshFailed') || t('hostsList.refreshFailed') || '刷新失败')
}
}
/**
* 显示手动输入国家的弹窗
* @param {Function} t - 国际化函数
*/
const showCountryInputDialog = (t) => {
ElMessageBox.prompt(
t('workbenchesSetup.enterCountryPrompt') || t('hostsList.enterCountryPrompt') || '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
t('workbenchesSetup.enterCountryTitle') || t('hostsList.enterCountryTitle') || '获取国家失败',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '确定',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
inputPlaceholder: t('workbenchesSetup.countryPlaceholder') || t('hostsList.countryPlaceholder') || '例如:美国、日本、英国',
inputValidator: (value) => {
if (!value || value.trim() === '') {
return t('workbenchesSetup.countryRequired') || t('hostsList.countryRequired') || '请输入国家名称'
}
return true
}
}
).then(({ value }) => {
countryData.value = value.trim()
countryDataEN.value = value.trim()
hasInitialized.value = true
lastFetchTime.value = Date.now()
ElMessage.success(t('workbenchesSetup.countrySetSuccess') || t('hostsList.countrySetSuccess') || '国家设置成功')
}).catch(() => {
// 用户取消输入
if (!hasInitialized.value) {
countryData.value = t('workbenchesSetup.unknown') || t('hostsList.unknown') || '未知'
countryDataEN.value = 'Unknown'
hasInitialized.value = true
}
})
}
/**
* 初始化国家信息(只在第一次调用时获取)
* @param {Function} t - 国际化函数
*/
const initCountryInfo = async (t) => {
if (hasInitialized.value) {
return // 已经初始化过,不再重复获取
}
await fetchCountryInfo(t, true)
}
/**
* 手动设置国家信息
* @param {string} countryName - 国家名称(中文)
* @param {Function} t - 国际化函数(可选)
*/
const setCountryManually = (countryName, t = null) => {
if (!countryName || countryName.trim() === '') {
return false
}
countryData.value = countryName.trim()
countryDataEN.value = countryName.trim()
hasInitialized.value = true
lastFetchTime.value = Date.now()
if (t) {
ElMessage.success(t('workbenchesSetup.countrySetSuccess') || t('hostsList.countrySetSuccess') || '国家设置成功')
}
return true
}
/**
* 显示编辑国家的弹窗
* @param {Function} t - 国际化函数
* @returns {Promise} 确认时 resolve 新的国家名称,取消时 reject
*/
const showEditCountryDialog = (t) => {
return ElMessageBox.prompt(
t('workbenchesSetup.editCountryPrompt') || t('hostsList.editCountryPrompt') || '请输入国家名称(中文)',
t('workbenchesSetup.editCountryTitle') || t('hostsList.editCountryTitle') || '编辑国家',
{
confirmButtonText: t('workbenchesSetup.confirm') || t('hostsList.confirm') || '确定',
cancelButtonText: t('workbenchesSetup.cancel') || t('hostsList.cancel') || '取消',
inputPlaceholder: t('workbenchesSetup.countryPlaceholder') || t('hostsList.countryPlaceholder') || '例如:美国、日本、英国',
inputValue: countryData.value, // 预填充当前值
inputValidator: (value) => {
if (!value || value.trim() === '') {
return t('workbenchesSetup.countryRequired') || t('hostsList.countryRequired') || '请输入国家名称'
}
return true
}
}
).then(({ value }) => {
setCountryManually(value, t)
return value.trim() // 返回新的国家名称
})
}
return {
// 状态
countryData,
countryDataEN,
isLoading,
hasInitialized,
lastFetchTime,
// 方法
fetchCountryInfo,
refreshCountry,
showCountryInputDialog,
initCountryInfo,
setCountryManually,
showEditCountryDialog,
}
})

View File

@@ -1,91 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getActiveNotices } from '@/api/notice'
export const useNoticeStore = defineStore('notice', () => {
// 状态
const notices = ref([])
const isLoading = ref(false)
const dismissedIds = ref([]) // 当前会话已关闭的公告 ID
const lastFetchTime = ref(null)
const useMock = ref(false) // 后台接口就绪后改为 false
// 过滤已关闭公告后的有效列表
const activeNotices = computed(() =>
notices.value.filter(n => !dismissedIds.value.includes(n.id))
)
// info 或无 category 的公告 → 滚动栏显示 title
const infoNotices = computed(() =>
activeNotices.value.filter(n => !n.category || n.category === 'info')
)
// danger / warning 的公告 → 弹窗显示 title + content
const alertNotices = computed(() =>
activeNotices.value.filter(n => n.category === 'danger' || n.category === 'warning')
)
// 是否有可显示的公告
const hasNotices = computed(() => activeNotices.value.length > 0)
/**
* 从后台拉取公告
* 全局 axios 拦截器在 code==0 时返回 response.data.data即数组本身
*/
const fetchNotices = async () => {
if (isLoading.value) return
if (useMock.value) {
loadMockNotices()
return
}
isLoading.value = true
try {
const res = await getActiveNotices()
console.log('[NoticeStore] 获取公告', res)
notices.value = Array.isArray(res) ? res.filter(n => !n.deleted) : []
lastFetchTime.value = Date.now()
} catch (error) {
console.error('[NoticeStore] 获取公告失败:', error)
} finally {
isLoading.value = false
}
}
/**
* 关闭某条公告(仅当前会话生效)
*/
const dismissNotice = (id) => {
if (!dismissedIds.value.includes(id)) {
dismissedIds.value.push(id)
}
}
/**
* 加载 Mock 数据(后台接口未就绪时使用)
*/
const loadMockNotices = () => {
notices.value = [
{ id: 1, title: 'YOLO 系统公告', content: '<p>欢迎使用 Yolo 系统,如有问题请联系管理员。</p>', category: 'info' },
{ id: 2, title: '系统维护通知', content: '<p>系统将于本周六凌晨 2:00-4:00 进行维护升级,届时服务将暂停,请提前做好安排。</p>', category: 'warning' },
{ id: 3, title: '紧急安全通知', content: '<p>请所有用户立即更新客户端至最新版本。</p>', category: 'danger' },
]
lastFetchTime.value = Date.now()
}
return {
// 状态
notices,
activeNotices,
infoNotices,
alertNotices,
hasNotices,
isLoading,
useMock,
// 方法
fetchNotices,
dismissNotice,
loadMockNotices,
}
})

View File

@@ -1,62 +0,0 @@
import { defineStore } from 'pinia'
export const pkNoticeStore = defineStore('pkNoticeNum', {
state: () => {
return { data: { num: 0 } }
},
actions: {
increment() {
this.data.num++
}
}
})
export const pkTokenStore = defineStore('pkToken', {
state: () => {
return { token: '' }
},
actions: {
setToken(token) {
this.token = token
}
}
})
export const pkUserStore = defineStore('pkUser', {
state: () => {
return { user: {} }
},
actions: {
setUser(user) {
this.user = user
}
}
})
export const pkIMloginStore = defineStore('pkIMlogin', {
state: () => {
return { IMstate: false }
},
actions: {
setIMstate(state) {
this.IMstate = state
}
}
})
export const pkUnreadStore = defineStore('pkUnread', {
state: () => {
return { count: 0 }
},
actions: {
setCount(count) {
this.count = count
},
decrease(num = 1) {
this.count = Math.max(0, this.count - num)
},
clear() {
this.count = 0
}
}
})

View File

@@ -5,7 +5,7 @@
/* 自定义样式 */
:root {
--sidebar-width: 200px;
--color-bg-dark: #0f172a;
--color-bg-sidebar: #1e293b;
--color-accent: #38bdf8;
--color-text: #e2e8f0;

View File

@@ -77,34 +77,10 @@ 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 }>
showViews: () => Promise<{ success: boolean }>
warmUpViews: () => Promise<{ success: boolean; error?: string }>
switchTab: (tab: TabId) => Promise<{ success: boolean; currentTab?: TabId; error?: string }>
switchToView: (viewId: number) => Promise<{ success: boolean; currentViewId?: number; error?: string }>
getCurrentTab: () => Promise<TabId>
@@ -121,17 +97,6 @@ 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>
@@ -146,9 +111,7 @@ export interface ElectronAPI {
loadAIConfig: () => Promise<Record<string, unknown>>
loadAnchorData: () => Promise<unknown[]>
saveAnchorData: (data: unknown[]) => Promise<{ success: boolean }>
openShop: (url: string) => Promise<{ success: boolean; error?: string }>
hideShop: () => Promise<{ success: boolean; error?: string }>
setSidebarWidth: (width: number) => Promise<{ success: boolean }>
addAnchorData: (data: { anchors: { anchorId: string; country: string; hostsLevel: string }[]; invitationType: number }) => Promise<{ success: boolean; added?: number; skipped?: number; error?: string }>
saveRunConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
loadRunConfig: () => Promise<Record<string, unknown> | null>
@@ -160,7 +123,6 @@ export interface ElectronAPI {
// 打招呼统计
getGreetingStats: () => Promise<GreetingStats>
getRepliedSessions: () => Promise<Array<{ name: string; id: string }>>
// 获取打招呼内容
fetchPrologue: () => Promise<{ success: boolean; data?: string[]; error?: string }>
@@ -179,49 +141,13 @@ 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
onUpdateProgress: (callback: (progress: UpdateProgress) => void) => () => void
onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void
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 }>
controlCheckTask: (payload: { isRunning: boolean; model: boolean }) => 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,22 +4,27 @@
*/
import axios from 'axios'
import { getToken } from '@/utils/storage'
import router from '@/router'
import { ElMessage } from 'element-plus';
import { usePythonBridge, } from '@/utils/pythonBridge'
import { ENV } from '@/config'
const { stopScript } = usePythonBridge();
// 请求地址前缀
const baseURL = ENV.API_BASE_URL
function emitAuthExpired(code, message) {
window.dispatchEvent(new CustomEvent('auth-expired', {
detail: { code, message }
}))
let baseURL = ''
if (process.env.NODE_ENV === 'development') {
// 生产环境
// baseURL = "https://api.tkpage.yolozs.com"
baseURL = "http://192.168.2.22:8101"
// baseURL = "https://crawlclient.api.yolozs.com"
} else {
// 测试环境
// baseURL = "http://120.26.251.180:8085/"
// 开发环境
baseURL = "https://crawlclient.api.yolozs.com"
// baseURL = "http://api.tkpage.vvtiktok.cn"
}
// 请求拦截器
@@ -49,12 +54,10 @@ axios.interceptors.response.use((response) => {
return response.data.data
} else if (response.data.code == 40400) {
stopScript();
emitAuthExpired(response.data.code, response.data.message)
router.push('/')
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,143 +0,0 @@
import { isElectron } from '@/utils/electronBridge'
const HOST_LIST_MIN_COUNT_KEY = 'host_list_dialog_min_count'
const HOST_LIST_GATE_ENABLED_SESSION_KEY = 'host_list_dialog_gate_enabled_session'
const HOST_LIST_GATE_STATE_SESSION_KEY = 'host_list_dialog_gate_state_session'
const HOST_LIST_MONITOR_INTERVAL_MS = 30000
let monitorTimer = null
let syncPromise = null
function getElectronAPI() {
if (typeof window === 'undefined') return null
return window.electronAPI || null
}
function getGateEnabled() {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(HOST_LIST_GATE_ENABLED_SESSION_KEY) === 'true'
}
function getLowerLimit() {
if (typeof window === 'undefined') return 5
const raw = Number(localStorage.getItem(HOST_LIST_MIN_COUNT_KEY))
return Number.isFinite(raw) && raw >= 0 ? raw : 5
}
function getPersistedGateState() {
if (typeof window === 'undefined') return null
const raw = sessionStorage.getItem(HOST_LIST_GATE_STATE_SESSION_KEY)
if (raw === 'true') return true
if (raw === 'false') return false
return null
}
function persistGateState(value) {
if (typeof window === 'undefined') return
if (value === null || value === undefined) {
sessionStorage.removeItem(HOST_LIST_GATE_STATE_SESSION_KEY)
return
}
sessionStorage.setItem(HOST_LIST_GATE_STATE_SESSION_KEY, String(value))
}
function getFilteredHostCount(hosts, filters) {
const excludedLevels = Array.isArray(filters?.hostsLevelList) ? filters.hostsLevelList : []
const minOnlineFans = Number(filters?.minOnlineFans) || 0
const maxOnlineFans = Number(filters?.maxOnlineFans) || 0
return hosts.filter((host) => {
if (filters?.gold === false && host.invitationType === 2) return false
if (filters?.ordinary === false && host.invitationType === 1) return false
if (minOnlineFans > 0 && host.onlineFans !== undefined && host.onlineFans < minOnlineFans) {
return false
}
if (maxOnlineFans > 0 && host.onlineFans !== undefined && host.onlineFans > maxOnlineFans) {
return false
}
if (excludedLevels.length > 0 && host.hostsLevel && excludedLevels.includes(host.hostsLevel)) {
return false
}
return true
}).length
}
export async function syncHostListGateDecision(forceSync = false) {
if (!isElectron()) return
if (!getGateEnabled()) return
if (syncPromise) return syncPromise
const electronAPI = getElectronAPI()
if (!electronAPI?.getAutomationConfig || !electronAPI?.loadAnchorData || !electronAPI?.tk?.controlCheckTask) {
return
}
syncPromise = (async () => {
const config = await electronAPI.getAutomationConfig()
const hosts = await electronAPI.loadAnchorData()
const filters = config?.filters || {}
const hostCount = getFilteredHostCount(hosts || [], filters)
const upperLimit = Number(filters.maxAnchorCount)
const lowerLimit = getLowerLimit()
const lastGateState = getPersistedGateState()
let nextState = null
if (Number.isFinite(upperLimit) && upperLimit >= 0 && hostCount >= upperLimit) {
nextState = false
} else if (Number.isFinite(lowerLimit) && lowerLimit >= 0 && hostCount < lowerLimit) {
nextState = true
} else if (lastGateState !== null) {
nextState = lastGateState
} else if (forceSync) {
nextState = true
}
if (nextState === null) return
if (!forceSync && lastGateState === nextState) return
const result = await electronAPI.tk.controlCheckTask({
isRunning: Boolean(nextState),
model: true
})
if (result?.success === false) {
console.error('[HostListGateMonitor] controlCheckTask 调用失败:', result.error)
return
}
persistGateState(nextState)
console.log('[HostListGateMonitor] 门控状态已更新:', nextState, '当前数量:', hostCount)
})().catch((error) => {
console.error('[HostListGateMonitor] 同步门控状态失败:', error)
}).finally(() => {
syncPromise = null
})
return syncPromise
}
export function ensureHostListGateMonitorRunning() {
if (!isElectron()) return
if (monitorTimer) return
monitorTimer = setInterval(() => {
if (!getGateEnabled()) {
stopHostListGateMonitor()
return
}
void syncHostListGateDecision()
}, HOST_LIST_MONITOR_INTERVAL_MS)
}
export function stopHostListGateMonitor() {
if (!monitorTimer) return
clearInterval(monitorTimer)
monitorTimer = null
}

View File

@@ -1,524 +0,0 @@
// 国家数据 - 完整补全版本
const zhCountries = {
"CN": "中国",
"US": "美国",
"JP": "日本",
"KR": "韩国",
"GB": "英国",
"DE": "德国",
"FR": "法国",
"IT": "意大利",
"ES": "西班牙",
"RU": "俄罗斯",
"BR": "巴西",
"IN": "印度",
"AU": "澳大利亚",
"CA": "加拿大",
"MX": "墨西哥",
"ID": "印度尼西亚",
"TH": "泰国",
"VN": "越南",
"MY": "马来西亚",
"SG": "新加坡",
"PH": "菲律宾",
"TW": "中国台湾",
"HK": "中国香港",
"MO": "中国澳门",
"AE": "阿联酋",
"SA": "沙特阿拉伯",
"TR": "土耳其",
"EG": "埃及",
"ZA": "南非",
"NG": "尼日利亚",
"AR": "阿根廷",
"CL": "智利",
"CO": "哥伦比亚",
"PE": "秘鲁",
"PL": "波兰",
"NL": "荷兰",
"BE": "比利时",
"SE": "瑞典",
"NO": "挪威",
"DK": "丹麦",
"FI": "芬兰",
"AT": "奥地利",
"CH": "瑞士",
"PT": "葡萄牙",
"GR": "希腊",
"CZ": "捷克",
"RO": "罗马尼亚",
"HU": "匈牙利",
"UA": "乌克兰",
"IL": "以色列",
"PK": "巴基斯坦",
"BD": "孟加拉国",
"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 = {
"CN": "China",
"US": "United States",
"JP": "Japan",
"KR": "South Korea",
"GB": "United Kingdom",
"DE": "Germany",
"FR": "France",
"IT": "Italy",
"ES": "Spain",
"RU": "Russia",
"BR": "Brazil",
"IN": "India",
"AU": "Australia",
"CA": "Canada",
"MX": "Mexico",
"ID": "Indonesia",
"TH": "Thailand",
"VN": "Vietnam",
"MY": "Malaysia",
"SG": "Singapore",
"PH": "Philippines",
"TW": "Taiwan",
"HK": "Hong Kong",
"MO": "Macao",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"EG": "Egypt",
"ZA": "South Africa",
"NG": "Nigeria",
"AR": "Argentina",
"CL": "Chile",
"CO": "Colombia",
"PE": "Peru",
"PL": "Poland",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"NO": "Norway",
"DK": "Denmark",
"FI": "Finland",
"AT": "Austria",
"CH": "Switzerland",
"PT": "Portugal",
"GR": "Greece",
"CZ": "Czech Republic",
"RO": "Romania",
"HU": "Hungary",
"UA": "Ukraine",
"IL": "Israel",
"PK": "Pakistan",
"BD": "Bangladesh",
"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"
}
// 创建中文名称到国家代码的映射
const zhNameToCode = {}
Object.entries(zhCountries).forEach(([code, zhName]) => {
zhNameToCode[zhName] = code
})
// 获取国家名称数组value 固定为中文名称label 根据当前语言变化
export function getCountryNamesArray() {
const currentLanguage = localStorage.getItem('language') || 'ZH'
return Object.entries(zhCountries).map(([code, zhName]) => ({
value: zhName,
label: currentLanguage === 'ZH' ? zhName : enCountries[code]
}))
}
// 根据中文名称获取当前语言环境的翻译
export function translateCountryName(zhName) {
const currentLanguage = localStorage.getItem('language') || 'ZH'
const code = zhNameToCode[zhName]
if (!code) return zhName
return currentLanguage === 'ZH' ? zhName : enCountries[code]
}

View File

@@ -1,312 +0,0 @@
/**
* PK Mini 模块专用 GoEasy 实例
*
* 使用前请确保在 src/config/pk-mini.js 中将 GOEASY_ENABLED 设为 true
*/
import GoEasy from 'goeasy'
import { pkIMloginStore } from '@/stores/pk-mini/notice.js'
import { PK_MINI_CONFIG, isGoEasyEnabled } from '@/config/pk-mini.js'
// PK Mini 模块专用 GoEasy 实例
let pkGoEasyInstance = null
// 获取或创建 PK GoEasy 实例
export function getPkGoEasy() {
if (!isGoEasyEnabled()) {
console.warn('[PK GoEasy] GoEasy 未启用,请在 src/config/pk-mini.js 中设置 GOEASY_ENABLED: true')
return null
}
if (!pkGoEasyInstance) {
console.log("GoEasy", GoEasy)
pkGoEasyInstance = GoEasy.getInstance({
host: PK_MINI_CONFIG.GOEASY.HOST,
appkey: PK_MINI_CONFIG.GOEASY.APP_KEY,
modules: ['im']
})
}
return pkGoEasyInstance
}
// 初始化 PK GoEasy (在 PkMiniWorkbench 挂载时调用)
export function initPkGoEasy() {
if (!isGoEasyEnabled()) {
console.warn('[PK GoEasy] GoEasy 未启用')
return null
}
return getPkGoEasy()
}
// 链接 IM (登录 IM)
export function goEasyLink(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const counter = pkIMloginStore()
return new Promise((resolve, reject) => {
goeasy.connect({
id: data.id,
data: { avatar: data.avatar, nickname: data.nickname },
otp: data.key,
onSuccess: function () {
console.log('PK IM 连接成功')
counter.setIMstate(true)
data.onSuccess?.()
resolve(true)
},
onFailed: function (error) {
console.log('PK IM 连接失败,错误码:' + error.code + ',错误信息:' + error.content)
data.onFailed?.(error)
reject(error)
},
onProgress: function (attempts) {
console.log('PK IM 正在重连中...')
data.onProgress?.(attempts)
}
})
})
}
// 断开 IM
export function goEasyDisConnect() {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
return new Promise((resolve, reject) => {
goeasy.disconnect({
onSuccess: function () {
resolve(true)
},
onFailed: function (error) {
console.log('断开失败, code:' + error.code + ',error:' + error.content)
reject(error)
}
})
})
}
// 获取会话列表
export function goEasyGetConversations() {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.latestConversations({
onSuccess: function (result) {
console.log('会话列表', result)
resolve(result)
},
onFailed: function (error) {
console.log('获取会话列表失败,错误码:' + error.code + ' content:' + error.content)
reject(error)
}
})
})
}
// 获取指定会话的消息列表
export function goEasyGetMessages(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.history({
id: data.id,
type: GoEasy.IM_SCENE.PRIVATE,
lastTimestamp: data.timestamp,
limit: 30,
onSuccess: function (result) {
resolve(result.content)
},
onFailed: function (error) {
console.log('获取消息列表失败,错误码:' + error.code + ' content:' + error.content)
reject(error)
}
})
})
}
// 发送文本消息
export function goEasySendMessage(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
console.log("```````````````````````名称``````````````````````", data.nickname);
const goeasy = getPkGoEasy()
const im = goeasy.im
let textMessage = im.createTextMessage({
text: data.text,
to: {
type: GoEasy.IM_SCENE.PRIVATE,
id: data.id,
data: { avatar: data.avatar, nickname: data.nickname }
}
})
return new Promise((resolve, reject) => {
im.sendMessage({
message: textMessage,
onSuccess: function () {
resolve(textMessage)
},
onFailed: function (error) {
console.log('Failed to send private messagecode:' + error.code + ' ,error ' + error.content)
reject(error)
}
})
})
}
// 发送图片消息
export function goEasySendImageMessage(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
const message = im.createImageMessage({
file: data.imagefile,
to: {
type: GoEasy.IM_SCENE.PRIVATE,
id: data.id,
data: { avatar: data.avatar, nickname: data.nickname }
}
})
return new Promise((resolve, reject) => {
im.sendMessage({
message: message,
onSuccess: function () {
resolve(message)
},
onFailed: function (error) {
console.log('Failed to send messagecode:' + error.code + ',error' + error.content)
reject(error)
}
})
})
}
// 发送 PK 消息
export function goEasySendPKMessage(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
const customData = {
id: data.msgid,
pkIdA: data.pkIdA,
pkIdB: data.pkIdB
}
const order = {
customData: customData,
link: 'https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/pk.png',
text: 'PK'
}
const customMessage = im.createCustomMessage({
type: 'pk',
payload: order,
to: {
type: GoEasy.IM_SCENE.PRIVATE,
id: data.id,
data: { avatar: data.avatar, nickname: data.nickname }
}
})
return new Promise((resolve, reject) => {
im.sendMessage({
message: customMessage,
onSuccess: function () {
resolve(customMessage)
},
onFailed: function (error) {
console.log('Failed to send messagecode:' + error.code + ',error' + error.content)
reject(error)
}
})
})
}
// 消息已读
export function goEasyMessageRead(data) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.markMessageAsRead({
id: data.id,
type: GoEasy.IM_SCENE.PRIVATE,
onSuccess: function () {
resolve(true)
},
onFailed: function (error) {
console.log('标记私聊已读失败', error)
reject(error)
}
})
})
}
// 撤回消息
export function goEasyRecallMessage(message) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.recallMessage({
messages: [message],
onSuccess: function () {
resolve(true)
},
onFailed: function (error) {
console.log('撤回失败, code:' + error.code + ' content:' + error.content)
reject(error)
}
})
})
}
// 删除会话
export function goEasyRemoveConversation(conversation) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.removeConversation({
conversation: conversation,
onSuccess: function () {
resolve(true)
},
onFailed: function (error) {
console.log('删除会话失败code:' + error.code + ',error:' + error.content)
reject(error)
}
})
})
}
// 导出 GoEasy 常量(用于事件监听)
export { GoEasy }

View File

@@ -1,79 +0,0 @@
// PK Mini 模块专用 Storage 工具
// 使用 pk_mini_ 前缀避免与主项目冲突
const PREFIX = 'pk_mini_'
export function setStorage(key, value) {
localStorage.setItem(PREFIX + key, JSON.stringify(value))
}
export function getStorage(key) {
const value = localStorage.getItem(PREFIX + key)
if (value) {
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
return null
}
export function getPromiseStorage(key) {
return new Promise((resolve, reject) => {
const value = getStorage(key)
if (value) {
resolve(value)
} else {
reject(new Error('Key not found: ' + key))
}
})
}
export function clearStorage(key) {
localStorage.removeItem(PREFIX + key)
}
export function getPromiseSessionStorage(key) {
return new Promise((resolve, reject) => {
const value = sessionStorage.getItem(PREFIX + key)
if (value) {
try {
resolve(JSON.parse(value))
} catch (e) {
resolve(value)
}
} else {
reject(new Error('Key not found: ' + key))
}
})
}
export function setSessionStorage(key, value) {
sessionStorage.setItem(PREFIX + key, JSON.stringify(value))
}
// 获取主项目的用户数据(用于获取 token 和用户信息)
// 主项目使用 'user' 键存储用户信息
export function getMainUserData() {
// 优先从 'user' 获取(主项目当前使用的键)
let userData = localStorage.getItem('user')
if (userData) {
try {
return JSON.parse(userData)
} catch (e) {
// 解析失败,继续尝试其他键
}
}
// 兼容:尝试从 'user_data' 获取(旧键名)
userData = localStorage.getItem('user_data')
if (userData) {
try {
return JSON.parse(userData)
} catch (e) {
return null
}
}
return null
}

View File

@@ -1,29 +0,0 @@
// 时间戳转换为本地时间,格式为 YYYY/MM/DD hh:mm
export function TimestamptolocalTime(date) {
if (!date || isNaN(date)) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
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

@@ -1,27 +0,0 @@
// 记录上次调用时间
let lastTimestamp = null
/**
* 比较时间戳是否超过 5 分钟
* @param {number} timestamp - 要比较的时间戳(毫秒)
* @returns {boolean} - 是否超过 5 分钟或第一次调用
*/
export function timeDisplay(timestamp) {
// 第一次调用直接返回 true
if (lastTimestamp === null) {
lastTimestamp = timestamp
return true
}
// 计算时间差(毫秒)
const timeDiff = Math.abs(timestamp - lastTimestamp)
lastTimestamp = timestamp
// 5 分钟 = 300,000 毫秒
return timeDiff > 300000
}
// 重置时间戳(在组件卸载时调用)
export function resetTimeDisplay() {
lastTimestamp = null
}

View File

@@ -36,11 +36,7 @@ export function usePythonBridge() {
if (!inElectron) return;
await window.electronAPI.tk.loginTikTok();
};
// loginTikTok
const loginBigTikTok = async () => {
if (!inElectron) return;
await window.electronAPI.tk.loginBigTikTok();
};
// loginBackStage
const loginBackStage = async (data) => {
if (!inElectron) return;
@@ -57,22 +53,16 @@ export function usePythonBridge() {
await window.electronAPI.tk.visitAnchor(id);
};
// 查询后台登录状态(合并接口,通过 account 参数区分)
// account: 公会账号,不传则返回所有账号状态
const backStageloginStatus = async (account) => {
// backStageloginStatus
const backStageloginStatus = async () => {
if (!inElectron) return null;
try {
const res = await window.electronAPI.tk.checkBackStageLoginStatus(account);
return typeof res === 'string' ? JSON.parse(res) : res;
} catch (e) {
console.error('backStageloginStatus error:', e);
return null;
}
return await window.electronAPI.tk.checkBackStageLoginStatus();
};
// 兼容旧接口:查询副账号登录状态(内部调用合并后的接口)
const backStageloginStatusCopy = async (account) => {
return await backStageloginStatus(account);
// backStageloginStatusCopy
const backStageloginStatusCopy = async () => {
if (!inElectron) return null;
return await window.electronAPI.tk.checkBackStageLoginStatusCopy();
};
// exportToExcel
@@ -106,14 +96,6 @@ export function usePythonBridge() {
await window.electronAPI.tk.controlTask(data);
};
const controlCheckTask = async (isRunning, model) => {
if (!inElectron) return { success: false, error: 'Not in Electron' };
return await window.electronAPI.tk.controlCheckTask({
isRunning: Boolean(isRunning),
model: Boolean(model)
});
};
const getBrotherInfo = async () => {
if (!inElectron) return { total: 0, valid: 0 };
const res = await window.electronAPI.tk.getBrotherInfo();
@@ -143,17 +125,9 @@ export function usePythonBridge() {
await window.electronAPI.tk.openRoom(id);
};
// Clipboard helper - 优先使用 Python RPCfallback 到浏览器 API
// Clipboard helper - maybe use navigator.clipboard directly in Vue component?
// Original used python bridge for clipboard.
const setClipboards = async (text) => {
if (inElectron) {
try {
const result = await window.electronAPI.tk.setClipboard(text);
if (result.success) return true;
} catch (e) {
console.warn('Electron clipboard failed, fallback to browser:', e);
}
}
// Fallback to browser API
try {
await navigator.clipboard.writeText(text);
return true;
@@ -163,84 +137,11 @@ export function usePythonBridge() {
}
}
// ========== 新增接口 ==========
// 启动大哥监控TikTok 登录)
const startBrotherMonitor = async () => {
if (!inElectron) return { success: false, error: 'Not in Electron' };
try {
return await window.electronAPI.tk.startBrotherMonitor();
} catch (e) {
console.error('startBrotherMonitor error:', e);
return { success: false, error: String(e) };
}
};
// 获取大哥模块 TikTok 登录状态
const getBrotherLoginStatus = async () => {
if (!inElectron) return { isLoggedIn: false };
try {
const res = await window.electronAPI.tk.getBrotherLoginStatus();
return JSON.parse(res);
} catch (e) {
console.error('getBrotherLoginStatus error:', e);
return { isLoggedIn: false };
}
};
// 打开大哥个人主页
const visitGifter = async (data) => {
if (!inElectron) return { success: false };
try {
// data 可以是 { id: 'xxx' } 或 { uniqueId: 'xxx' }
return await window.electronAPI.tk.visitGifter(JSON.stringify(data));
} catch (e) {
console.error('visitGifter error:', e);
return { success: false, error: String(e) };
}
};
// 关闭所有浏览器
const closeAllBrowsers = async () => {
if (!inElectron) return { success: false, error: 'Not in Electron' };
try {
return await window.electronAPI.tk.closeAllBrowsers();
} catch (e) {
console.error('closeAllBrowsers error:', e);
return { success: false, error: String(e) };
}
};
// 加密存储账号信息
const storageAccountInfo = async (data) => {
if (!inElectron) return { success: false, error: 'Not in Electron' };
try {
// data: { key: string, data: object }
return await window.electronAPI.tk.storageAccount(JSON.stringify(data));
} catch (e) {
console.error('storageAccountInfo error:', e);
return { success: false, error: String(e) };
}
};
// 解密读取账号信息
const readAccountInfo = async (key) => {
if (!inElectron) return { status: 'error', message: 'Not in Electron', data: null };
try {
const res = await window.electronAPI.tk.readAccount(JSON.stringify({ key }));
return JSON.parse(res);
} catch (e) {
console.error('readAccountInfo error:', e);
return { status: 'error', message: String(e), data: null };
}
};
return {
fetchDataConfig,
fetchDataCount,
loginBackStage,
loginTikTok,
loginBigTikTok,
givePyAnchorId,
backStageloginStatus,
backStageloginStatusCopy,
@@ -250,19 +151,11 @@ export function usePythonBridge() {
getTkLoginStatus,
// New Fan Workbench exports
controlTask,
controlCheckTask,
getBrotherInfo,
Specifystreaming,
storageSetInfos,
readSetInfos,
openAnchorIdRooms,
setClipboards,
// 新增接口
startBrotherMonitor,
getBrotherLoginStatus,
visitGifter,
closeAllBrowsers,
storageAccountInfo,
readAccountInfo
setClipboards
};
}

View File

@@ -58,28 +58,26 @@ const PERMISSIONS_KEY = 'user_permissions';
/**
* 存储权限信息
* @param {Object} permissions - 权限对象 { bigBrother, crawl, webAi, autotk }
* @param {Object} permissions - 权限对象 { bigBrother, crawl, webAi }
*/
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, autotk }
* @returns {Object} 权限对象 { bigBrother, crawl, webAi }
*/
export function getPermissions() {
try {
const permissions = JSON.parse(localStorage.getItem(PERMISSIONS_KEY));
return permissions || { bigBrother: 0, crawl: 0, webAi: 0, autotk: 0 };
return permissions || { bigBrother: 0, crawl: 0, webAi: 0 };
} catch {
return { bigBrother: 0, crawl: 0, webAi: 0, autotk: 0 };
return { bigBrother: 0, crawl: 0, webAi: 0 };
}
}

Some files were not shown because too many files have changed in this diff Show More