41 Commits
old ... v2.4.3

Author SHA1 Message Date
d517c1573d 2.4.3 2026-03-23 14:46:34 +08:00
52e44bd857 删除深色模式的样式 2026-03-20 16:40:58 +08:00
1a45638f32 历史回复 2026-03-18 20:33:28 +08:00
fad174824a 更新回复列表 2026-03-18 09:09:51 +08:00
a414d2b003 权限刷新 2026-03-17 10:30:15 +08:00
f6ba1a9dc2 修复指定直播间bug 2026-03-16 14:44:32 +08:00
7e3b7448fa 剪切板 mac前去下载 2026-03-13 18:01:38 +08:00
44df456240 mac不强制更新 2026-03-13 17:48:00 +08:00
1dd64988ba 2.3.9历史回复数据 2026-03-13 16:08:34 +08:00
99b029377a 2.3.9 历史回复列表 2026-03-12 18:03:30 +08:00
919f1ad650 添加 邀请类型 关联账号 2026-03-12 16:54:48 +08:00
bfd8748554 添加国家独立设置 2026-03-12 09:59:19 +08:00
b4efa96416 国际化补全 2026-03-11 13:53:11 +08:00
3d1a6f3870 编辑国家 获取地区国家 2026-03-09 16:29:41 +08:00
1d9e0bb145 优化 2026-03-09 11:26:01 +08:00
ab645588ac 2.3.0 2026-03-05 14:47:02 +08:00
89d3487c02 销售名片 2026-03-03 21:57:18 +08:00
e1c132ead9 5in1 优化若干bug 2026-03-03 14:36:25 +08:00
b6f8586efa 修复退出应用不关闭浏览器,和删除主播的调用优化 2026-02-28 16:13:35 +08:00
3276e7d5cb 新增商店外链 2026-02-28 14:32:26 +08:00
13fa7ac04c 优化 2026-02-27 19:17:10 +08:00
4780e15ffa 右键复制撤回功能 2026-02-26 18:47:39 +08:00
fdb4a56197 添加已读未读功能 2026-02-26 18:34:00 +08:00
7ff2382025 聊天页面上面的用户名 2026-02-26 18:16:19 +08:00
00489b5c27 红点问题修复 2026-02-26 17:01:00 +08:00
a0b476da7a 删除确认框 2026-02-26 16:43:50 +08:00
e1e4c8f531 im连接失败提示 2026-02-26 16:29:11 +08:00
35f08b0c99 im通讯修复,遗留问题发送消息后名字变成自己的 2026-02-26 15:57:22 +08:00
5c1911314f pk优化版 2026-02-26 13:15:19 +08:00
d4c0dcf6b1 注册功能 2026-02-24 15:15:16 +08:00
adc5a4d5fe yolo商店 2026-02-12 13:03:52 +08:00
c2c9b239a3 菜单栏加宽 2026-02-11 18:30:45 +08:00
92780ef52e 测试版 2026-02-11 14:49:18 +08:00
bef5c2f437 优化 2026-02-10 19:24:46 +08:00
d59e4c0bb9 修复国家检测bug 2026-02-09 21:04:09 +08:00
658f50cc51 pk 重构ui风格 2026-02-08 20:53:39 +08:00
9f2b9a1997 goeasy移植 2026-02-08 16:35:01 +08:00
76d83fc77e 融合PK头像头像功能 2026-02-08 15:33:10 +08:00
c6435c6db5 优化 2026-02-06 20:03:56 +08:00
c383f9063d 优化 2026-02-06 13:08:27 +08:00
0615b032ff 三合一 2026-02-05 20:57:28 +08:00
105 changed files with 9449 additions and 702 deletions

10
.env.development Normal file
View File

@@ -0,0 +1,10 @@
# 后端 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_SHOP_URL=https://www.tkzyw.com

9
.env.production Normal file
View File

@@ -0,0 +1,9 @@
# 后端 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

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.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

View File

@@ -8,5 +8,6 @@
<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>

6
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "yolo-web-frontend",
"version": "1.0.0",
"dependencies": {
"goeasy": "^2.14.9",
"vue": "^3.5.27"
},
"devDependencies": {
@@ -2100,6 +2101,11 @@
"node": ">=10.13.0"
}
},
"node_modules/goeasy": {
"version": "2.14.9",
"resolved": "https://registry.npmmirror.com/goeasy/-/goeasy-2.14.9.tgz",
"integrity": "sha512-AlHx7PCWfIUWKAvjILJx6AzM1GmquGM1MxxA7gPH6TiR9eTtiieJNk9nIXvVYRm0iTC8AfEnekRY2Clghm9xrA=="
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"goeasy": "^2.14.9",
"vue": "^3.5.27"
},
"devDependencies": {

View File

@@ -4,6 +4,9 @@
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
<template v-else>
<!-- 滚动通知栏登录页和工作台都显示 -->
<NoticeBar />
<!-- 登录页面 -->
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
@@ -11,7 +14,8 @@
<!-- 配置页面 - 使用 v-show 保持状态 -->
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'config'">
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="handleLogout" />
<UpdateNotification />
<!-- 更新通知组件启动时已在 UpdateChecker 检查此处暂不显示 -->
<!-- <UpdateNotification /> -->
</div>
<!-- 浏览器页面 -->
@@ -38,6 +42,8 @@ import ConfigPage from './pages/ConfigPage.vue'
import UpdateChecker from './pages/UpdateChecker.vue'
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import NoticeBar from './components/NoticeBar.vue'
import { useNoticeStore } from './stores/noticeStore'
// Constants
const USER_KEY = 'user_data'
@@ -57,6 +63,10 @@ const automationLogs = ref([])
const isElectronEnv = isElectron()
const isDev = window.location.port === '5173'
// 公告通知
const noticeStore = useNoticeStore()
noticeStore.fetchNotices()
// Lifecycle
onMounted(() => {
// Set Title
@@ -154,6 +164,13 @@ const startHealthCheck = () => {
if (result.success && result.code === 40400) {
alert('当前账号已在其他地方登录,请重新登录')
localStorage.removeItem(USER_KEY)
// 隐藏所有 BrowserView 并停止自动化,防止视图悬浮在登录页上方
try {
await window.electronAPI.hideViews()
await handleStopAll()
} catch (e) {
console.warn('[App] 清理视图失败:', e)
}
currentPage.value = 'login'
}
} catch (error) {
@@ -214,11 +231,19 @@ const handleGoToConfig = async () => {
}
const handleLogout = async () => {
if (isElectron()) {
await window.electronAPI.logout()
}
localStorage.removeItem(USER_KEY)
stopHealthCheck()
currentPage.value = 'login'
localStorage.removeItem(USER_KEY)
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)
}
}
}
const handleStopAll = async () => {

View File

@@ -18,10 +18,14 @@ 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) {
@@ -80,3 +84,12 @@ 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' })
}

6
src/api/notice.js Normal file
View File

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

228
src/api/pk-mini.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 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 })
}

27
src/api/register.js Normal file
View File

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

After

Width:  |  Height:  |  Size: 48 KiB

BIN
src/assets/nav/back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/nav/card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
src/assets/nav/exchange.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
src/assets/nav/nav1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

BIN
src/assets/nav/nav11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/assets/nav/nav2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/assets/nav/nav22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/assets/nav/nav3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/nav/nav33.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/nav/nav4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/nav/nav44.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/assets/nav/nav5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src/assets/nav/nav55.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/assets/nav/nav6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/nav/nav66.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src/assets/nav/phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/assets/nav/yolo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
src/assets/pk-mini/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/pk-mini/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 252 KiB

View File

@@ -14,9 +14,19 @@
<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">
@@ -29,12 +39,32 @@
</div>
</div>
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange" @paste="handlePaste"
placeholder="每行一句打招呼内容..."
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
<!-- 批量导入模式 -->
<div 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" />
<div class="text-xs text-gray-500 mt-2">
提示: 每行一句可直接粘贴多行文本
<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>
</div>
@@ -78,13 +108,15 @@
]">
{{ 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>
@@ -112,7 +144,8 @@
<!-- 当前语言的翻译结果 -->
<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">
@@ -141,22 +174,23 @@
</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 class="p-4 border-t border-gray-200 flex justify-between items-center">
<span class="text-xs text-gray-500">
{{ sentences.filter(Boolean).length }} · 选择 {{ selectedRegions.length }} 个大区 · {{
selectedLanguages.length }} 种语言
</span>
<div class="flex gap-3">
<button @click="onClose"
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleConfirm"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
确定
</button>
</div>
</div>
</div>
</div>
@@ -180,6 +214,7 @@ const REGION_LIST = getRegions()
const sentences = ref([''])
const bulkText = ref('')
const inputMode = ref('bulk') // 'bulk' 或 'individual'
const selectedRegions = ref([])
const translations = ref({})
const activeTab = ref('')
@@ -262,6 +297,7 @@ function loadFromStorage() {
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
} catch (e) {
console.error('加载本地数据失败:', e)
}
@@ -275,6 +311,7 @@ function saveToStorage() {
translations: translations.value,
needTranslate: needTranslate.value,
activeTab: activeTab.value,
inputMode: inputMode.value,
}))
}
@@ -285,6 +322,17 @@ 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
@@ -397,6 +445,7 @@ 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,6 +16,10 @@
<!-- 工具栏 -->
<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"
@@ -32,7 +36,7 @@
<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" />
@@ -116,10 +120,10 @@
<!-- 主播列表 -->
<div class="flex-1 overflow-auto p-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div v-for="host in filteredHosts" :key="host.anchorId" @click="toggleSelect(host.anchorId)"
<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.anchorId) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
selected.has(host.id) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
]">
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
@@ -140,7 +144,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>
@@ -164,6 +168,84 @@
</div>
</div>
</div>
<!-- 添加主播弹窗 -->
<div v-if="showAddDialog" 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="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>
</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
</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>
</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>
</div>
</div>
</Teleport>
</template>
@@ -231,6 +313,57 @@ const maxCount = ref(100)
const selectedLevels = ref(new Set())
const showLevelDropdown = 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' },
]
// 解析输入的主播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) {
@@ -273,6 +406,7 @@ 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) {
@@ -290,7 +424,7 @@ const loadConfig = async () => {
if (config?.filters?.hostsLevelList) {
selectedLevels.value = new Set(config.filters.hostsLevelList)
}
// 加载票/普票过滤配置
// 加载进阶票/普票过滤配置
if (config?.filters?.gold !== undefined) {
filters.value.gold = config.filters.gold
}
@@ -398,7 +532,7 @@ const toggleSelect = (id) => {
}
const selectAll = () => {
selected.value = new Set(filteredHosts.value.map(h => h.anchorId))
selected.value = new Set(filteredHosts.value.map(h => h.id))
}
const selectNone = () => {
@@ -413,22 +547,89 @@ const invertSelect = () => {
selected.value = next
}
const deleteSelected = () => {
const deleteSelected = async () => {
if (!selected.value.size) return
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
const remaining = hosts.value.filter(h => !selected.value.has(h.anchorId))
hosts.value = remaining
selected.value = new Set()
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.anchorId))
selected.value = new Set()
}
} catch (e) {
console.error('[HostListDialog] 删除异常:', e)
hosts.value = hosts.value.filter(h => !selected.value.has(h.anchorId))
selected.value = new Set()
}
} else {
hosts.value = hosts.value.filter(h => !selected.value.has(h.anchorId))
selected.value = new Set()
}
}
const onClose = () => emit('close')
const handleSave = async () => {
if (isElectron()) {
await window.electronAPI.saveAnchorData(JSON.parse(JSON.stringify(hosts.value)))
}
emit('save', hosts.value)
onClose()
}
// 关闭添加弹窗
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

@@ -0,0 +1,302 @@
<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"
:close-on-click-modal="false"
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

@@ -7,7 +7,7 @@
<!-- 无权限时显示遮罩和占位内容 -->
<template v-else>
<!-- 占位背景 - 显示工作台截图作为假界面 -->
<!-- 占位背景 -->
<div class="permission-placeholder">
<img v-if="placeholderImage" :src="placeholderImage" alt="" class="placeholder-image" />
<div v-else class="placeholder-pattern"></div>
@@ -15,18 +15,64 @@
<!-- 权限遮罩层 -->
<div class="permission-mask" ref="maskRef" :data-permission-guard="guardKey">
<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>
<!-- 上方锁提示区域 -->
<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>
<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 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>
</div>
</div>
</div>
@@ -36,61 +82,113 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { getPermissions } from '@/utils/storage'
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'
const props = defineProps({
/**
* 权限类型: 'bigBrother' | 'crawl' | 'webAi'
*/
permissionKey: {
type: String,
required: true,
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 permissionsData = ref(getPermissions())
const hasAccess = computed(() => {
return permissionsData.value[props.permissionKey] === 1
})
// 定时刷新权限状态防止localStorage被篡改后状态不同步
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,
});
}
}
const shuffleContacts = () => {
updateVisibleContacts(true)
}
let permissionCheckInterval = null
const refreshPermissions = () => {
permissionsData.value = getPermissions()
}
// MutationObserver 监测DOM篡改
let observer = null
const setupDOMProtection = () => {
@@ -98,16 +196,13 @@ const setupDOMProtection = () => {
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)
@@ -128,30 +223,27 @@ const setupDOMProtection = () => {
}
onMounted(() => {
// 定时检查权限每2秒
updateVisibleContacts(false)
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>
@@ -162,7 +254,6 @@ watch(hasAccess, (newVal) => {
height: 100%;
}
/* 占位背景 - 无权限时显示,防止删除遮罩后看到内容 */
.permission-placeholder {
position: absolute;
inset: 0;
@@ -198,11 +289,21 @@ watch(hasAccess, (newVal) => {
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
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 {
@@ -219,14 +320,8 @@ 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 {
@@ -276,4 +371,136 @@ 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,5 +1,6 @@
<template>
<aside class="w-[200px] h-full bg-gradient-to-b from-white to-gray-50 border-r border-gray-200 flex flex-col shadow-sm">
<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">
<!-- 返回和停止按钮 -->
<div class="m-3 mb-0 flex gap-2">
<button @click="onGoBack"
@@ -71,7 +72,8 @@
<!-- 详细统计 -->
<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>
@@ -81,14 +83,17 @@
暂无统计数据
</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>
@@ -123,18 +128,114 @@
未启动任务
</div>
<!-- 统计数据 -->
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1">
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1 relative">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已打招呼</span>
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已发邀请</span>
<span class="text-purple-600 font-medium">{{ greetingStats.inviteCount }} </span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">已回复</span>
<span class="text-emerald-600 font-medium">{{ greetingStats.replyCount || 0 }} </span>
<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>
</div>
</div>
</div>
@@ -143,7 +244,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 },
@@ -154,11 +255,104 @@ const props = defineProps({
type: Object,
default: () => ({ greetingCount: 0, inviteCount: 0 })
},
automationLogs: { type: Array, default: () => [] }
automationLogs: { type: Array, default: () => [] },
sidebarWidth: { type: Number, default: 144 }
})
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) => {
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器使用 Clipboard API
navigator.clipboard.writeText(id).then(() => {
showCopySuccess()
}).catch(err => {
console.error('复制失败:', err)
// 回退到传统方法
fallbackCopyTextToClipboard(id)
})
} else {
// 传统方法
fallbackCopyTextToClipboard(id)
}
}
// 回退复制方法(兼容旧浏览器)
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,10 +38,31 @@
<p v-if="updateInfo.releaseNotes" class="text-gray-500 text-xs bg-gray-50 p-2 rounded-lg line-clamp-3">
{{ updateInfo.releaseNotes }}
</p>
<button @click="downloadUpdate"
class="w-full py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium hover:from-blue-600 hover:to-blue-700 transition-all shadow-md hover:shadow-lg">
下载更新
</button>
<!-- 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>
</div>
<!-- 下载中 -->
@@ -107,6 +128,7 @@ const {
progress,
error,
currentVersion,
isMac,
checkForUpdates,
downloadUpdate,
installUpdate,
@@ -120,4 +142,13 @@ 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

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

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

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

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

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

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

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

@@ -0,0 +1,883 @@
<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%"
format="YYYY/MM/DD HH:mm"
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 } 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
// 表单数据
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时间不能早于当前时间')
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

@@ -0,0 +1,151 @@
<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>
</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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getIntegralDetail } from '@/api/pk-mini'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
function getUserId(user) {
return user?.id || user?.userId || user?.uid || null
}
const currentUser = ref({})
const pointsList = ref([])
const page = ref(0)
const formatTime = TimestamptolocalTime
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(() => {
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: center;
align-items: center;
}
.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;
}
</style>

View File

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

10
src/config/index.js Normal file
View File

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

28
src/config/pk-mini.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 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,85 +1,141 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { isElectron } from '../utils/electronBridge'
// NOTE: Since we are using JS, we don't have interfaces, but structure remains same.
// ============== 单例状态 ==============
// 在函数外部定义,确保所有组件共享同一个状态
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
}
/**
* 应用更新 Hook (Vue Composable)
* 管理更新状态、进度和操作
* 单例模式:所有组件共享同一个状态和监听器
* 注意:此 Composable 仅在 Electron 环境中有效
*/
export function useUpdate() {
const status = ref('idle')
const updateInfo = ref(null)
const progress = ref(null)
const error = ref(null)
const currentVersion = ref('')
// 获取当前版本
const fetchVersion = () => {
if (!isElectron()) {
currentVersion.value = 'web'
return
}
window.electronAPI.getAppVersion().then(v => {
currentVersion.value = v
}).catch(console.error)
}
let unsubList = []
// 监听更新事件
const setupListeners = () => {
if (!isElectron()) return
const api = window.electronAPI
// 正在检查
if (api.onUpdateChecking) {
unsubList.push(api.onUpdateChecking(() => {
status.value = 'checking'
}))
}
// 发现新版本
unsubList.push(api.onUpdateAvailable((info) => {
updateInfo.value = info
status.value = 'available'
error.value = null
}))
// 无可用更新
if (api.onUpdateNotAvailable) {
unsubList.push(api.onUpdateNotAvailable(() => {
status.value = 'idle'
}))
}
unsubList.push(api.onUpdateProgress((prog) => {
progress.value = prog
status.value = 'downloading'
}))
unsubList.push(api.onUpdateDownloaded(() => {
status.value = 'downloaded'
progress.value = null
}))
unsubList.push(api.onUpdateError((err) => {
error.value = err.message
status.value = 'error'
}))
}
// 每个组件挂载时的初始化逻辑
onMounted(() => {
// 检测平台(确保只设置一次)
if (isElectron() && platform.value === 'unknown') {
platform.value = getPlatformInfo()
console.log('[useUpdate] 平台检测结果:', platform.value)
}
fetchVersion()
setupListeners()
})
onUnmounted(() => {
unsubList.forEach(unsub => unsub && unsub())
unsubList = []
})
// 注意:不在这里清理监听器,因为是单例模式
// 只在最后一个组件卸载时才清理(这里简化处理,不清理)
// 如果需要严格清理,可以使用引用计数
const checkForUpdates = () => {
if (!isElectron()) return
@@ -123,6 +179,9 @@ export function useUpdate() {
progress,
error,
currentVersion,
platform,
isMac,
isWindows,
checkForUpdates,
downloadUpdate,
installUpdate,

View File

@@ -1,65 +1,77 @@
<template>
<div class="flex h-screen w-screen overflow-hidden bg-white">
<!-- Left Navigation Sidebar -->
<div class="w-16 flex flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-50">
<div class="mb-6">
<div ref="sidebarRef" class="flex flex-col items-center py-4 border-r z-50" style="flex: 0 0 calc(100vw * 2 / 19); min-width: 96px; max-width: 400px; background-color: #F8F9FA;">
<div class="mb-6" style="border-bottom: 1px solid #A0AEC023; padding: 10%;">
<!-- Logo or Brand -->
<div class="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 class="" >
<img :src="yoloIcon" class="yolo-logo" />
</div>
</div>
<div class="flex-1 flex flex-col gap-4 w-full px-2">
<!-- Auto DM Workbench Tab -->
<button @click="currentView = 'auto_dm'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'auto_dm' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">chat</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
自动私信工作台
</div>
</button>
<!-- Fan Workbench Tab -->
<button @click="currentView = 'FanWorkbench'"
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
:class="currentView === 'FanWorkbench' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
<span class="material-icons-round text-2xl">supervised_user_circle</span>
<div
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
大哥工作台
</div>
</button>
<div class="flex-1 flex flex-col w-full px-2" style="gap: 2vh;">
<!-- 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>
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
:class="currentView === 'tk' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'tk' ? nav11 : nav1" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">TK 工作台</span>
</button>
<!-- Hosts List Tab -->
<button @click="currentView = 'hosts'"
class="w-full 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>
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
:class="currentView === 'hosts' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'hosts' ? nav22 : nav2" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">主播列表</span>
</button>
<!-- Auto DM Workbench Tab -->
<button @click="currentView = 'auto_dm'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
:class="currentView === 'auto_dm' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'auto_dm' ? nav33 : nav3" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">自动私信</span>
</button>
<!-- Fan Workbench Tab -->
<button @click="currentView = '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>
</button>
<!-- PK 工作台 Tab -->
<button @click="currentView = 'pk_mini'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
:class="currentView === 'pk_mini' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
<img :src="currentView === 'pk_mini' ? nav55 : nav5" class="w-9 h-9 object-contain flex-shrink-0" />
<span class="text-base font-medium truncate">PK 工作台</span>
</button>
<!-- yolo商店 Tab -->
<button @click="currentView = 'shop'"
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
style="height: 6vh;"
:class="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>
</div>
<div class="mt-auto">
<!-- Logout or Back -->
<div class="mt-auto w-full px-2">
<!-- Logout -->
<button @click="$emit('logout')"
class="w-10 h-10 rounded-xl flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-red-400 transition-all">
<span class="material-icons-round">logout</span>
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>
</button>
</div>
</div>
@@ -74,6 +86,7 @@
title="自动私信工作台未开通"
description="您当前没有使用自动私信功能的权限"
:placeholder-image="placeholderWebAi"
:contacts="serviceContacts"
>
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
<ConfigPage
@@ -84,6 +97,7 @@
<div v-show="autoDmMode === 'browser'" class="h-full w-full">
<YoloBrowser
v-bind="$attrs"
:nav-sidebar-width="navSidebarWidth"
@go-back="handleBackToConfig"
@stop-all="handleStopAll"
/>
@@ -98,47 +112,88 @@
title="TK工作台未开通"
description="您当前没有使用TK工作台功能的权限"
:placeholder-image="placeholderTk"
:contacts="serviceContacts"
>
<TkWorkbenches />
</PermissionMask>
</div>
<!-- Tab 3: Hosts List - crawl 权限 -->
<div v-show="currentView === 'hosts'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<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"
>
<HostsList />
</PermissionMask>
</div>
<!-- Tab 4: Fan Workbench - bigBrother 权限 -->
<div v-show="currentView === 'FanWorkbench'" class="absolute inset-0 z-20 bg-gray-50 h-full overflow-hidden p-4">
<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"
>
<FanWorkbench />
</PermissionMask>
</div>
<!-- Tab 5: PK Mini 工作台 - 无需权限控制 -->
<div v-show="currentView === 'pk_mini'" class="absolute inset-0 z-20 h-full overflow-hidden">
<PkMiniWorkbench />
</div>
<!-- Tab 6: yolo商店 - Electron BrowserViewWeb iframe 兜底 -->
<div v-show="currentView === 'shop'" class="absolute inset-0 z-20 h-full overflow-hidden">
<div v-if="isElectron()" class="w-full h-full flex items-center justify-center text-base text-slate-500 bg-white">
正在进入商店...
</div>
<iframe
v-else-if="adminLoaded"
:src="shopUrl"
class="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads"
></iframe>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, onMounted, onUnmounted, useTemplateRef } 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 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'
@@ -148,8 +203,58 @@ import placeholderBigBrother from '@/assets/placeholder-bigbrother.png'
const emit = defineEmits(['logout', 'go-back', 'stop-all'])
const currentView = ref('auto_dm') // Default Tab
const currentView = ref('tk') // Default Tab
const autoDmMode = ref('config') // Default Sub-state: 'config' or 'browser'
const adminLoaded = ref(false) // Web iframe 懒加载(仅非 Electron
const shopOpened = ref(false) // Electron 只首开加载一次
const shopUrl = ENV.SHOP_URL
const sidebarRef = useTemplateRef('sidebarRef')
const navSidebarWidth = ref(200) // 左侧导航菜单的实际宽度px传给 YoloBrowser/Sidebar 使用
// 客服名片
const serviceContacts = ref([])
const loadServiceContacts = async () => {
try {
const res = await getCustomServiceInfo()
console.log("获取名片",res)
if (res) {
serviceContacts.value = res.map(item => ({
avatar: item.avater,
name: item.name,
desc: item.description,
qrcode: item.concat,
phone: item.phone
}))
}
} catch (e) {
console.error('获取客服名片失败:', e)
}
}
// 监听菜单栏实际宽度,通知后端更新 BrowserView 定位
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 handleGoToBrowser = async () => {
autoDmMode.value = 'browser'
@@ -171,8 +276,36 @@ const handleStopAll = () => {
// Watch for view changes to manage native Electron BrowserViews
watch(currentView, async (newVal, oldVal) => {
// 懒加载 Web 端 iframe仅非 Electron
if (newVal === 'shop' && !adminLoaded.value && !isElectron()) {
adminLoaded.value = true
}
if (!isElectron()) return
if (newVal === 'shop') {
if (!shopOpened.value) {
shopOpened.value = true
try {
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
} else {
try {
await window.electronAPI.openShop(shopUrl)
} catch (e) {
console.error('打开商店失败:', e)
}
}
} else if (oldVal === 'shop') {
try {
await window.electronAPI.hideShop()
} catch (e) {
console.error('隐藏商店失败:', e)
}
}
if (newVal === 'auto_dm' && autoDmMode.value === 'browser') {
// Switching TO Auto DM tab AND we are in browser mode: Show views
try {
@@ -205,6 +338,10 @@ watch(autoDmMode, async (newVal) => {
<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,6 +48,20 @@ 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',
@@ -79,6 +93,7 @@ export default {
invitationType: 'invitationType',
invitationType1: 'Regular',
invitationType2: 'Golden',
invitationType3: 'Linked Account',
liveSessions: 'Live Sessions',
viewSessions: 'View Sessions',
liveRevenue: 'Live Revenue',
@@ -93,6 +108,16 @@ 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',
@@ -156,6 +181,18 @@ 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.)
@@ -179,5 +216,65 @@ 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,6 +48,20 @@ export default {
prompt: '达到数量后停止爬取',
setHostNum: '设置爬取数量',
unlimitedQuantity: '不限爬取数量',
refreshCountry: '刷新国家',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失败',
enterCountryPrompt: '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
enterCountryTitle: '获取国家失败',
confirm: '确定',
cancel: '取消',
countryPlaceholder: '例如:美国、日本、英国',
countryRequired: '请输入国家名称',
countrySetSuccess: '国家设置成功',
unknown: '未知',
editCountry: '编辑国家',
editCountryPrompt: '请输入国家名称(中文)',
editCountryTitle: '编辑国家',
},
hostList: {
placeCountry: '选择国家',
@@ -78,7 +92,8 @@ export default {
sure: '确定',
invitationType: '邀请类型',
invitationType1: '普票',
invitationType2: '票',
invitationType2: '进阶票',
invitationType3: '关联账号',
liveSessions: '直播场次',
viewSessions: '查看场次',
liveRevenue: '直播收益',
@@ -93,6 +108,16 @@ export default {
revenueTime: '时间',
close: '关闭',
selectPlaceholder: '请选择',
searchPlaceholder: '搜索...',
onlyAbnormal: '只看异常',
total: '总条数',
totalLikes: '点赞合计',
zeroLikes: '无点赞',
startTime: '开始时间',
endTime: '结束时间',
duration: '时长',
likeCount: '点赞',
createTime: '入库时间',
},
hostsList: {
filterPrivateUsers: '过滤隐私用户',
@@ -156,19 +181,97 @@ 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: "阿塞拜疆",
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: "津巴布韦"
},
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: '确认'
}
}

View File

@@ -325,6 +325,34 @@
<!-- 打招呼内容弹窗 -->
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
<!-- 预热 Loading 遮罩 -->
<transition name="fade">
<div v-if="warmingUp"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div
class="bg-white/90 rounded-2xl shadow-2xl border border-white/60 px-6 py-5 flex items-center gap-4">
<!-- spinner -->
<div class="w-10 h-10 rounded-full border-4 border-gray-200 border-t-blue-500 animate-spin"></div>
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-900">正在预热视图</div>
<div class="text-xs text-gray-500">
这会提升后台视图渲染稳定性请稍候
</div>
<!-- 可选进度小点点动画 -->
<div class="flex gap-1 pt-1">
<span
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.2s]"></span>
<span
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.1s]"></span>
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce"></span>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
@@ -517,7 +545,11 @@ const saveToLocalStorage = () => {
const saveToFile = async () => {
if (!isElectronEnv) return
try {
await window.electronAPI.saveRunConfig(JSON.parse(JSON.stringify(config.value)))
const configToSave = JSON.parse(JSON.stringify(config.value))
// ConfigPage 不管理 filtersHostListDialog 会单独管理
// 删除 filters 避免用 ConfigPage 中可能过期的状态覆盖后端
delete configToSave.filters
await window.electronAPI.saveRunConfig(configToSave)
} catch (e) {
console.error('保存配置失败:', e)
}
@@ -631,7 +663,7 @@ const handleSleepTimeInput = (val) => {
config.value.sleepTime = parseInt(val) || 0
}
}
const warmingUp = ref(false)
// Start/Stop
const handleStart = async (specificGroupIndex) => {
const activeGroupIndex = specificGroupIndex ?? 0
@@ -706,6 +738,16 @@ const handleStart = async (specificGroupIndex) => {
}
}
// 预热所有视图,确保后台视图完成渲染,解决自动化执行失败问题
warmingUp.value = true
try {
console.log('[ConfigPage] 预热所有视图...')
await window.electronAPI.warmUpViews()
console.log('[ConfigPage] 视图预热完成')
} catch (e) {
console.warn('[ConfigPage] 视图预热失败,继续启动:', e)
}
const results = await Promise.allSettled(
startTasks.map(async ({ viewId, account, delay }) => {
await new Promise(r => setTimeout(r, delay))
@@ -731,6 +773,7 @@ const handleStart = async (specificGroupIndex) => {
} else if (firstError.result.status === 'fulfilled') {
errorMsg = firstError.result.value.error || '启动失败'
}
warmingUp.value = false
alert(`启动失败:${errorMsg}`)
return
}
@@ -745,6 +788,7 @@ const handleStart = async (specificGroupIndex) => {
const status = await window.electronAPI.getRotationStatus()
rotationStatus.value = status
handleStatusChange(status)
warmingUp.value = false //关闭遮罩
emit('goToBrowser')
}
@@ -810,3 +854,14 @@ const togglePasswordVisibility = (gIndex, aIndex) => {
showPasswordMap.value[key] = !showPasswordMap.value[key]
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,13 +1,7 @@
```
<template>
<div
class="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
<div class="absolute top-8 right-8 flex gap-4 z-20">
<!-- Network Settings (Placeholder/Mock) -->
<!-- <div class="bg-white/95 border border-slate-200 rounded-2xl px-3 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
<span class="text-sm font-semibold text-slate-700">{{ $t('login.network') }}</span>
</div> -->
<!-- Language Selector -->
<el-dropdown>
<div
@@ -35,100 +29,265 @@
<!-- Left Side: Form -->
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
<!-- Header / Logo -->
<div class="flex justify-center">
<!-- Header / Logo注册页隐藏避免表单被挤出屏幕 -->
<div v-show="mode === 'login'" class="flex justify-center">
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
</div>
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- 租户号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
<!-- ==================== 登录表单 ==================== -->
<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>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="credentials.username" placeholder="请输入账号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="credentials.password" placeholder="请输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
<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>
登录中
</span>
</template>
<template v-else>
登 录
</template>
</div>
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="credentials.username" placeholder="请输入账号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="credentials.password" placeholder="请输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
登录中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</form>
<!-- 切换到注册 -->
<div class="mt-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">
立即注册
</button>
</div>
</form>
</template>
<div class="mt-8 text-center">
<!-- ==================== 注册表单 ==================== -->
<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">
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
</div>
</div>
@@ -156,42 +315,51 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
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.png'
import illustration from '../assets/illustration.webp'
const emit = defineEmits(['loginSuccess'])
const { locale } = useI18n()
// 当前模式login / register
const mode = ref('login')
// Language Switcher
const switchLanguage = (lang) => {
locale.value = lang
localStorage.setItem('lang', lang)
}
// const STORAGE_KEY = 'login_credentials' // Deprecated in favor of getUserPass
// const USER_KEY = 'user_data' // Deprecated in favor of setUser
// 切换登录/注册
const 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()
if (saved) {
@@ -214,7 +382,6 @@ const handleSubmit = async () => {
error.value = ''
try {
// 保存凭据 (Using compatible storage helper)
setUserPass(credentials.value)
console.log('[LoginPage] 开始登录...', credentials.value)
@@ -229,11 +396,9 @@ const handleSubmit = async () => {
console.log('[LoginPage] 登录结果:', result)
if (result.success && result.user) {
// Save token and user info to localStorage using legacy keys to support ported views
setToken(result.user.tokenValue);
setUser(result.user);
// 保存权限信息
setPermissions({
bigBrother: result.user.bigBrother,
crawl: result.user.crawl,
@@ -250,6 +415,156 @@ 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,10 +76,27 @@
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
</div>
<button @click="downloadUpdate"
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
立即下载更新
</button>
<!-- 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>
<p class="text-center text-gray-400 text-xs">
必须更新后才能使用程序
@@ -185,6 +202,7 @@ const {
progress,
error,
currentVersion,
isMac,
checkForUpdates,
downloadUpdate,
installUpdate
@@ -197,6 +215,8 @@ 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
@@ -304,6 +324,14 @@ 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)

194
src/stores/countryStore.js Normal file
View File

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

91
src/stores/noticeStore.js Normal file
View File

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

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

@@ -81,6 +81,7 @@ 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>
@@ -111,6 +112,9 @@ 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 }>
saveRunConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
loadRunConfig: () => Promise<Record<string, unknown> | null>
@@ -122,6 +126,7 @@ export interface ElectronAPI {
// 打招呼统计
getGreetingStats: () => Promise<GreetingStats>
getRepliedSessions: () => Promise<Array<{ name: string; id: string }>>
// 获取打招呼内容
fetchPrologue: () => Promise<{ success: boolean; data?: string[]; error?: string }>
@@ -146,6 +151,7 @@ export interface ElectronAPI {
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
}

View File

@@ -8,24 +8,13 @@ import router from '@/router'
import { ElMessage } from 'element-plus';
import { usePythonBridge, } from '@/utils/pythonBridge'
import { ENV } from '@/config'
const { stopScript } = usePythonBridge();
// 请求地址前缀
let baseURL = ''
if (process.env.NODE_ENV === 'development') {
// 生产环境
// baseURL = "https://api.tkpage.yolozs.com"
baseURL = "http://192.168.2.22:8101"
// baseURL = "https://crawlclient.api.yolozs.com"
} else {
// 测试环境
// baseURL = "http://120.26.251.180:8085/"
// 开发环境
baseURL = "https://crawlclient.api.yolozs.com"
// baseURL = "http://api.tkpage.vvtiktok.cn"
}
const baseURL = ENV.API_BASE_URL
// 请求拦截器
axios.interceptors.request.use((config) => {

View File

@@ -0,0 +1,136 @@
// 国家数据 - 简化版本,直接内嵌数据
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": "中国香港",
"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": "新西兰"
}
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",
"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"
}
// 创建中文名称到国家代码的映射
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]
}

312
src/utils/pk-mini/goeasy.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* 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

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

@@ -0,0 +1,13 @@
// 时间戳转换为本地时间,格式为 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}`
}

View File

@@ -0,0 +1,27 @@
// 记录上次调用时间
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,7 +36,11 @@ 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;
@@ -53,16 +57,22 @@ export function usePythonBridge() {
await window.electronAPI.tk.visitAnchor(id);
};
// backStageloginStatus
const backStageloginStatus = async () => {
// 查询后台登录状态(合并接口,通过 account 参数区分)
// account: 公会账号,不传则返回所有账号状态
const backStageloginStatus = async (account) => {
if (!inElectron) return null;
return await window.electronAPI.tk.checkBackStageLoginStatus();
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;
}
};
// backStageloginStatusCopy
const backStageloginStatusCopy = async () => {
if (!inElectron) return null;
return await window.electronAPI.tk.checkBackStageLoginStatusCopy();
// 兼容旧接口:查询副账号登录状态(内部调用合并后的接口)
const backStageloginStatusCopy = async (account) => {
return await backStageloginStatus(account);
};
// exportToExcel
@@ -125,9 +135,17 @@ export function usePythonBridge() {
await window.electronAPI.tk.openRoom(id);
};
// Clipboard helper - maybe use navigator.clipboard directly in Vue component?
// Original used python bridge for clipboard.
// Clipboard helper - 优先使用 Python RPCfallback 到浏览器 API
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;
@@ -137,11 +155,84 @@ 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,
@@ -156,6 +247,13 @@ export function usePythonBridge() {
storageSetInfos,
readSetInfos,
openAnchorIdRooms,
setClipboards
setClipboards,
// 新增接口
startBrotherMonitor,
getBrotherLoginStatus,
visitGifter,
closeAllBrowsers,
storageAccountInfo,
readAccountInfo
};
}

View File

@@ -3,7 +3,8 @@
<!-- 侧边栏 -->
<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" />
:rotation-status="rotationStatus" :greeting-stats="greetingStats" :automation-logs="automationLogs"
:sidebar-width="navSidebarWidth" />
<!-- 内容区域 -->
<main class="flex-1 flex flex-col relative">
@@ -64,7 +65,7 @@ import ViewPlaceholder from '@/components/ViewPlaceholder.vue'
// Wait, in App.vue these were local state. I needs to move the logic here or keep it in App.vue and pass via props.
// To keep valid functionality, I will copy the logic 1:1 here.
const props = defineProps(['accountGroups', 'rotationStatus', 'greetingStats', 'automationLogs'])
const props = defineProps(['accountGroups', 'rotationStatus', 'greetingStats', 'automationLogs', 'navSidebarWidth'])
const emit = defineEmits(['go-back', 'stop-all', 'request-config-load'])
// Constants

View File

@@ -0,0 +1,94 @@
<template>
<!-- 站内信页面 -->
<div class="forum">
<div class="forum-list">
<el-card
v-for="(item, index) in noticeList"
:key="index"
class="notice-card"
>
<template #header>
<div class="card-header">{{ item.title }}</div>
</template>
<div class="card-body">{{ item.content }}</div>
<template #footer>
<div class="card-footer">{{ item.time }}</div>
</template>
</el-card>
<div v-if="noticeList.length === 0" class="empty-tip">暂无站内信</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getNoticeList } from '@/api/pk-mini'
const noticeList = ref([])
const page = ref(0)
async function loadNotices() {
try {
const res = await getNoticeList({ page: page.value, size: 20 })
noticeList.value = res || []
} catch (e) {
console.error('加载站内信失败', e)
}
}
onMounted(() => {
loadNotices()
})
</script>
<style scoped lang="less">
.forum {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
background: white;
border-radius: 16px;
overflow: auto;
}
.forum-list {
width: 90%;
max-width: 800px;
}
.notice-card {
margin-bottom: 20px;
border-radius: 16px;
border: 1px solid #4fcacd;
background: linear-gradient(180deg, #e4ffff, #ffffff);
}
.card-header {
font-size: 18px;
font-weight: bold;
text-align: center;
}
.card-body {
color: #333;
font-size: 16px;
line-height: 1.6;
}
.card-footer {
font-size: 14px;
color: #999;
text-align: right;
}
.empty-tip {
text-align: center;
padding: 50px;
color: #999;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,727 @@
<template>
<!-- 消息页面 -->
<div class="message-page" ref="messagePageRef">
<div class="message-layout">
<!-- 会话列表 -->
<div class="conversation-panel">
<div class="conversation-list">
<div
v-for="(item, index) in chatList"
:key="index"
class="conversation-item"
:class="{ active: selectedChat === item }"
@click="selectChat(item)"
>
<el-badge :value="item.unread > 0 ? item.unread : ''" :max="99">
</el-badge>
<div class="conv-info">
<div class="conv-header">
<span class="conv-name">{{ item.data?.nickname || '用户' }}</span>
<span class="conv-time">{{ formatTime(item.lastMessage?.timestamp) }}</span>
</div>
<div class="conv-preview">{{ item.lastMessage?.payload?.text || '' }}</div>
</div>
<span
class="conv-delete"
@click.stop="removeConversation(item, index)"
title="删除会话"
>×</span>
</div>
<div v-if="chatList.length === 0" class="empty-tip">暂无会话</div>
</div>
</div>
<!-- 消息列表 -->
<div class="chat-panel">
<div v-if="selectedChat" class="chat-container">
<div class="chat-header">
<span class="chat-header-name">{{ selectedChat.data?.nickname || '用户' }}</span>
</div>
<div class="chat-messages" ref="chatMessagesRef" :style="{ visibility: isScrollReady ? 'visible' : 'hidden' }">
<div
v-for="(msg, index) in messagesList"
:key="index"
class="message-item"
:class="{ mine: msg.senderId == currentUser.id }"
>
<div
class="message-bubble"
@contextmenu.prevent="msg.type !== 'pk' ? showContextMenu($event, msg, index) : null"
>
<div v-if="msg.recalled" class="recalled-tip">消息已撤回</div>
<template v-else>
<div v-if="msg.type === 'text'" class="text-message">{{ msg.payload.text }}</div>
<PictureMessage v-else-if="msg.type === 'image'" :item="msg" />
<MiniPKMessage v-else-if="msg.type === 'pk'" :item="msg" />
<VoiceMessage v-else-if="msg.type === 'audio'" :item="msg.payload.url" :size="msg.payload.duration" />
<div v-if="msg.senderId == currentUser.id" class="read-status">
{{ msg.read ? '已读' : '未读' }}
</div>
</template>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="input-toolbar">
<div class="toolbar-btn" @click="handleSendImage">
<span class="material-icons-round">image</span>
</div>
</div>
<div class="input-box">
<textarea
v-model="inputText"
placeholder="输入消息..."
@keydown.enter.prevent="sendMessage"
></textarea>
<el-button type="primary" @click="sendMessage">发送</el-button>
</div>
</div>
</div>
<div v-else class="chat-placeholder">
<span class="material-icons-round placeholder-icon">chat_bubble_outline</span>
<p>选择左侧会话开始聊天</p>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px', transformOrigin: contextMenu.isMine ? 'top right' : 'top left' }"
@mouseleave="hideContextMenu"
>
<div v-if="contextMenu.msg?.type === 'text'" class="context-menu-item" @click="copyMessage">复制</div>
<div v-if="contextMenu.msg?.senderId == currentUser.id" class="context-menu-item danger" @click="recallMessage">撤回</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, onActivated, nextTick, watch } from 'vue'
import { getMainUserData } from '@/utils/pk-mini/storage'
import { TimestamptolocalTime } from '@/utils/pk-mini/timeConversion'
import { isGoEasyEnabled } from '@/config/pk-mini'
import {
goEasyGetConversations,
goEasyGetMessages,
goEasySendMessage,
goEasySendImageMessage,
goEasyMessageRead,
goEasyRemoveConversation,
goEasyRecallMessage,
getPkGoEasy,
GoEasy
} from '@/utils/pk-mini/goeasy'
import PictureMessage from '@/components/pk-mini/chat/PictureMessage.vue'
import MiniPKMessage from '@/components/pk-mini/chat/MiniPKMessage.vue'
import VoiceMessage from '@/components/pk-mini/chat/VoiceMessage.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pkUnreadStore } from '@/stores/pk-mini/notice.js'
const defaultAvatar = 'https://vv-1317974657.cos.ap-shanghai.myqcloud.com/util/default-avatar.png'
const chatList = ref([])
const selectedChat = ref(null)
const messagesList = ref([])
const inputText = ref('')
const currentUser = ref({})
const isScrollReady = ref(false)
const chatMessagesRef = ref(null)
const fileInputRef = ref(null)
const unreadStore = pkUnreadStore()
const contextMenu = ref({ visible: false, x: 0, y: 0, msg: null, index: -1 })
const messagePageRef = ref(null)
function showContextMenu(event, msg, index) {
const menuWidth = 110
const menuHeight = msg.senderId == currentUser.value.id ? 80 : 44
const rect = messagePageRef.value?.getBoundingClientRect() || { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
let x = event.clientX - rect.left
let y = event.clientY - rect.top
const isMine = msg.senderId == currentUser.value.id
// 自己的消息在右侧,菜单向左偏移
if (isMine) x -= menuWidth
if (x + menuWidth > rect.width) x -= menuWidth
if (x < 0) x = 0
if (y + menuHeight > rect.height) y -= menuHeight
contextMenu.value = { visible: true, x, y, msg, index, isMine }
}
function hideContextMenu() {
contextMenu.value.visible = false
}
function copyMessage() {
const text = contextMenu.value.msg?.payload?.text || ''
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {})
} else {
const el = document.createElement('textarea')
el.value = text
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
hideContextMenu()
}
async function recallMessage() {
const msg = contextMenu.value.msg
const index = contextMenu.value.index
hideContextMenu()
if (!msg) return
try {
await goEasyRecallMessage(msg)
// 用对象替换触发 Vue 响应式更新
messagesList.value.splice(index, 1, { ...msg, recalled: true })
} catch (e) {
console.error('撤回失败', e)
ElMessage.error('撤回失败消息超过4小时或发送中')
}
}
const formatTime = TimestamptolocalTime
async function loadConversations() {
if (!isGoEasyEnabled()) {
ElMessage.warning('消息功能暂时不可用GoEasy 订阅未续费)')
return
}
try {
const result = await goEasyGetConversations()
chatList.value = result?.content?.conversations || []
} catch (e) {
console.error('加载会话列表失败', e)
}
}
async function selectChat(item) {
if (!isGoEasyEnabled()) return
selectedChat.value = item
try {
const messages = await goEasyGetMessages({ id: String(item.userId), timestamp: null })
messagesList.value = messages || []
await nextTick()
scrollToBottom()
// 异步卡片内容加载完后再滚一次
scrollToBottomHidden()
// 标记消息已读
goEasyMessageRead({ id: String(item.userId) }).catch(() => {})
unreadStore.decrease(item.unread || 0)
item.unread = 0
} catch (e) {
console.error('加载消息失败', e)
}
}
function scrollToBottom() {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight
}
}
function scrollToBottomHidden() {
isScrollReady.value = false
setTimeout(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight
}
isScrollReady.value = true
}, 300)
}
async function sendMessage() {
if (!isGoEasyEnabled()) {
ElMessage.warning('消息功能暂时不可用GoEasy 订阅未续费)')
return
}
if (!inputText.value.trim()) return
if (!selectedChat.value) return
try {
const msg = await goEasySendMessage({
text: inputText.value,
id: String(selectedChat.value.userId),
avatar: selectedChat.value.data?.avatar || '',
nickname: selectedChat.value.data?.nickname || ''
})
messagesList.value.push(msg)
inputText.value = ''
await nextTick()
scrollToBottom()
// 发送消息后标记已读,清除该会话红点
const conv = chatList.value.find(c => c.userId === selectedChat.value.userId)
if (conv && conv.unread > 0) {
goEasyMessageRead({ id: String(selectedChat.value.userId) }).catch(() => {})
unreadStore.decrease(conv.unread)
conv.unread = 0
}
} catch (e) {
console.error('发送消息失败', e)
if(e =='Error: id can not be the same as your id'){
ElMessage.error('不能给自己发消息')
}else{
ElMessage.error('发送失败')
}
}
}
function handleSendImage() {
if (!isGoEasyEnabled()) {
ElMessage.warning('消息功能暂时不可用GoEasy 订阅未续费)')
return
}
if (!selectedChat.value) {
ElMessage.warning('请先选择一个会话')
return
}
fileInputRef.value?.click()
}
async function handleFileSelect(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !isGoEasyEnabled()) return
if (!selectedChat.value) return
try {
const msg = await goEasySendImageMessage({
imagefile: file,
id: String(selectedChat.value.userId),
avatar: selectedChat.value.data?.avatar || '',
nickname: selectedChat.value.data?.nickname || ''
})
messagesList.value.push(msg)
await nextTick()
scrollToBottom()
} catch (e) {
console.error('发送图片失败', e)
ElMessage.error('发送图片失败')
}
}
async function removeConversation(item, index) {
try {
await ElMessageBox.confirm('确定删除该会话吗?', '删除会话', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await goEasyRemoveConversation(item)
chatList.value.splice(index, 1)
if (selectedChat.value === item) {
selectedChat.value = null
messagesList.value = []
}
} catch (e) {
console.error('删除会话失败', e)
ElMessage.error('删除会话失败')
}
}
onMounted(() => {
currentUser.value = getMainUserData() || {}
document.addEventListener('click', hideContextMenu)
if (isGoEasyEnabled()) {
loadConversations()
const goeasy = getPkGoEasy()
if (goeasy) {
goeasy.im.on(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onConversationsUpdated)
goeasy.im.on(GoEasy.IM_EVENT.MESSAGE_READ, onMessageRead)
goeasy.im.on(GoEasy.IM_EVENT.MESSAGE_RECALLED, onMessageRecalled)
}
}
// 切换回消息页面时,滚到聊天记录最底部
nextTick(() => scrollToBottomHidden())
})
// KeepAlive 缓存激活时触发(从其他页面切回消息页面)
onActivated(() => {
scrollToBottomHidden()
})
// 监听 chatMessagesRef 出现selectedChat 从 null 变为有值时 DOM 才渲染)
watch(chatMessagesRef, (el) => {
if (el) scrollToBottomHidden()
})
function onConversationsUpdated(conversations) {
chatList.value = conversations.conversations || []
console.log("chatList返回",chatList.value)
}
function onMessageRead(messages) {
messages.forEach(readMsg => {
const index = messagesList.value.findIndex(m => m.messageId === readMsg.messageId)
if (index !== -1) {
messagesList.value.splice(index, 1, { ...messagesList.value[index], read: true })
}
})
}
function onMessageRecalled(messages) {
messages.forEach(recalled => {
const index = messagesList.value.findIndex(m => m.messageId === recalled.messageId)
if (index !== -1) {
messagesList.value.splice(index, 1, { ...messagesList.value[index], recalled: true })
}
})
}
function onMessageReceived(message) {
if (!isGoEasyEnabled()) return
// 始终累加会话未读数和更新最后一条消息(全局 unreadStore 由 PkAppaside 统一维护)
const conv = chatList.value.find(c => c.userId === message.senderId)
if (conv) {
conv.unread = (conv.unread || 0) + 1
conv.lastMessage = message
}
// 如果当前正在查看该会话,追加消息到列表
if (selectedChat.value && selectedChat.value.userId === message.senderId) {
messagesList.value.push(message)
nextTick(() => scrollToBottom())
}
}
onUnmounted(() => {
document.removeEventListener('click', hideContextMenu)
if (isGoEasyEnabled()) {
const goeasy = getPkGoEasy()
if (goeasy) {
try {
goeasy.im.off(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onConversationsUpdated)
goeasy.im.off(GoEasy.IM_EVENT.MESSAGE_READ, onMessageRead)
goeasy.im.off(GoEasy.IM_EVENT.MESSAGE_RECALLED, onMessageRecalled)
} catch (e) {
console.warn('清理 GoEasy 监听器失败', e)
}
}
}
})
</script>
<style scoped lang="less">
.message-page {
position: relative;
width: 100%;
height: 100%;
background-color: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 12px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.message-layout {
display: flex;
height: 100%;
}
.conversation-panel {
width: 280px;
min-width: 220px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
}
.chat-panel {
flex: 1;
min-width: 0;
height: 100%;
}
.conversation-list {
height: 100%;
overflow: auto;
background-color: #f9fafb; // gray-50
}
.conversation-item {
display: flex;
padding: 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid #f1f5f9; // slate-100
}
.conversation-item:hover, .conversation-item.active {
background-color: #eff6ff; // blue-50
}
.conv-avatar {
width: 50px;
height: 50px;
border-radius: 10px;
overflow: hidden;
margin-right: 12px;
}
.conv-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.conv-info {
flex: 1;
min-width: 0;
}
.conv-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.conv-name {
font-weight: bold;
color: #0f172a; // slate-900
}
.conv-time {
font-size: 12px;
color: #94a3b8; // slate-400
}
.conv-preview {
font-size: 13px;
color: #64748b; // slate-500
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-container {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 14px 20px;
border-bottom: 1px solid #f1f5f9;
background-color: #ffffff;
flex-shrink: 0;
}
.chat-header-name {
font-size: 15px;
font-weight: bold;
color: #0f172a;
}
.chat-messages {
flex: 1;
overflow: auto;
padding: 20px;
}
.message-item {
display: flex;
margin-bottom: 20px;
}
.message-item.mine {
flex-direction: row-reverse;
}
.message-avatar {
width: 45px;
height: 45px;
border-radius: 10px;
overflow: hidden;
margin: 0 12px;
flex-shrink: 0;
}
.message-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.message-bubble {
max-width: 60%;
}
.text-message {
padding: 12px 16px;
background-color: #f1f5f9; // slate-100
border-radius: 12px;
font-size: 15px;
line-height: 1.5;
}
.message-item.mine .text-message {
background-color: #dbeafe; // blue-100
}
.chat-input-area {
border-top: 1px solid #f1f5f9; // slate-100
}
.input-toolbar {
display: flex;
padding: 10px 15px;
background-color: #eff6ff; // blue-50
}
.toolbar-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
}
.toolbar-btn:hover {
background-color: rgba(255, 255, 255, 0.8);
}
.toolbar-btn .material-icons-round {
color: #2563eb; // blue-600
}
.input-box {
display: flex;
padding: 10px 15px;
gap: 10px;
}
.input-box textarea {
flex: 1;
border: none;
outline: none;
resize: none;
height: 100px;
font-size: 14px;
padding: 10px;
}
.chat-placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #2563eb; // blue-600
}
.placeholder-icon {
font-size: 80px;
opacity: 0.3;
margin-bottom: 20px;
}
.conversation-item:hover .conv-delete,
.conversation-item.active .conv-delete {
display: flex;
}
.conv-delete {
display: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 16px;
line-height: 1;
color: #94a3b8;
flex-shrink: 0;
margin-left: 4px;
&:hover {
background-color: #fee2e2;
color: #ef4444;
}
}
.read-status {
font-size: 11px;
color: #94a3b8;
text-align: right;
margin-top: 4px;
}
.message-item.mine .read-status {
color: #60a5fa;
}
.recalled-tip {
font-size: 13px;
color: #94a3b8;
font-style: italic;
padding: 6px 12px;
}
.context-menu {
position: absolute;
z-index: 9999;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
min-width: 100px;
overflow: hidden;
transform-origin: top left;
animation: context-menu-in 0.15s ease-out;
}
@keyframes context-menu-in {
from {
opacity: 0;
transform: scale(0.7);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu-item {
padding: 10px 16px;
font-size: 14px;
cursor: pointer;
color: #374151;
&:hover {
background-color: #f3f4f6;
}
&.danger {
color: #ef4444;
&:hover {
background-color: #fee2e2;
}
}
}
.empty-tip {
text-align: center;
padding: 50px;
color: #94a3b8; // slate-400
}
</style>

142
src/views/pk-mini/Mine.vue Normal file
View File

@@ -0,0 +1,142 @@
<template>
<!-- 我的页面 -->
<div class="mine-page">
<!-- 顶部选项卡 -->
<div class="tab-bar">
<div
v-for="item in tabs"
:key="item.value"
class="tab-item"
:class="{ active: activeTab === item.value }"
@click="activeTab = item.value"
>
<img class="tab-icon" :class="item.iconClass" :src="item.icon" alt="" />
<span class="tab-label">{{ item.label }}</span>
</div>
</div>
<!-- 内容区 -->
<div class="tab-content">
<AnchorLibrary v-if="activeTab === 1" />
<PKmessage v-if="activeTab === 2" />
<PKRecord v-if="activeTab === 3" />
<PointsList v-if="activeTab === 4" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AnchorLibrary from '@/components/pk-mini/mine/AnchorLibrary.vue'
import PKmessage from '@/components/pk-mini/mine/PKmessage.vue'
import PKRecord from '@/components/pk-mini/mine/PKRecord.vue'
import PointsList from '@/components/pk-mini/mine/PointsList.vue'
// 导入本地图片
import iconAnchorLibrary from '@/assets/pk-mini/AnchorLibrary.png'
import iconPKInformation from '@/assets/pk-mini/PKInformation.png'
import iconPKRecord from '@/assets/pk-mini/PKRecord.png'
import iconPointsList from '@/assets/pk-mini/PointsList.png'
const activeTab = ref(1)
const tabs = [
{
value: 1,
label: '主播库',
icon: iconAnchorLibrary
},
{
value: 2,
label: 'PK信息',
icon: iconPKInformation,
iconClass: 'pk-info-icon'
},
{
value: 3,
label: '我的PK记录',
icon: iconPKRecord
},
{
value: 4,
label: '积分列表',
icon: iconPointsList,
iconClass: 'points-icon'
}
]
</script>
<style scoped lang="less">
.mine-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tab-bar {
width: 100%;
height: 110px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}
.tab-item {
flex: 1;
height: 90px;
margin: 0 10px;
border-radius: 24px;
background-color: #f1f5f9; // slate-100
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}
.tab-item:hover {
transform: scale(1.02);
}
.tab-item.active {
background: linear-gradient(90deg, #eff6ff, #ffffff); // from-blue-50 to-white
border-color: #2563eb; // blue-600
}
.tab-icon {
width: 65px;
height: 65px;
margin-right: 20px;
}
.tab-icon.pk-info-icon {
width: 50px;
height: 72px;
}
.tab-icon.points-icon {
width: 65px;
height: 69px;
}
.tab-label {
font-size: 24px;
color: #64748b; // slate-500
}
.tab-item.active .tab-label {
color: #2563eb; // blue-600
font-weight: bold;
}
.tab-content {
flex: 1;
height: calc(100% - 110px);
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
}
</style>

1166
src/views/pk-mini/PkHall.vue Normal file

File diff suppressed because it is too large Load Diff

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