Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d517c1573d | |||
| 52e44bd857 | |||
| 1a45638f32 | |||
| fad174824a | |||
| a414d2b003 | |||
| f6ba1a9dc2 | |||
| 7e3b7448fa | |||
| 44df456240 | |||
| 1dd64988ba | |||
| 99b029377a | |||
| 919f1ad650 | |||
| bfd8748554 | |||
| b4efa96416 | |||
| 3d1a6f3870 | |||
| 1d9e0bb145 | |||
| ab645588ac | |||
| 89d3487c02 | |||
| e1c132ead9 | |||
| b6f8586efa | |||
| 3276e7d5cb | |||
| 13fa7ac04c | |||
| 4780e15ffa | |||
| fdb4a56197 | |||
| 7ff2382025 | |||
| 00489b5c27 | |||
| a0b476da7a | |||
| e1e4c8f531 | |||
| 35f08b0c99 | |||
| 5c1911314f | |||
| d4c0dcf6b1 | |||
| adc5a4d5fe | |||
| c2c9b239a3 | |||
| 92780ef52e | |||
| bef5c2f437 | |||
| d59e4c0bb9 | |||
| 658f50cc51 | |||
| 9f2b9a1997 | |||
| 76d83fc77e | |||
| c6435c6db5 | |||
| c383f9063d | |||
| 0615b032ff |
10
.env.development
Normal 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
@@ -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
@@ -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
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
6
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "yolo-web-frontend",
|
"name": "yolo-web-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"goeasy": "^2.14.9",
|
||||||
"vue": "^3.5.27"
|
"vue": "^3.5.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2100,6 +2101,11 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"goeasy": "^2.14.9",
|
||||||
"vue": "^3.5.27"
|
"vue": "^3.5.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
35
src/App.vue
@@ -4,6 +4,9 @@
|
|||||||
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
|
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 滚动通知栏(登录页和工作台都显示) -->
|
||||||
|
<NoticeBar />
|
||||||
|
|
||||||
<!-- 登录页面 -->
|
<!-- 登录页面 -->
|
||||||
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
|
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
|
||||||
|
|
||||||
@@ -11,7 +14,8 @@
|
|||||||
<!-- 配置页面 - 使用 v-show 保持状态 -->
|
<!-- 配置页面 - 使用 v-show 保持状态 -->
|
||||||
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'config'">
|
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'config'">
|
||||||
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="handleLogout" />
|
<ConfigPage @go-to-browser="handleGoToBrowser" @logout="handleLogout" />
|
||||||
<UpdateNotification />
|
<!-- 更新通知组件:启动时已在 UpdateChecker 检查,此处暂不显示 -->
|
||||||
|
<!-- <UpdateNotification /> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 浏览器页面 -->
|
<!-- 浏览器页面 -->
|
||||||
@@ -38,6 +42,8 @@ import ConfigPage from './pages/ConfigPage.vue'
|
|||||||
import UpdateChecker from './pages/UpdateChecker.vue'
|
import UpdateChecker from './pages/UpdateChecker.vue'
|
||||||
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
|
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
|
||||||
import UpdateNotification from './components/UpdateNotification.vue'
|
import UpdateNotification from './components/UpdateNotification.vue'
|
||||||
|
import NoticeBar from './components/NoticeBar.vue'
|
||||||
|
import { useNoticeStore } from './stores/noticeStore'
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const USER_KEY = 'user_data'
|
const USER_KEY = 'user_data'
|
||||||
@@ -57,6 +63,10 @@ const automationLogs = ref([])
|
|||||||
const isElectronEnv = isElectron()
|
const isElectronEnv = isElectron()
|
||||||
const isDev = window.location.port === '5173'
|
const isDev = window.location.port === '5173'
|
||||||
|
|
||||||
|
// 公告通知
|
||||||
|
const noticeStore = useNoticeStore()
|
||||||
|
noticeStore.fetchNotices()
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Set Title
|
// Set Title
|
||||||
@@ -154,6 +164,13 @@ const startHealthCheck = () => {
|
|||||||
if (result.success && result.code === 40400) {
|
if (result.success && result.code === 40400) {
|
||||||
alert('当前账号已在其他地方登录,请重新登录')
|
alert('当前账号已在其他地方登录,请重新登录')
|
||||||
localStorage.removeItem(USER_KEY)
|
localStorage.removeItem(USER_KEY)
|
||||||
|
// 隐藏所有 BrowserView 并停止自动化,防止视图悬浮在登录页上方
|
||||||
|
try {
|
||||||
|
await window.electronAPI.hideViews()
|
||||||
|
await handleStopAll()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[App] 清理视图失败:', e)
|
||||||
|
}
|
||||||
currentPage.value = 'login'
|
currentPage.value = 'login'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -214,11 +231,19 @@ const handleGoToConfig = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (isElectron()) {
|
stopHealthCheck()
|
||||||
await window.electronAPI.logout()
|
|
||||||
}
|
|
||||||
localStorage.removeItem(USER_KEY)
|
|
||||||
currentPage.value = 'login'
|
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 () => {
|
const handleStopAll = async () => {
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ export function getCountryinfo(data) {
|
|||||||
export function tkaccountuseinfo(accountName) {
|
export function tkaccountuseinfo(accountName) {
|
||||||
return getAxios({ url: `/api/common/accountCount?accountName=${accountName}` })
|
return getAxios({ url: `/api/common/accountCount?accountName=${accountName}` })
|
||||||
}
|
}
|
||||||
|
//查询主播列表
|
||||||
export function tkhostdata(data) {
|
export function tkhostdata(data) {
|
||||||
return postAxios({ url: '/api/save_data/hosts_info', 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) {
|
export function getExpiredTime(tenantId) {
|
||||||
@@ -80,3 +84,12 @@ export function liveHostDetail(data) {
|
|||||||
export function revenueStats(hostId) {
|
export function revenueStats(hostId) {
|
||||||
return getAxios({ url: 'api/save_data/revenue_stats?displayId=' + 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
@@ -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
@@ -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
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
BIN
src/assets/illustration.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/assets/nav/back.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/nav/card.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
src/assets/nav/exchange.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src/assets/nav/nav1.png
Normal file
|
After Width: | Height: | Size: 642 B |
BIN
src/assets/nav/nav11.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/nav/nav2.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/nav/nav22.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/nav/nav3.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/nav/nav33.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/nav/nav4.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/nav/nav44.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/nav/nav5.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/nav/nav55.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/nav/nav6.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/nav/nav66.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/nav/phone.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/nav/yolo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/pk-mini/AnchorLibrary.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src/assets/pk-mini/Delete.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
src/assets/pk-mini/Editor.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/pk-mini/Email.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/pk-mini/InTotal.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/pk-mini/Invitation.png
Normal file
|
After Width: | Height: | Size: 1006 B |
BIN
src/assets/pk-mini/InvitationSelected.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/pk-mini/PKInformation.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/assets/pk-mini/PKRecord.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/pk-mini/PKbackground.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
src/assets/pk-mini/Password.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/pk-mini/Points.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/assets/pk-mini/PointsList.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/pk-mini/Publish.png
Normal file
|
After Width: | Height: | Size: 530 B |
BIN
src/assets/pk-mini/PublishSelected.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/pk-mini/Reset.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/pk-mini/Search.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src/assets/pk-mini/bg.png
Normal file
|
After Width: | Height: | Size: 544 KiB |
BIN
src/assets/pk-mini/embellish.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/pk-mini/logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/pk-mini/messageVS.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/pk-mini/pkMessageleft.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/pk-mini/pkMessageright.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/pk-mini/selectSidebar.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/pk-mini/switchEmail.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src/assets/pk-mini/switchvx.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/pk-mini/topPosition.png
Normal file
|
After Width: | Height: | Size: 659 B |
BIN
src/assets/pk-mini/unpinned.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 252 KiB |
@@ -14,9 +14,19 @@
|
|||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="font-medium text-gray-800">源文本</span>
|
<span class="font-medium text-gray-800">源文本</span>
|
||||||
<div class="flex gap-2">
|
<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"
|
<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">
|
class="px-3 py-1 text-sm bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200">
|
||||||
新增一行
|
新增
|
||||||
</button>
|
</button>
|
||||||
<button @click="fetchPrologue" :disabled="isFetching || !isElectronEnv"
|
<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">
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange" @paste="handlePaste"
|
<!-- 批量导入模式 -->
|
||||||
placeholder="每行一句打招呼内容..."
|
<div v-if="inputMode === 'bulk'">
|
||||||
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
|
<textarea :value="bulkText || sentences.join('\n')" @input="handleBulkChange"
|
||||||
|
@paste="handlePaste" placeholder="每行一句打招呼内容..."
|
||||||
|
class="w-full h-32 p-3 border border-gray-300 rounded text-sm text-gray-900 resize-none focus:border-blue-500 focus:outline-none" />
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 mt-2">
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,13 +108,15 @@
|
|||||||
]">
|
]">
|
||||||
{{ region }}
|
{{ region }}
|
||||||
</div>
|
</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>
|
</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">
|
<div class="text-sm text-blue-800 font-medium mb-2">
|
||||||
选中 {{ selectedRegions.length }} 个大区,将翻译以下 {{ selectedLanguages.length }} 种语言:
|
选中 {{ selectedRegions.length }} 个大区,将翻译以下 {{ selectedLanguages.length }} 种语言:
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +144,8 @@
|
|||||||
<!-- 当前语言的翻译结果 -->
|
<!-- 当前语言的翻译结果 -->
|
||||||
<div class="space-y-2 max-h-40 overflow-auto">
|
<div class="space-y-2 max-h-40 overflow-auto">
|
||||||
<template v-if="translations[activeTab]?.length">
|
<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)"
|
<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" />
|
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">
|
<div class="relative group/source">
|
||||||
@@ -141,22 +174,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部 -->
|
<!-- 底部 -->
|
||||||
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-xs text-gray-500">
|
||||||
共 {{ sentences.filter(Boolean).length }} 句 · 选择 {{ selectedRegions.length }} 个大区 · {{
|
共 {{ sentences.filter(Boolean).length }} 句 · 选择 {{ selectedRegions.length }} 个大区 · {{
|
||||||
selectedLanguages.length }} 种语言
|
selectedLanguages.length }} 种语言
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-3">
|
<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 @click="onClose"
|
||||||
取消
|
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||||
</button>
|
取消
|
||||||
<button @click="handleConfirm"
|
</button>
|
||||||
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
<button @click="handleConfirm"
|
||||||
确定
|
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||||
</button>
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +214,7 @@ const REGION_LIST = getRegions()
|
|||||||
|
|
||||||
const sentences = ref([''])
|
const sentences = ref([''])
|
||||||
const bulkText = ref('')
|
const bulkText = ref('')
|
||||||
|
const inputMode = ref('bulk') // 'bulk' 或 'individual'
|
||||||
const selectedRegions = ref([])
|
const selectedRegions = ref([])
|
||||||
const translations = ref({})
|
const translations = ref({})
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
@@ -262,6 +297,7 @@ function loadFromStorage() {
|
|||||||
if (data.translations) translations.value = data.translations
|
if (data.translations) translations.value = data.translations
|
||||||
if (typeof data.needTranslate === 'boolean') needTranslate.value = data.needTranslate
|
if (typeof data.needTranslate === 'boolean') needTranslate.value = data.needTranslate
|
||||||
if (data.activeTab) activeTab.value = data.activeTab
|
if (data.activeTab) activeTab.value = data.activeTab
|
||||||
|
if (data.inputMode) inputMode.value = data.inputMode
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载本地数据失败:', e)
|
console.error('加载本地数据失败:', e)
|
||||||
}
|
}
|
||||||
@@ -275,6 +311,7 @@ function saveToStorage() {
|
|||||||
translations: translations.value,
|
translations: translations.value,
|
||||||
needTranslate: needTranslate.value,
|
needTranslate: needTranslate.value,
|
||||||
activeTab: activeTab.value,
|
activeTab: activeTab.value,
|
||||||
|
inputMode: inputMode.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +322,17 @@ const addSentence = () => {
|
|||||||
sentences.value.push('')
|
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) => {
|
const handlePaste = (e) => {
|
||||||
// e.preventDefault() handled by Vue if needed but standard logic applies
|
// e.preventDefault() handled by Vue if needed but standard logic applies
|
||||||
// We can rely on @paste event
|
// We can rely on @paste event
|
||||||
@@ -397,6 +445,7 @@ const handleTranslate = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.translate(joinedText, lang)
|
const result = await window.electronAPI.translate(joinedText, lang)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
console.log(`翻译结果完整 ${lang} 成功:`, result)
|
||||||
let translatedLines = result.result.split('\n').map(s => s.trim())
|
let translatedLines = result.result.split('\n').map(s => s.trim())
|
||||||
|
|
||||||
if (translatedLines.length > 0) {
|
if (translatedLines.length > 0) {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<div class="p-4 border-b border-gray-100 space-y-3">
|
<div class="p-4 border-b border-gray-100 space-y-3">
|
||||||
<div class="flex flex-wrap gap-2">
|
<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"
|
<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>
|
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"
|
<button @click="selectNone"
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" v-model="filters.gold" class="w-4 h-4" />
|
<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>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" v-model="filters.ordinary" class="w-4 h-4" />
|
<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="flex-1 overflow-auto p-4">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
<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="[
|
:class="[
|
||||||
'p-3 rounded-lg border cursor-pointer transition-all',
|
'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">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
|
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
|
||||||
@@ -140,7 +144,7 @@
|
|||||||
'px-1.5 py-0.5 rounded border',
|
'px-1.5 py-0.5 rounded border',
|
||||||
host.invitationType === 2 ? 'text-yellow-600 border-yellow-400' : 'border-gray-300'
|
host.invitationType === 2 ? 'text-yellow-600 border-yellow-400' : 'border-gray-300'
|
||||||
]">
|
]">
|
||||||
{{ host.invitationType === 2 ? '金票' : '普票' }}
|
{{ host.invitationType === 2 ? '进阶票' : '普票' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +168,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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,每行一个 例如: anchor_001 anchor_002 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>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -231,6 +313,57 @@ const maxCount = ref(100)
|
|||||||
const selectedLevels = ref(new Set())
|
const selectedLevels = ref(new Set())
|
||||||
const showLevelDropdown = ref(false)
|
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
|
// Lifecycle
|
||||||
watch(() => props.visible, (newVal) => {
|
watch(() => props.visible, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -273,6 +406,7 @@ const loadHosts = async () => {
|
|||||||
if (!isElectron()) return
|
if (!isElectron()) return
|
||||||
try {
|
try {
|
||||||
const data = await window.electronAPI.loadAnchorData()
|
const data = await window.electronAPI.loadAnchorData()
|
||||||
|
console.log('加载主播数据:', data)
|
||||||
hosts.value = data
|
hosts.value = data
|
||||||
selected.value = new Set()
|
selected.value = new Set()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -290,7 +424,7 @@ const loadConfig = async () => {
|
|||||||
if (config?.filters?.hostsLevelList) {
|
if (config?.filters?.hostsLevelList) {
|
||||||
selectedLevels.value = new Set(config.filters.hostsLevelList)
|
selectedLevels.value = new Set(config.filters.hostsLevelList)
|
||||||
}
|
}
|
||||||
// 加载金票/普票过滤配置
|
// 加载进阶票/普票过滤配置
|
||||||
if (config?.filters?.gold !== undefined) {
|
if (config?.filters?.gold !== undefined) {
|
||||||
filters.value.gold = config.filters.gold
|
filters.value.gold = config.filters.gold
|
||||||
}
|
}
|
||||||
@@ -398,7 +532,7 @@ const toggleSelect = (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
selected.value = new Set(filteredHosts.value.map(h => h.anchorId))
|
selected.value = new Set(filteredHosts.value.map(h => h.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectNone = () => {
|
const selectNone = () => {
|
||||||
@@ -413,22 +547,89 @@ const invertSelect = () => {
|
|||||||
selected.value = next
|
selected.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSelected = () => {
|
const deleteSelected = async () => {
|
||||||
if (!selected.value.size) return
|
if (!selected.value.size) return
|
||||||
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
|
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
|
||||||
|
console.log(selected.value)
|
||||||
const remaining = hosts.value.filter(h => !selected.value.has(h.anchorId))
|
if (isElectron()) {
|
||||||
hosts.value = remaining
|
try {
|
||||||
selected.value = new Set()
|
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 onClose = () => emit('close')
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (isElectron()) {
|
|
||||||
await window.electronAPI.saveAnchorData(JSON.parse(JSON.stringify(hosts.value)))
|
|
||||||
}
|
|
||||||
emit('save', hosts.value)
|
emit('save', hosts.value)
|
||||||
onClose()
|
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>
|
</script>
|
||||||
|
|||||||
302
src/components/NoticeBar.vue
Normal 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>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<!-- 无权限时显示遮罩和占位内容 -->
|
<!-- 无权限时显示遮罩和占位内容 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 占位背景 - 显示工作台截图作为假界面 -->
|
<!-- 占位背景 -->
|
||||||
<div class="permission-placeholder">
|
<div class="permission-placeholder">
|
||||||
<img v-if="placeholderImage" :src="placeholderImage" alt="" class="placeholder-image" />
|
<img v-if="placeholderImage" :src="placeholderImage" alt="" class="placeholder-image" />
|
||||||
<div v-else class="placeholder-pattern"></div>
|
<div v-else class="placeholder-pattern"></div>
|
||||||
@@ -15,18 +15,64 @@
|
|||||||
|
|
||||||
<!-- 权限遮罩层 -->
|
<!-- 权限遮罩层 -->
|
||||||
<div class="permission-mask" ref="maskRef" :data-permission-guard="guardKey">
|
<div class="permission-mask" ref="maskRef" :data-permission-guard="guardKey">
|
||||||
<div class="mask-content">
|
<!-- 上方:锁提示区域 -->
|
||||||
<div class="lock-icon-wrapper">
|
<button @click="refreshPage" style="
|
||||||
<svg class="lock-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
position: absolute; top: 20px; left: 20px;
|
||||||
<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"/>
|
padding: 10px 20px;
|
||||||
<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"/>
|
background: #fff;
|
||||||
</svg>
|
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>
|
</div>
|
||||||
<h3 class="mask-title">{{ title }}</h3>
|
</div>
|
||||||
<p class="mask-description">{{ description }}</p>
|
|
||||||
<div class="mask-hint">
|
<!-- 下方:名片区域 -->
|
||||||
<span class="material-icons-round hint-icon">info</span>
|
<div v-if="contacts && contacts.length" class="cards-area">
|
||||||
<span>请联系管理员开通权限</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,61 +82,113 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
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({
|
const props = defineProps({
|
||||||
/**
|
|
||||||
* 权限类型: 'bigBrother' | 'crawl' | 'webAi'
|
|
||||||
*/
|
|
||||||
permissionKey: {
|
permissionKey: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) => ['bigBrother', 'crawl', 'webAi'].includes(value)
|
validator: (value) => ['bigBrother', 'crawl', 'webAi'].includes(value)
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 遮罩标题
|
|
||||||
*/
|
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '功能未开通'
|
default: '功能未开通'
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 遮罩描述
|
|
||||||
*/
|
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '您当前没有使用此功能的权限'
|
default: '您当前没有使用此功能的权限'
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 占位图片路径(工作台截图)
|
|
||||||
*/
|
|
||||||
placeholderImage: {
|
placeholderImage: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
// 名片数据,每项: { avatar, name, desc, qrcode, phone }
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperRef = ref(null)
|
const wrapperRef = ref(null)
|
||||||
const maskRef = ref(null)
|
const maskRef = ref(null)
|
||||||
|
const visibleIndices = ref([])
|
||||||
|
const visibleContacts = ref([])
|
||||||
|
|
||||||
// 生成唯一的守卫标识
|
|
||||||
const guardKey = computed(() => `guard_${props.permissionKey}_${Date.now()}`)
|
const guardKey = computed(() => `guard_${props.permissionKey}_${Date.now()}`)
|
||||||
|
|
||||||
// 响应式权限检查
|
|
||||||
const permissionsData = ref(getPermissions())
|
const permissionsData = ref(getPermissions())
|
||||||
|
|
||||||
const hasAccess = computed(() => {
|
const hasAccess = computed(() => {
|
||||||
return permissionsData.value[props.permissionKey] === 1
|
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
|
let permissionCheckInterval = null
|
||||||
|
|
||||||
const refreshPermissions = () => {
|
const refreshPermissions = () => {
|
||||||
permissionsData.value = getPermissions()
|
permissionsData.value = getPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MutationObserver 监测DOM篡改
|
|
||||||
let observer = null
|
let observer = null
|
||||||
|
|
||||||
const setupDOMProtection = () => {
|
const setupDOMProtection = () => {
|
||||||
@@ -98,16 +196,13 @@ const setupDOMProtection = () => {
|
|||||||
|
|
||||||
observer = new MutationObserver((mutations) => {
|
observer = new MutationObserver((mutations) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
// 检测遮罩是否被删除
|
|
||||||
if (mutation.type === 'childList') {
|
if (mutation.type === 'childList') {
|
||||||
const maskExists = wrapperRef.value?.querySelector('.permission-mask')
|
const maskExists = wrapperRef.value?.querySelector('.permission-mask')
|
||||||
if (!maskExists && !hasAccess.value) {
|
if (!maskExists && !hasAccess.value) {
|
||||||
console.warn('[PermissionMask] 检测到权限遮罩被非法移除,正在重载页面...')
|
console.warn('[PermissionMask] 检测到权限遮罩被非法移除,正在重载页面...')
|
||||||
// 强制刷新页面
|
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 检测遮罩样式是否被修改(如display:none, visibility:hidden等)
|
|
||||||
if (mutation.type === 'attributes' && mutation.target.classList?.contains('permission-mask')) {
|
if (mutation.type === 'attributes' && mutation.target.classList?.contains('permission-mask')) {
|
||||||
const mask = mutation.target
|
const mask = mutation.target
|
||||||
const style = window.getComputedStyle(mask)
|
const style = window.getComputedStyle(mask)
|
||||||
@@ -128,30 +223,27 @@ const setupDOMProtection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 定时检查权限(每2秒)
|
updateVisibleContacts(false)
|
||||||
permissionCheckInterval = setInterval(refreshPermissions, 2000)
|
permissionCheckInterval = setInterval(refreshPermissions, 2000)
|
||||||
|
|
||||||
// 延迟设置DOM保护,确保元素已渲染
|
|
||||||
setTimeout(setupDOMProtection, 100)
|
setTimeout(setupDOMProtection, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (permissionCheckInterval) {
|
if (permissionCheckInterval) clearInterval(permissionCheckInterval)
|
||||||
clearInterval(permissionCheckInterval)
|
if (observer) observer.disconnect()
|
||||||
}
|
|
||||||
if (observer) {
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 权限变化时重新设置保护
|
watch(
|
||||||
|
() => props.contacts,
|
||||||
|
() => {
|
||||||
|
updateVisibleContacts(false)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(hasAccess, (newVal) => {
|
watch(hasAccess, (newVal) => {
|
||||||
if (observer) {
|
if (observer) observer.disconnect()
|
||||||
observer.disconnect()
|
if (!newVal) setTimeout(setupDOMProtection, 100)
|
||||||
}
|
|
||||||
if (!newVal) {
|
|
||||||
setTimeout(setupDOMProtection, 100)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -162,7 +254,6 @@ watch(hasAccess, (newVal) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 占位背景 - 无权限时显示,防止删除遮罩后看到内容 */
|
|
||||||
.permission-placeholder {
|
.permission-placeholder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -198,11 +289,21 @@ watch(hasAccess, (newVal) => {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
background: rgba(15, 23, 42, 0.75);
|
background: rgba(15, 23, 42, 0.75);
|
||||||
backdrop-filter: blur(1px);
|
backdrop-filter: blur(1px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上方锁提示区域 */
|
||||||
|
.mask-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 6vh;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mask-content {
|
.mask-content {
|
||||||
@@ -219,14 +320,8 @@ watch(hasAccess, (newVal) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
opacity: 0;
|
to { opacity: 1; transform: translateY(0); }
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lock-icon-wrapper {
|
.lock-icon-wrapper {
|
||||||
@@ -276,4 +371,136 @@ watch(hasAccess, (newVal) => {
|
|||||||
.hint-icon {
|
.hint-icon {
|
||||||
font-size: 1.125rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<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">
|
<div class="m-3 mb-0 flex gap-2">
|
||||||
<button @click="onGoBack"
|
<button @click="onGoBack"
|
||||||
@@ -71,7 +72,8 @@
|
|||||||
|
|
||||||
<!-- 详细统计 -->
|
<!-- 详细统计 -->
|
||||||
<div class="flex-1 min-h-0 border-t border-gray-200 flex flex-col">
|
<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>详细统计</span>
|
||||||
<span class="text-[10px] font-normal text-gray-400">招呼/邀请/回复</span>
|
<span class="text-[10px] font-normal text-gray-400">招呼/邀请/回复</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,14 +83,17 @@
|
|||||||
暂无统计数据
|
暂无统计数据
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<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">
|
<div class="px-3 py-1.5 bg-gray-100/50 text-xs font-medium text-gray-600">
|
||||||
{{ groupName }}
|
{{ groupName }}
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-gray-500">视图 {{ stat.viewId }}</span>
|
<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>
|
||||||
<div class="flex items-center gap-3 font-mono text-gray-700">
|
<div class="flex items-center gap-3 font-mono text-gray-700">
|
||||||
<span class="text-blue-600 w-6 text-right">{{ stat.greeting }}</span>
|
<span class="text-blue-600 w-6 text-right">{{ stat.greeting }}</span>
|
||||||
@@ -123,18 +128,114 @@
|
|||||||
未启动任务
|
未启动任务
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between text-xs">
|
||||||
<span class="text-gray-500">已打招呼</span>
|
<span class="text-gray-500">已打招呼</span>
|
||||||
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} 位</span>
|
<span class="text-blue-600 font-medium">{{ greetingStats.greetingCount }} 位</span>
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between text-xs">
|
||||||
<span class="text-gray-500">已回复</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +244,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tabs: { type: Array, required: true },
|
tabs: { type: Array, required: true },
|
||||||
currentTab: { type: String, required: true },
|
currentTab: { type: String, required: true },
|
||||||
@@ -154,11 +255,104 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ greetingCount: 0, inviteCount: 0 })
|
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 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
|
// Event handlers
|
||||||
const onTabSwitch = (id) => emit('tabSwitch', id)
|
const onTabSwitch = (id) => emit('tabSwitch', id)
|
||||||
const onGoBack = () => emit('goBack')
|
const onGoBack = () => emit('goBack')
|
||||||
|
|||||||
@@ -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">
|
<p v-if="updateInfo.releaseNotes" class="text-gray-500 text-xs bg-gray-50 p-2 rounded-lg line-clamp-3">
|
||||||
{{ updateInfo.releaseNotes }}
|
{{ updateInfo.releaseNotes }}
|
||||||
</p>
|
</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">
|
<!-- Mac 用户:跳转下载页面 -->
|
||||||
下载更新
|
<template v-if="isMac">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 下载中 -->
|
<!-- 下载中 -->
|
||||||
@@ -107,6 +128,7 @@ const {
|
|||||||
progress,
|
progress,
|
||||||
error,
|
error,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
isMac,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadUpdate,
|
downloadUpdate,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
@@ -120,4 +142,13 @@ function formatBytes(bytes) {
|
|||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
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>
|
</script>
|
||||||
|
|||||||
252
src/components/pk-mini/PkAppaside.vue
Normal 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>
|
||||||
434
src/components/pk-mini/chat/MiniPKMessage.vue
Normal 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>
|
||||||
116
src/components/pk-mini/chat/PKMessage.vue
Normal 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>
|
||||||
54
src/components/pk-mini/chat/PictureMessage.vue
Normal 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>
|
||||||
91
src/components/pk-mini/chat/VoiceMessage.vue
Normal 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>
|
||||||
442
src/components/pk-mini/mine/AnchorLibrary.vue
Normal 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>
|
||||||
501
src/components/pk-mini/mine/PKRecord.vue
Normal 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>
|
||||||
883
src/components/pk-mini/mine/PKmessage.vue
Normal 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>
|
||||||
151
src/components/pk-mini/mine/PointsList.vue
Normal 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>
|
||||||
190
src/composables/useCountryInfo.js
Normal 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
@@ -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
@@ -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
|
||||||
@@ -1,85 +1,141 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { isElectron } from '../utils/electronBridge'
|
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)
|
* 应用更新 Hook (Vue Composable)
|
||||||
* 管理更新状态、进度和操作
|
* 单例模式:所有组件共享同一个状态和监听器
|
||||||
* 注意:此 Composable 仅在 Electron 环境中有效
|
* 注意:此 Composable 仅在 Electron 环境中有效
|
||||||
*/
|
*/
|
||||||
export function useUpdate() {
|
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(() => {
|
onMounted(() => {
|
||||||
|
// 检测平台(确保只设置一次)
|
||||||
|
if (isElectron() && platform.value === 'unknown') {
|
||||||
|
platform.value = getPlatformInfo()
|
||||||
|
console.log('[useUpdate] 平台检测结果:', platform.value)
|
||||||
|
}
|
||||||
fetchVersion()
|
fetchVersion()
|
||||||
setupListeners()
|
setupListeners()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
// 注意:不在这里清理监听器,因为是单例模式
|
||||||
unsubList.forEach(unsub => unsub && unsub())
|
// 只在最后一个组件卸载时才清理(这里简化处理,不清理)
|
||||||
unsubList = []
|
// 如果需要严格清理,可以使用引用计数
|
||||||
})
|
|
||||||
|
|
||||||
const checkForUpdates = () => {
|
const checkForUpdates = () => {
|
||||||
if (!isElectron()) return
|
if (!isElectron()) return
|
||||||
@@ -123,6 +179,9 @@ export function useUpdate() {
|
|||||||
progress,
|
progress,
|
||||||
error,
|
error,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
platform,
|
||||||
|
isMac,
|
||||||
|
isWindows,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadUpdate,
|
downloadUpdate,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
|||||||
@@ -1,65 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen w-screen overflow-hidden bg-white">
|
<div class="flex h-screen w-screen overflow-hidden bg-white">
|
||||||
<!-- Left Navigation Sidebar -->
|
<!-- 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 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">
|
<div class="mb-6" style="border-bottom: 1px solid #A0AEC023; padding: 10%;">
|
||||||
<!-- Logo or Brand -->
|
<!-- 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">
|
<div class="" >
|
||||||
<span class="material-icons-round text-white">grid_view</span>
|
<img :src="yoloIcon" class="yolo-logo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col gap-4 w-full px-2">
|
<div class="flex-1 flex flex-col w-full px-2" style="gap: 2vh;">
|
||||||
<!-- Auto DM Workbench Tab -->
|
|
||||||
<button @click="currentView = 'auto_dm'"
|
|
||||||
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
|
|
||||||
:class="currentView === 'auto_dm' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
|
|
||||||
<span class="material-icons-round text-2xl">chat</span>
|
|
||||||
<div
|
|
||||||
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
|
||||||
自动私信工作台
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Fan Workbench Tab -->
|
|
||||||
<button @click="currentView = 'FanWorkbench'"
|
|
||||||
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
|
|
||||||
:class="currentView === 'FanWorkbench' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
|
|
||||||
<span class="material-icons-round text-2xl">supervised_user_circle</span>
|
|
||||||
<div
|
|
||||||
class="absolute left-14 bg-slate-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
|
||||||
大哥工作台
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- TK Workbench Tab -->
|
<!-- TK Workbench Tab -->
|
||||||
<button @click="currentView = 'tk'"
|
<button @click="currentView = 'tk'"
|
||||||
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
|
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
|
||||||
:class="currentView === 'tk' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
|
style="height: 6vh;"
|
||||||
<span class="material-icons-round text-2xl">tiktok</span>
|
:class="currentView === 'tk' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
|
||||||
<div
|
<img :src="currentView === 'tk' ? nav11 : nav1" class="w-9 h-9 object-contain flex-shrink-0" />
|
||||||
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">
|
<span class="text-base font-medium truncate">TK 工作台</span>
|
||||||
TK 工作台
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Hosts List Tab -->
|
<!-- Hosts List Tab -->
|
||||||
<button @click="currentView = 'hosts'"
|
<button @click="currentView = 'hosts'"
|
||||||
class="w-full aspect-square rounded-xl flex items-center justify-center transition-all duration-200 group relative"
|
class="w-full rounded-xl flex items-center gap-2 px-3 py-2.5 transition-all duration-200"
|
||||||
:class="currentView === 'hosts' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30' : 'text-slate-400 hover:bg-slate-800 hover:text-white'">
|
style="height: 6vh;"
|
||||||
<span class="material-icons-round text-2xl">people</span>
|
:class="currentView === 'hosts' ? 'bg-white text-blue-600 shadow shadow-blue-900/20' : 'text-slate-400 hover:bg-[rgba(21,96,250,0.06)]'">
|
||||||
<div
|
<img :src="currentView === 'hosts' ? nav22 : nav2" class="w-9 h-9 object-contain flex-shrink-0" />
|
||||||
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">
|
<span class="text-base font-medium truncate">主播列表</span>
|
||||||
主播列表
|
</button>
|
||||||
</div>
|
|
||||||
|
<!-- 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto">
|
<div class="mt-auto w-full px-2">
|
||||||
<!-- Logout or Back -->
|
<!-- Logout -->
|
||||||
<button @click="$emit('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">
|
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">
|
||||||
<span class="material-icons-round">logout</span>
|
<img :src="backIcon" class="w-9 h-9 object-contain flex-shrink-0" />
|
||||||
|
<span class="text-base font-medium" style="color: #ED4949;">退出登录</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +86,7 @@
|
|||||||
title="自动私信工作台未开通"
|
title="自动私信工作台未开通"
|
||||||
description="您当前没有使用自动私信功能的权限"
|
description="您当前没有使用自动私信功能的权限"
|
||||||
:placeholder-image="placeholderWebAi"
|
:placeholder-image="placeholderWebAi"
|
||||||
|
:contacts="serviceContacts"
|
||||||
>
|
>
|
||||||
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
|
<div v-if="autoDmMode === 'config'" class="h-full w-full bg-slate-50 overflow-auto">
|
||||||
<ConfigPage
|
<ConfigPage
|
||||||
@@ -84,6 +97,7 @@
|
|||||||
<div v-show="autoDmMode === 'browser'" class="h-full w-full">
|
<div v-show="autoDmMode === 'browser'" class="h-full w-full">
|
||||||
<YoloBrowser
|
<YoloBrowser
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
:nav-sidebar-width="navSidebarWidth"
|
||||||
@go-back="handleBackToConfig"
|
@go-back="handleBackToConfig"
|
||||||
@stop-all="handleStopAll"
|
@stop-all="handleStopAll"
|
||||||
/>
|
/>
|
||||||
@@ -98,47 +112,88 @@
|
|||||||
title="TK工作台未开通"
|
title="TK工作台未开通"
|
||||||
description="您当前没有使用TK工作台功能的权限"
|
description="您当前没有使用TK工作台功能的权限"
|
||||||
:placeholder-image="placeholderTk"
|
:placeholder-image="placeholderTk"
|
||||||
|
:contacts="serviceContacts"
|
||||||
>
|
>
|
||||||
<TkWorkbenches />
|
<TkWorkbenches />
|
||||||
</PermissionMask>
|
</PermissionMask>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 3: Hosts List - crawl 权限 -->
|
<!-- 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
|
<PermissionMask
|
||||||
permission-key="crawl"
|
permission-key="crawl"
|
||||||
title="主播列表未开通"
|
title="主播列表未开通"
|
||||||
description="您当前没有使用主播列表功能的权限"
|
description="您当前没有使用主播列表功能的权限"
|
||||||
:placeholder-image="placeholderHosts"
|
:placeholder-image="placeholderHosts"
|
||||||
|
:contacts="serviceContacts"
|
||||||
>
|
>
|
||||||
<HostsList />
|
<HostsList />
|
||||||
</PermissionMask>
|
</PermissionMask>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 4: Fan Workbench - bigBrother 权限 -->
|
<!-- 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
|
<PermissionMask
|
||||||
permission-key="bigBrother"
|
permission-key="bigBrother"
|
||||||
title="大哥工作台未开通"
|
title="大哥工作台未开通"
|
||||||
description="您当前没有使用大哥工作台功能的权限"
|
description="您当前没有使用大哥工作台功能的权限"
|
||||||
:placeholder-image="placeholderBigBrother"
|
:placeholder-image="placeholderBigBrother"
|
||||||
|
:contacts="serviceContacts"
|
||||||
>
|
>
|
||||||
<FanWorkbench />
|
<FanWorkbench />
|
||||||
</PermissionMask>
|
</PermissionMask>
|
||||||
</div>
|
</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 用 BrowserView,Web 用 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||||
import { isElectron } from '@/utils/electronBridge'
|
import { isElectron } from '@/utils/electronBridge'
|
||||||
import YoloBrowser from '@/views/YoloBrowser.vue'
|
import YoloBrowser from '@/views/YoloBrowser.vue'
|
||||||
import TkWorkbenches from '@/views/tk/Workbenches.vue'
|
import TkWorkbenches from '@/views/tk/Workbenches.vue'
|
||||||
import HostsList from '@/views/tk/HostsList.vue'
|
import HostsList from '@/views/tk/HostsList.vue'
|
||||||
import ConfigPage from '@/pages/ConfigPage.vue'
|
import ConfigPage from '@/pages/ConfigPage.vue'
|
||||||
import FanWorkbench from '@/views/tk/FanWorkbench.vue'
|
import FanWorkbench from '@/views/tk/FanWorkbench.vue'
|
||||||
|
import PkMiniWorkbench from '@/views/pk-mini/PkMiniWorkbench.vue'
|
||||||
import PermissionMask from '@/components/PermissionMask.vue'
|
import PermissionMask from '@/components/PermissionMask.vue'
|
||||||
|
import { ENV } from '@/config'
|
||||||
|
import { getCustomServiceInfo } from '@/api/account'
|
||||||
|
|
||||||
|
// 导航图标
|
||||||
|
import yoloIcon from '@/assets/nav/yolo.png'
|
||||||
|
import nav1 from '@/assets/nav/nav1.png'
|
||||||
|
import nav11 from '@/assets/nav/nav11.png'
|
||||||
|
import nav2 from '@/assets/nav/nav2.png'
|
||||||
|
import nav22 from '@/assets/nav/nav22.png'
|
||||||
|
import nav3 from '@/assets/nav/nav3.png'
|
||||||
|
import nav33 from '@/assets/nav/nav33.png'
|
||||||
|
import nav4 from '@/assets/nav/nav4.png'
|
||||||
|
import nav44 from '@/assets/nav/nav44.png'
|
||||||
|
import nav5 from '@/assets/nav/nav5.png'
|
||||||
|
import nav55 from '@/assets/nav/nav55.png'
|
||||||
|
import nav6 from '@/assets/nav/nav6.png'
|
||||||
|
import nav66 from '@/assets/nav/nav66.png'
|
||||||
|
import backIcon from '@/assets/nav/back.png'
|
||||||
|
|
||||||
// 占位图片 - 无权限时显示的工作台截图
|
// 占位图片 - 无权限时显示的工作台截图
|
||||||
import placeholderTk from '@/assets/placeholder-tk.png'
|
import 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 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 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 () => {
|
const handleGoToBrowser = async () => {
|
||||||
autoDmMode.value = 'browser'
|
autoDmMode.value = 'browser'
|
||||||
@@ -171,8 +276,36 @@ const handleStopAll = () => {
|
|||||||
|
|
||||||
// Watch for view changes to manage native Electron BrowserViews
|
// Watch for view changes to manage native Electron BrowserViews
|
||||||
watch(currentView, async (newVal, oldVal) => {
|
watch(currentView, async (newVal, oldVal) => {
|
||||||
|
// 懒加载 Web 端 iframe(仅非 Electron)
|
||||||
|
if (newVal === 'shop' && !adminLoaded.value && !isElectron()) {
|
||||||
|
adminLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
if (!isElectron()) return
|
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') {
|
if (newVal === 'auto_dm' && autoDmMode.value === 'browser') {
|
||||||
// Switching TO Auto DM tab AND we are in browser mode: Show views
|
// Switching TO Auto DM tab AND we are in browser mode: Show views
|
||||||
try {
|
try {
|
||||||
@@ -205,6 +338,10 @@ watch(autoDmMode, async (newVal) => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* Material Icons support - simplistic import, ideal to put in index.html or main.js */
|
/* 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');
|
@import url('https://fonts.googleapis.com/icon?family=Material+Icons+Round');
|
||||||
|
|
||||||
|
.yolo-logo{
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export default {
|
|||||||
prompt: 'Stop crawling specified number',
|
prompt: 'Stop crawling specified number',
|
||||||
setHostNum: 'Set crawling quantity',
|
setHostNum: 'Set crawling quantity',
|
||||||
unlimitedQuantity: 'Unlimited 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: {
|
hostList: {
|
||||||
placeCountry: 'Select country',
|
placeCountry: 'Select country',
|
||||||
@@ -79,6 +93,7 @@ export default {
|
|||||||
invitationType: 'invitationType',
|
invitationType: 'invitationType',
|
||||||
invitationType1: 'Regular',
|
invitationType1: 'Regular',
|
||||||
invitationType2: 'Golden',
|
invitationType2: 'Golden',
|
||||||
|
invitationType3: 'Linked Account',
|
||||||
liveSessions: 'Live Sessions',
|
liveSessions: 'Live Sessions',
|
||||||
viewSessions: 'View Sessions',
|
viewSessions: 'View Sessions',
|
||||||
liveRevenue: 'Live Revenue',
|
liveRevenue: 'Live Revenue',
|
||||||
@@ -93,6 +108,16 @@ export default {
|
|||||||
revenueTime: 'Time',
|
revenueTime: 'Time',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
selectPlaceholder: 'Please select',
|
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: {
|
hostsList: {
|
||||||
filterPrivateUsers: 'Filter Private Users',
|
filterPrivateUsers: 'Filter Private Users',
|
||||||
@@ -156,6 +181,18 @@ export default {
|
|||||||
starting: 'Starting...',
|
starting: 'Starting...',
|
||||||
pleaseEnterCountryName: 'Please enter the country name in Chinese',
|
pleaseEnterCountryName: 'Please enter the country name in Chinese',
|
||||||
getCountryFailed: 'Failed to get country',
|
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: {
|
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.)
|
// ... (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",
|
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",
|
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"
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,20 @@ export default {
|
|||||||
prompt: '达到数量后停止爬取',
|
prompt: '达到数量后停止爬取',
|
||||||
setHostNum: '设置爬取数量',
|
setHostNum: '设置爬取数量',
|
||||||
unlimitedQuantity: '不限爬取数量',
|
unlimitedQuantity: '不限爬取数量',
|
||||||
|
refreshCountry: '刷新国家',
|
||||||
|
refreshSuccess: '刷新成功',
|
||||||
|
refreshFailed: '刷新失败',
|
||||||
|
enterCountryPrompt: '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
|
||||||
|
enterCountryTitle: '获取国家失败',
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消',
|
||||||
|
countryPlaceholder: '例如:美国、日本、英国',
|
||||||
|
countryRequired: '请输入国家名称',
|
||||||
|
countrySetSuccess: '国家设置成功',
|
||||||
|
unknown: '未知',
|
||||||
|
editCountry: '编辑国家',
|
||||||
|
editCountryPrompt: '请输入国家名称(中文)',
|
||||||
|
editCountryTitle: '编辑国家',
|
||||||
},
|
},
|
||||||
hostList: {
|
hostList: {
|
||||||
placeCountry: '选择国家',
|
placeCountry: '选择国家',
|
||||||
@@ -78,7 +92,8 @@ export default {
|
|||||||
sure: '确定',
|
sure: '确定',
|
||||||
invitationType: '邀请类型',
|
invitationType: '邀请类型',
|
||||||
invitationType1: '普票',
|
invitationType1: '普票',
|
||||||
invitationType2: '金票',
|
invitationType2: '进阶票',
|
||||||
|
invitationType3: '关联账号',
|
||||||
liveSessions: '直播场次',
|
liveSessions: '直播场次',
|
||||||
viewSessions: '查看场次',
|
viewSessions: '查看场次',
|
||||||
liveRevenue: '直播收益',
|
liveRevenue: '直播收益',
|
||||||
@@ -93,6 +108,16 @@ export default {
|
|||||||
revenueTime: '时间',
|
revenueTime: '时间',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
selectPlaceholder: '请选择',
|
selectPlaceholder: '请选择',
|
||||||
|
searchPlaceholder: '搜索...',
|
||||||
|
onlyAbnormal: '只看异常',
|
||||||
|
total: '总条数',
|
||||||
|
totalLikes: '点赞合计',
|
||||||
|
zeroLikes: '无点赞',
|
||||||
|
startTime: '开始时间',
|
||||||
|
endTime: '结束时间',
|
||||||
|
duration: '时长',
|
||||||
|
likeCount: '点赞',
|
||||||
|
createTime: '入库时间',
|
||||||
},
|
},
|
||||||
hostsList: {
|
hostsList: {
|
||||||
filterPrivateUsers: '过滤隐私用户',
|
filterPrivateUsers: '过滤隐私用户',
|
||||||
@@ -156,19 +181,97 @@ export default {
|
|||||||
stopping: '正在停止...',
|
stopping: '正在停止...',
|
||||||
starting: '正在启动...',
|
starting: '正在启动...',
|
||||||
enterRoomId: '请输入直播间id',
|
enterRoomId: '请输入直播间id',
|
||||||
|
refreshCountry: '刷新国家',
|
||||||
|
refreshSuccess: '刷新成功',
|
||||||
|
refreshFailed: '刷新失败',
|
||||||
|
enterCountryPrompt: '由于网络原因无法自动获取国家信息,请手动输入当前网络所在国家(中文名)',
|
||||||
|
enterCountryTitle: '获取国家失败',
|
||||||
|
countryPlaceholder: '例如:美国、日本、英国',
|
||||||
|
countryRequired: '请输入国家名称',
|
||||||
|
countrySetSuccess: '国家设置成功',
|
||||||
|
unknown: '未知',
|
||||||
|
editCountry: '编辑国家',
|
||||||
|
editCountryPrompt: '请输入国家名称(中文)',
|
||||||
|
editCountryTitle: '编辑国家',
|
||||||
},
|
},
|
||||||
countries: {
|
countries: {
|
||||||
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
|
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: "捷克共和国",
|
notice: {
|
||||||
DE: "德国", DG: "迪戈加西亚岛", DJ: "吉布提", DK: "丹麦", DM: "多米尼克", DO: "多米尼加共和国", DZ: "阿尔及利亚", EC: "厄瓜多尔", EE: "爱沙尼亚", EG: "埃及", EH: "西撒哈拉", ER: "厄立特里亚", ES: "西班牙", ET: "埃塞俄比亚", FI: "芬兰", FJ: "斐济", FK: "福克兰群岛", FM: "密克罗尼西亚", FO: "法罗群岛", FR: "法国",
|
close: '关闭通知',
|
||||||
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: "意大利",
|
// PK Mini 模块翻译
|
||||||
JE: "泽西岛", JM: "牙买加", JO: "约旦", JP: "日本", JP1: "日本", KE: "肯尼亚", KG: "吉尔吉斯斯坦", KH: "柬埔寨", KI: "基里巴斯", KM: "科摩罗", KN: "圣基茨和尼维斯", KP: "朝鲜", KR: "韩国", KR1: "韩国", KW: "科威特", KY: "开曼群岛", KZ: "哈萨克斯坦",
|
pkMini: {
|
||||||
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: "卢旺达",
|
pkHall: 'PK大厅',
|
||||||
SA: "沙特阿拉伯", SB: "索罗门群岛", SC: "塞舌尔", SD: "苏丹", SE: "瑞典", SG: "新加坡", SI: "斯洛文尼亚", SJ: "斯瓦尔巴和扬马延", SK: "斯洛伐克", SL: "塞拉利昂", SM: "圣马利诺", SN: "塞内加尔", SO: "索马里", SR: "苏里南", SS: "南苏丹", ST: "圣多美和普林西比", SV: "萨尔瓦多", SX: "荷属圣马丁", SY: "叙利亚", SZ: "斯威士兰",
|
todayPK: '今日PK',
|
||||||
TC: "特克斯和凯科斯群岛", TD: "乍得", TF: "法属南部领地", TG: "多哥", TH: "泰国", TJ: "塔吉克斯坦", TK: "托克劳群岛", TL: "东帝汶", TM: "土库曼斯坦", TN: "突尼斯", TO: "汤加", TR: "土耳其", TT: "特立尼达和多巴哥", TV: "图瓦卢", TW: "台湾", TZ: "坦桑尼亚", UA: "乌克兰", UG: "乌干达", UM: "美国本土外小岛屿", US: "美国", UY: "乌拉圭", UZ: "乌兹别克斯坦",
|
forum: '站内信',
|
||||||
VA: "梵蒂冈", VC: "圣文森特", VE: "委内瑞拉", VG: "英属维尔京群岛", VI: "美属维尔京群岛", VN: "越南", VN1: "越南", VU: "瓦努阿图", WS: "萨摩亚", YE: "也门", YT: "马约特岛", ZA: "南非", ZM: "赞比亚", ZW: "津巴布韦"
|
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: '确认'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +325,34 @@
|
|||||||
|
|
||||||
<!-- 打招呼内容弹窗 -->
|
<!-- 打招呼内容弹窗 -->
|
||||||
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -517,7 +545,11 @@ const saveToLocalStorage = () => {
|
|||||||
const saveToFile = async () => {
|
const saveToFile = async () => {
|
||||||
if (!isElectronEnv) return
|
if (!isElectronEnv) return
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.saveRunConfig(JSON.parse(JSON.stringify(config.value)))
|
const configToSave = JSON.parse(JSON.stringify(config.value))
|
||||||
|
// ConfigPage 不管理 filters,HostListDialog 会单独管理
|
||||||
|
// 删除 filters 避免用 ConfigPage 中可能过期的状态覆盖后端
|
||||||
|
delete configToSave.filters
|
||||||
|
await window.electronAPI.saveRunConfig(configToSave)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('保存配置失败:', e)
|
console.error('保存配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -631,7 +663,7 @@ const handleSleepTimeInput = (val) => {
|
|||||||
config.value.sleepTime = parseInt(val) || 0
|
config.value.sleepTime = parseInt(val) || 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const warmingUp = ref(false)
|
||||||
// Start/Stop
|
// Start/Stop
|
||||||
const handleStart = async (specificGroupIndex) => {
|
const handleStart = async (specificGroupIndex) => {
|
||||||
const activeGroupIndex = specificGroupIndex ?? 0
|
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(
|
const results = await Promise.allSettled(
|
||||||
startTasks.map(async ({ viewId, account, delay }) => {
|
startTasks.map(async ({ viewId, account, delay }) => {
|
||||||
await new Promise(r => setTimeout(r, delay))
|
await new Promise(r => setTimeout(r, delay))
|
||||||
@@ -731,6 +773,7 @@ const handleStart = async (specificGroupIndex) => {
|
|||||||
} else if (firstError.result.status === 'fulfilled') {
|
} else if (firstError.result.status === 'fulfilled') {
|
||||||
errorMsg = firstError.result.value.error || '启动失败'
|
errorMsg = firstError.result.value.error || '启动失败'
|
||||||
}
|
}
|
||||||
|
warmingUp.value = false
|
||||||
alert(`启动失败:${errorMsg}`)
|
alert(`启动失败:${errorMsg}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -745,6 +788,7 @@ const handleStart = async (specificGroupIndex) => {
|
|||||||
const status = await window.electronAPI.getRotationStatus()
|
const status = await window.electronAPI.getRotationStatus()
|
||||||
rotationStatus.value = status
|
rotationStatus.value = status
|
||||||
handleStatusChange(status)
|
handleStatusChange(status)
|
||||||
|
warmingUp.value = false //关闭遮罩
|
||||||
emit('goToBrowser')
|
emit('goToBrowser')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,3 +854,14 @@ const togglePasswordVisibility = (gIndex, aIndex) => {
|
|||||||
showPasswordMap.value[key] = !showPasswordMap.value[key]
|
showPasswordMap.value[key] = !showPasswordMap.value[key]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
```
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
|
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">
|
<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 -->
|
<!-- Language Selector -->
|
||||||
<el-dropdown>
|
<el-dropdown>
|
||||||
<div
|
<div
|
||||||
@@ -35,100 +29,265 @@
|
|||||||
|
|
||||||
<!-- Left Side: Form -->
|
<!-- Left Side: Form -->
|
||||||
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
|
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
|
||||||
<!-- Header / Logo -->
|
<!-- Header / Logo(注册页隐藏,避免表单被挤出屏幕) -->
|
||||||
<div class="flex justify-center">
|
<div v-show="mode === 'login'" class="flex justify-center">
|
||||||
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
|
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<!-- ==================== 登录表单 ==================== -->
|
||||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
|
<template v-if="mode === 'login'">
|
||||||
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
|
<div class="mb-8">
|
||||||
</div>
|
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
|
||||||
|
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
|
||||||
<!-- 租户号 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
|
|
||||||
<div class="relative rounded-md shadow-sm">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
|
|
||||||
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 账号 -->
|
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||||
<div>
|
<!-- 租户号 -->
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
|
<div>
|
||||||
<div class="relative rounded-md shadow-sm">
|
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="relative rounded-md shadow-sm">
|
||||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
stroke="currentColor">
|
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
stroke="currentColor">
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</svg>
|
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" />
|
||||||
</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>
|
</svg>
|
||||||
登录中
|
</div>
|
||||||
</span>
|
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
|
||||||
</template>
|
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" />
|
||||||
<template v-else>
|
</div>
|
||||||
登 录
|
</div>
|
||||||
</template>
|
|
||||||
|
<!-- 账号 -->
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,42 +315,51 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { isElectron, getAppVersion } from '../utils/electronBridge'
|
import { isElectron, getAppVersion } from '../utils/electronBridge'
|
||||||
import { setUser, setToken, setUserPass, getUserPass, setPermissions } from '@/utils/storage'
|
import { setUser, setToken, setUserPass, getUserPass, setPermissions } from '@/utils/storage'
|
||||||
|
import { tenantRegister } from '@/api/register'
|
||||||
import logo from '../assets/logo.png'
|
import logo from '../assets/logo.png'
|
||||||
import illustration from '../assets/illustration.png'
|
import illustration from '../assets/illustration.webp'
|
||||||
|
|
||||||
const emit = defineEmits(['loginSuccess'])
|
const emit = defineEmits(['loginSuccess'])
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
// 当前模式:login / register
|
||||||
|
const mode = ref('login')
|
||||||
|
|
||||||
// Language Switcher
|
// Language Switcher
|
||||||
const switchLanguage = (lang) => {
|
const switchLanguage = (lang) => {
|
||||||
locale.value = lang
|
locale.value = lang
|
||||||
localStorage.setItem('lang', 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({
|
const credentials = ref({
|
||||||
tenantName: '',
|
tenantName: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const version = ref('')
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 获取应用版本
|
|
||||||
getAppVersion().then(v => {
|
getAppVersion().then(v => {
|
||||||
version.value = v
|
version.value = v
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载保存的凭据
|
|
||||||
try {
|
try {
|
||||||
const saved = getUserPass()
|
const saved = getUserPass()
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -214,7 +382,6 @@ const handleSubmit = async () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 保存凭据 (Using compatible storage helper)
|
|
||||||
setUserPass(credentials.value)
|
setUserPass(credentials.value)
|
||||||
|
|
||||||
console.log('[LoginPage] 开始登录...', credentials.value)
|
console.log('[LoginPage] 开始登录...', credentials.value)
|
||||||
@@ -229,11 +396,9 @@ const handleSubmit = async () => {
|
|||||||
console.log('[LoginPage] 登录结果:', result)
|
console.log('[LoginPage] 登录结果:', result)
|
||||||
|
|
||||||
if (result.success && result.user) {
|
if (result.success && result.user) {
|
||||||
// Save token and user info to localStorage using legacy keys to support ported views
|
|
||||||
setToken(result.user.tokenValue);
|
setToken(result.user.tokenValue);
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
|
|
||||||
// 保存权限信息
|
|
||||||
setPermissions({
|
setPermissions({
|
||||||
bigBrother: result.user.bigBrother,
|
bigBrother: result.user.bigBrother,
|
||||||
crawl: result.user.crawl,
|
crawl: result.user.crawl,
|
||||||
@@ -250,6 +415,156 @@ const handleSubmit = async () => {
|
|||||||
isLoading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -76,10 +76,27 @@
|
|||||||
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
|
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="downloadUpdate"
|
<!-- Mac 用户:复制下载链接 -->
|
||||||
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">
|
<template v-if="isMac">
|
||||||
立即下载更新
|
<p class="text-amber-600 text-sm bg-amber-50 p-3 rounded-lg text-center">
|
||||||
</button>
|
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">
|
<p class="text-center text-gray-400 text-xs">
|
||||||
必须更新后才能使用程序
|
必须更新后才能使用程序
|
||||||
@@ -185,6 +202,7 @@ const {
|
|||||||
progress,
|
progress,
|
||||||
error,
|
error,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
isMac,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadUpdate,
|
downloadUpdate,
|
||||||
installUpdate
|
installUpdate
|
||||||
@@ -197,6 +215,8 @@ const retryCount = ref(0)
|
|||||||
const isTimeout = ref(false)
|
const isTimeout = ref(false)
|
||||||
const showTimeoutError = ref(false)
|
const showTimeoutError = ref(false)
|
||||||
const countdown = ref(AUTO_INSTALL_DELAY)
|
const countdown = ref(AUTO_INSTALL_DELAY)
|
||||||
|
const downloadUrl = 'https://yolozs.com/'
|
||||||
|
const copySuccess = ref(false)
|
||||||
|
|
||||||
let timeoutTimer = null
|
let timeoutTimer = null
|
||||||
let hasStarted = false
|
let hasStarted = false
|
||||||
@@ -304,6 +324,14 @@ function formatBytes(bytes) {
|
|||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
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(() => {
|
onUnmounted(() => {
|
||||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
|||||||
194
src/stores/countryStore.js
Normal 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
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
62
src/stores/pk-mini/notice.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
/* 自定义样式 */
|
/* 自定义样式 */
|
||||||
:root {
|
:root {
|
||||||
--sidebar-width: 200px;
|
--sidebar-width: 200px;
|
||||||
--color-bg-dark: #0f172a;
|
|
||||||
--color-bg-sidebar: #1e293b;
|
--color-bg-sidebar: #1e293b;
|
||||||
--color-accent: #38bdf8;
|
--color-accent: #38bdf8;
|
||||||
--color-text: #e2e8f0;
|
--color-text: #e2e8f0;
|
||||||
|
|||||||
6
src/types/electron.d.ts
vendored
@@ -81,6 +81,7 @@ export interface ElectronAPI {
|
|||||||
// 基础视图控制
|
// 基础视图控制
|
||||||
hideViews: () => Promise<{ success: boolean }>
|
hideViews: () => Promise<{ success: boolean }>
|
||||||
showViews: () => Promise<{ success: boolean }>
|
showViews: () => Promise<{ success: boolean }>
|
||||||
|
warmUpViews: () => Promise<{ success: boolean; error?: string }>
|
||||||
switchTab: (tab: TabId) => Promise<{ success: boolean; currentTab?: TabId; error?: string }>
|
switchTab: (tab: TabId) => Promise<{ success: boolean; currentTab?: TabId; error?: string }>
|
||||||
switchToView: (viewId: number) => Promise<{ success: boolean; currentViewId?: number; error?: string }>
|
switchToView: (viewId: number) => Promise<{ success: boolean; currentViewId?: number; error?: string }>
|
||||||
getCurrentTab: () => Promise<TabId>
|
getCurrentTab: () => Promise<TabId>
|
||||||
@@ -111,6 +112,9 @@ export interface ElectronAPI {
|
|||||||
loadAIConfig: () => Promise<Record<string, unknown>>
|
loadAIConfig: () => Promise<Record<string, unknown>>
|
||||||
loadAnchorData: () => Promise<unknown[]>
|
loadAnchorData: () => Promise<unknown[]>
|
||||||
saveAnchorData: (data: unknown[]) => Promise<{ success: boolean }>
|
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 }>
|
saveRunConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
|
||||||
loadRunConfig: () => Promise<Record<string, unknown> | null>
|
loadRunConfig: () => Promise<Record<string, unknown> | null>
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
// 打招呼统计
|
// 打招呼统计
|
||||||
getGreetingStats: () => Promise<GreetingStats>
|
getGreetingStats: () => Promise<GreetingStats>
|
||||||
|
getRepliedSessions: () => Promise<Array<{ name: string; id: string }>>
|
||||||
|
|
||||||
// 获取打招呼内容
|
// 获取打招呼内容
|
||||||
fetchPrologue: () => Promise<{ success: boolean; data?: string[]; error?: string }>
|
fetchPrologue: () => Promise<{ success: boolean; data?: string[]; error?: string }>
|
||||||
@@ -146,6 +151,7 @@ export interface ElectronAPI {
|
|||||||
onUpdateProgress: (callback: (progress: UpdateProgress) => void) => () => void
|
onUpdateProgress: (callback: (progress: UpdateProgress) => void) => () => void
|
||||||
onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void
|
onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void
|
||||||
onUpdateError: (callback: (error: { message: string }) => void) => () => void
|
onUpdateError: (callback: (error: { message: string }) => void) => () => void
|
||||||
|
onUpdateManualInstall: (callback: (info: { path: string }) => void) => () => void
|
||||||
onGreetingStatsChanged: (callback: (stats: GreetingStats) => void) => () => void
|
onGreetingStatsChanged: (callback: (stats: GreetingStats) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,13 @@ import router from '@/router'
|
|||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { usePythonBridge, } from '@/utils/pythonBridge'
|
import { usePythonBridge, } from '@/utils/pythonBridge'
|
||||||
|
import { ENV } from '@/config'
|
||||||
|
|
||||||
const { stopScript } = usePythonBridge();
|
const { stopScript } = usePythonBridge();
|
||||||
|
|
||||||
|
|
||||||
// 请求地址前缀
|
// 请求地址前缀
|
||||||
let baseURL = ''
|
const baseURL = ENV.API_BASE_URL
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
// 生产环境
|
|
||||||
// baseURL = "https://api.tkpage.yolozs.com"
|
|
||||||
baseURL = "http://192.168.2.22:8101"
|
|
||||||
// baseURL = "https://crawlclient.api.yolozs.com"
|
|
||||||
} else {
|
|
||||||
// 测试环境
|
|
||||||
// baseURL = "http://120.26.251.180:8085/"
|
|
||||||
// 开发环境
|
|
||||||
baseURL = "https://crawlclient.api.yolozs.com"
|
|
||||||
// baseURL = "http://api.tkpage.vvtiktok.cn"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
axios.interceptors.request.use((config) => {
|
axios.interceptors.request.use((config) => {
|
||||||
|
|||||||
136
src/utils/pk-mini/countryUtil.js
Normal 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
@@ -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 message,code:' + 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 message,code:' + 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 message,code:' + 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 }
|
||||||
79
src/utils/pk-mini/storage.js
Normal 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
|
||||||
|
}
|
||||||
13
src/utils/pk-mini/timeConversion.js
Normal 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}`
|
||||||
|
}
|
||||||
27
src/utils/pk-mini/timeDisplay.js
Normal 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
|
||||||
|
}
|
||||||
@@ -36,7 +36,11 @@ export function usePythonBridge() {
|
|||||||
if (!inElectron) return;
|
if (!inElectron) return;
|
||||||
await window.electronAPI.tk.loginTikTok();
|
await window.electronAPI.tk.loginTikTok();
|
||||||
};
|
};
|
||||||
|
// loginTikTok
|
||||||
|
const loginBigTikTok = async () => {
|
||||||
|
if (!inElectron) return;
|
||||||
|
await window.electronAPI.tk.loginBigTikTok();
|
||||||
|
};
|
||||||
// loginBackStage
|
// loginBackStage
|
||||||
const loginBackStage = async (data) => {
|
const loginBackStage = async (data) => {
|
||||||
if (!inElectron) return;
|
if (!inElectron) return;
|
||||||
@@ -53,16 +57,22 @@ export function usePythonBridge() {
|
|||||||
await window.electronAPI.tk.visitAnchor(id);
|
await window.electronAPI.tk.visitAnchor(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// backStageloginStatus
|
// 查询后台登录状态(合并接口,通过 account 参数区分)
|
||||||
const backStageloginStatus = async () => {
|
// account: 公会账号,不传则返回所有账号状态
|
||||||
|
const backStageloginStatus = async (account) => {
|
||||||
if (!inElectron) return null;
|
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 () => {
|
const backStageloginStatusCopy = async (account) => {
|
||||||
if (!inElectron) return null;
|
return await backStageloginStatus(account);
|
||||||
return await window.electronAPI.tk.checkBackStageLoginStatusCopy();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// exportToExcel
|
// exportToExcel
|
||||||
@@ -125,9 +135,17 @@ export function usePythonBridge() {
|
|||||||
await window.electronAPI.tk.openRoom(id);
|
await window.electronAPI.tk.openRoom(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clipboard helper - maybe use navigator.clipboard directly in Vue component?
|
// Clipboard helper - 优先使用 Python RPC,fallback 到浏览器 API
|
||||||
// Original used python bridge for clipboard.
|
|
||||||
const setClipboards = async (text) => {
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return true;
|
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 {
|
return {
|
||||||
fetchDataConfig,
|
fetchDataConfig,
|
||||||
fetchDataCount,
|
fetchDataCount,
|
||||||
loginBackStage,
|
loginBackStage,
|
||||||
loginTikTok,
|
loginTikTok,
|
||||||
|
loginBigTikTok,
|
||||||
givePyAnchorId,
|
givePyAnchorId,
|
||||||
backStageloginStatus,
|
backStageloginStatus,
|
||||||
backStageloginStatusCopy,
|
backStageloginStatusCopy,
|
||||||
@@ -156,6 +247,13 @@ export function usePythonBridge() {
|
|||||||
storageSetInfos,
|
storageSetInfos,
|
||||||
readSetInfos,
|
readSetInfos,
|
||||||
openAnchorIdRooms,
|
openAnchorIdRooms,
|
||||||
setClipboards
|
setClipboards,
|
||||||
|
// 新增接口
|
||||||
|
startBrotherMonitor,
|
||||||
|
getBrotherLoginStatus,
|
||||||
|
visitGifter,
|
||||||
|
closeAllBrowsers,
|
||||||
|
storageAccountInfo,
|
||||||
|
readAccountInfo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<Sidebar :tabs="tabs" :current-tab="currentTab" @tab-switch="handleTabSwitch" @go-back="handleGoToConfig"
|
<Sidebar :tabs="tabs" :current-tab="currentTab" @tab-switch="handleTabSwitch" @go-back="handleGoToConfig"
|
||||||
@stop-all="handleStopAll" :is-loading="isLoading" :account-groups="accountGroups"
|
@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">
|
<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.
|
// 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.
|
// 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'])
|
const emit = defineEmits(['go-back', 'stop-all', 'request-config-load'])
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
|
|||||||
94
src/views/pk-mini/Forum.vue
Normal 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>
|
||||||
727
src/views/pk-mini/Message.vue
Normal 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
@@ -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>
|
||||||