..
15
.env.development
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# iOS 控制服务
|
||||||
|
# VUE_APP_BASE_LOCAL=https://192.168.1.218:34567/
|
||||||
|
#訾寅本地
|
||||||
|
# VUE_APP_BASE_LOCAL=http://192.168.2.7:34568/
|
||||||
|
# VUE_APP_BASE_LOCAL=http://127.0.0.1:34568/
|
||||||
|
#小凯本地
|
||||||
|
VUE_APP_BASE_LOCAL=http://192.168.2.7:34568/
|
||||||
|
|
||||||
|
# 业务后端(开发用内网地址)
|
||||||
|
VUE_APP_BASE_REMOTE=https://crawlclient.api.yolozs.com
|
||||||
|
# VUE_APP_BASE_REMOTE=https://testapi.tknb.net
|
||||||
|
# VUE_APP_BASE_REMOTE=http://192.168.2.21:8101/
|
||||||
|
|
||||||
|
# AI 服务
|
||||||
|
VUE_APP_BASE_SPECIAL=https://ai.yolozs.com
|
||||||
9
.env.production
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# iOS 控制服务(如果生产环境也要用内网可改)
|
||||||
|
VUE_APP_BASE_LOCAL=http://127.0.0.1:34568/
|
||||||
|
|
||||||
|
# 业务后端(正式域名)
|
||||||
|
VUE_APP_BASE_REMOTE=https://crawlclient.api.yolozs.com
|
||||||
|
# VUE_APP_BASE_REMOTE=https://testapi.tknb.net
|
||||||
|
|
||||||
|
# AI 服务(如支持 HTTPS,最好用 https)
|
||||||
|
VUE_APP_BASE_SPECIAL=https://ai.yolozs.com
|
||||||
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# tk-page
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
19
jsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "esnext",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
14315
package-lock.json
generated
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "tk-page",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ffmpeg/core": "^0.12.10",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@vueuse/core": "^13.1.0",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"element-plus": "^2.9.7",
|
||||||
|
"ffmpeg-wasm": "^1.0.1",
|
||||||
|
"h264-converter": "^0.1.4",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"msgpack-lite": "^0.1.26",
|
||||||
|
"pinia": "^3.0.1",
|
||||||
|
"qwebchannel": "^6.2.0",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vue": "^3.2.13",
|
||||||
|
"vue-i18n": "^11.1.8",
|
||||||
|
"vue-router": "^4.0.3",
|
||||||
|
"vuex": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-router": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||||
|
"@vue/cli-service": "~5.0.0",
|
||||||
|
"less": "^4.2.2",
|
||||||
|
"less-loader": "^12.2.0",
|
||||||
|
"postcss-preset-env": "^10.1.5",
|
||||||
|
"postcss-px-to-viewport": "^1.1.1",
|
||||||
|
"postcss-px-viewport": "^0.0.4",
|
||||||
|
"postcss-viewport-units": "^0.1.6"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 1%",
|
||||||
|
"last 2 versions",
|
||||||
|
"not dead",
|
||||||
|
"not ie 11"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
postcss.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// module.exports = {
|
||||||
|
// plugins: {
|
||||||
|
// 'postcss-px-to-viewport': {
|
||||||
|
// viewportWidth: 1600, // 视窗的宽度,对应设计稿宽度
|
||||||
|
// viewportHeight: 900, // 视窗的高度,对应设计稿高度
|
||||||
|
// unitPrecision: 3, // 指定 px 转换为视窗单位值的小数位数
|
||||||
|
// viewportUnit: 'vw', // 指定需要转换成的视窗单位,vw 或者 vh
|
||||||
|
// selectorBlackList: ['.ignore', '.hairlines'], // 指定不需要转换的类
|
||||||
|
// minPixelValue: 1, // 小于或等于 1 px 不转换为视窗单位
|
||||||
|
// mediaQuery: false // 允许在媒体查询中转换 px
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// };
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
32
public/index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title>
|
||||||
|
<%= webpackConfig.name %>
|
||||||
|
</title>
|
||||||
|
<!-- <script src="qrc:///qtwebchannel/qwebchannel.js"></script> -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but <%= webpackConfig.name %> doesn't work properly without JavaScript enabled. Please enable it
|
||||||
|
to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
/* width: 1600px;
|
||||||
|
height: 900px; */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</html>
|
||||||
72
src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<router-view v-if="isRouterAlive" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
reload: this.reload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isRouterAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
reload() {
|
||||||
|
// 先将组件隐藏
|
||||||
|
this.isRouterAlive = false
|
||||||
|
|
||||||
|
// 使用nextTick确保DOM更新后再重新显示组件
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.isRouterAlive = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounce = (fn, delay) => {
|
||||||
|
let timer
|
||||||
|
return (...args) => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fn(...args)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _ResizeObserver = window.ResizeObserver
|
||||||
|
window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
|
||||||
|
constructor(callback) {
|
||||||
|
callback = debounce(callback, 200)
|
||||||
|
super(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* App.vue */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #vite-error-overlay { display: none !important; } 隐藏vite错误提示 */
|
||||||
|
.control-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
/* 禁用默认滚动 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
src/api/account.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { getRemote as getAxios, postRemote as postAxios, downFileRemote as downFile } from '@/utils/axios.js'
|
||||||
|
|
||||||
|
|
||||||
|
export function login(data) {
|
||||||
|
return postAxios({ url: '/api/user/webAi-doLogin', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdByName(name) {
|
||||||
|
return getAxios({ url: `/api/tenant/get-id-by-name?name=${name}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
//修改主播建联状态
|
||||||
|
export function update(data) {
|
||||||
|
return postAxios({ url: 'api/save_data/update', data })
|
||||||
|
}
|
||||||
|
//批量修改主播建联状态
|
||||||
|
export function updates(data) {
|
||||||
|
return postAxios({ url: 'api/save_data/updates', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取话术
|
||||||
|
export function prologue() {
|
||||||
|
return getAxios({ url: 'api/common/prologue' })
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取评论
|
||||||
|
export function comment() {
|
||||||
|
return getAxios({ url: 'api/common/comment' })
|
||||||
|
}
|
||||||
|
//登出
|
||||||
|
export function logout(data) {
|
||||||
|
return postAxios({ url: 'api/user/aiChat-logout', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取账号状态
|
||||||
|
export function health(data) {
|
||||||
|
return getAxios({ url: 'api/common/health' })
|
||||||
|
}
|
||||||
7
src/api/adb.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getAxios, postAxios, downFile } from '@/utils/axios.js'
|
||||||
|
export function getPhoneSize() {
|
||||||
|
return getAxios({ url: '/api/router/scrcpy/size' })
|
||||||
|
}
|
||||||
|
export function touchclick(data) {
|
||||||
|
return getAxios({ url: `/api/router/scrcpy/click?x=${data.x}&y=${data.y}&type=${data.type}`, })
|
||||||
|
}
|
||||||
19
src/api/chat.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getSpecial as getAxios, postSpecial as postAxios } from '@/utils/axios.js'
|
||||||
|
|
||||||
|
|
||||||
|
export function chat(data) {
|
||||||
|
return postAxios({ url: '/chat', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translationToChinese(data) {
|
||||||
|
return postAxios({ url: '/translationToChinese', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translation(data) {
|
||||||
|
return postAxios({ url: '/translation', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function customTranslation(data) {
|
||||||
|
return postAxios({ url: '/customTranslation', data })
|
||||||
|
}
|
||||||
38
src/api/ios.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { getLocal as getAxios, postLocal as postAxios, downFileLocal as downFile } from '@/utils/axios.js'
|
||||||
|
|
||||||
|
|
||||||
|
//设置经纪人信息
|
||||||
|
export function aiConfig(data) {
|
||||||
|
return postAxios({ url: 'aiConfig', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
//运行
|
||||||
|
export function run(data) {
|
||||||
|
return postAxios({ url: 'run', data })
|
||||||
|
}
|
||||||
|
//停止运行
|
||||||
|
export function shutdown() {
|
||||||
|
return postAxios({ url: 'shutdown' })
|
||||||
|
}
|
||||||
|
|
||||||
|
//运行
|
||||||
|
export function setTenantId(data) {
|
||||||
|
return postAxios({ url: 'setTenantId', data })
|
||||||
|
}
|
||||||
|
//获取主播列表
|
||||||
|
export function anchorList(data) {
|
||||||
|
return postAxios({ url: 'anchorList', data })
|
||||||
|
}
|
||||||
|
//删除主播列表
|
||||||
|
export function deleteAnchorWithIds(data) {
|
||||||
|
return postAxios({ url: 'deleteAnchorWithIds', data })
|
||||||
|
}
|
||||||
|
//更新主播列表
|
||||||
|
export function updateAnchorList(data) {
|
||||||
|
return postAxios({ url: 'updateAnchorList', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
//设置过滤条件
|
||||||
|
export function getMQMessagesByFilter(data) {
|
||||||
|
return postAxios({ url: 'getMQMessagesByFilter', data })
|
||||||
|
}
|
||||||
BIN
src/assets/Back.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/Home.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/Overview.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/filter.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/list.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/listAction.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/logo1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/logoBg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/assets/logoBg1.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/assets/logotext.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
src/assets/logotext1.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src/assets/logotext12.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/mes.wav
Normal file
BIN
src/assets/navAction.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/open.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/assets/password.png
Normal file
|
After Width: | Height: | Size: 806 B |
BIN
src/assets/username.png
Normal file
|
After Width: | Height: | Size: 945 B |
BIN
src/assets/video/chatMes.png
Normal file
|
After Width: | Height: | Size: 932 B |
BIN
src/assets/video/leftBg.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
src/assets/video/leftBtn1-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/video/leftBtn1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/video/leftBtn2-2.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
src/assets/video/leftBtn2.png
Normal file
|
After Width: | Height: | Size: 680 B |
BIN
src/assets/video/leftBtn3-3.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
src/assets/video/leftBtn3.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/video/leftBtn4-4.png
Normal file
|
After Width: | Height: | Size: 720 B |
BIN
src/assets/video/leftBtn4.png
Normal file
|
After Width: | Height: | Size: 902 B |
BIN
src/assets/video/leftBtn5-5.png
Normal file
|
After Width: | Height: | Size: 596 B |
BIN
src/assets/video/leftBtn5.png
Normal file
|
After Width: | Height: | Size: 754 B |
BIN
src/assets/video/leftBtn6-6.png
Normal file
|
After Width: | Height: | Size: 799 B |
BIN
src/assets/video/leftBtn6.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/video/leftBtn7-7.png
Normal file
|
After Width: | Height: | Size: 581 B |
BIN
src/assets/video/leftBtn7.png
Normal file
|
After Width: | Height: | Size: 728 B |
BIN
src/assets/video/leftBtn8-8.png
Normal file
|
After Width: | Height: | Size: 714 B |
BIN
src/assets/video/leftBtn8.png
Normal file
|
After Width: | Height: | Size: 900 B |
BIN
src/assets/video/leftBtn9-9.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/video/leftBtn9.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/video/mainBg.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
src/assets/wifi.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/assets/work.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
src/assets/workAction.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
src/assets/worklogo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
122
src/components/AgentGuildDialog.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :model-value="modelValue" :title="title" :width="width" :close-on-click-modal="false" append-to-body
|
||||||
|
@open="onOpen" @close="onClose">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="经纪人" prop="agentName">
|
||||||
|
<el-input v-model="form.agentName" placeholder="请输入经纪人名字" maxlength="50" show-word-limit clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="公会" prop="guildName">
|
||||||
|
<el-input v-model="form.guildName" placeholder="请输入公会名字" maxlength="50" show-word-limit clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 自由输入的联系工具(可不填) -->
|
||||||
|
<el-form-item label="联系工具" prop="contactTool">
|
||||||
|
<el-input v-model="form.contactTool" placeholder="例如:微信 / Telegram / 邮箱 / WhatsApp / 其它(可不填)"
|
||||||
|
maxlength="30" show-word-limit clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 联系方式:仅当联系工具已填写时才必填 -->
|
||||||
|
<el-form-item label="联系方式" prop="contact">
|
||||||
|
<el-input v-model="form.contact" placeholder="请输入联系方式(当填写了联系工具时必填)" maxlength="100" show-word-limit
|
||||||
|
clearable />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="onCancel">取 消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="onConfirm">保 存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '设置经纪信息' },
|
||||||
|
width: { type: [String, Number], default: '480px' },
|
||||||
|
// 初始值:新增 contactTool 字段(可为空)
|
||||||
|
model: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
agentName: '',
|
||||||
|
guildName: '',
|
||||||
|
contactTool: '',
|
||||||
|
contact: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['update:modelValue', 'save', 'open'])
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
agentName: '',
|
||||||
|
guildName: '',
|
||||||
|
contactTool: '',
|
||||||
|
contact: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncFromProps() {
|
||||||
|
form.agentName = props.model?.agentName ?? ''
|
||||||
|
form.guildName = props.model?.guildName ?? ''
|
||||||
|
form.contactTool = props.model?.contactTool ?? ''
|
||||||
|
form.contact = props.model?.contact ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, v => { if (v) syncFromProps() })
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
syncFromProps()
|
||||||
|
emits('open')
|
||||||
|
}
|
||||||
|
function onClose() { emits('update:modelValue', false) }
|
||||||
|
function onCancel() { onClose() }
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
agentName: [{ required: true, message: '请输入经纪人名字', trigger: 'blur' }],
|
||||||
|
guildName: [{ required: true, message: '请输入公会名字', trigger: 'blur' }],
|
||||||
|
// contactTool 不必填,不加 required
|
||||||
|
contact: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, cb) => {
|
||||||
|
const tool = (form.contactTool || '').trim()
|
||||||
|
const contact = (value || '').trim()
|
||||||
|
if (tool && !contact) {
|
||||||
|
return cb(new Error('已填写联系工具时,联系方式为必填'))
|
||||||
|
}
|
||||||
|
cb()
|
||||||
|
},
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
try {
|
||||||
|
submitting.value = true
|
||||||
|
await formRef.value?.validate()
|
||||||
|
|
||||||
|
// 提交前做一次 trim
|
||||||
|
const payload = {
|
||||||
|
agentName: form.agentName.trim(),
|
||||||
|
guildName: form.guildName.trim(),
|
||||||
|
contactTool: form.contactTool.trim(),
|
||||||
|
contact: form.contact.trim()
|
||||||
|
}
|
||||||
|
emits('save', payload)
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
src/components/ChatDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay" @click.self="close">
|
||||||
|
<div class="dialog-content">
|
||||||
|
<h3 class="text-lg font-bold mb-4">
|
||||||
|
聊天翻译内容
|
||||||
|
</h3>
|
||||||
|
<el-scrollbar class="chat-box" ref="scrollbarRef">
|
||||||
|
<div v-for="(msg, index) in messages.filter(m => m.type !== 'time')" :key="index"
|
||||||
|
:class="msg.dir === 'in' ? 'left-message' : 'right-message'">
|
||||||
|
<div @click="fallbackCopyTextToClipboard(index, msg.text)"
|
||||||
|
:class="['bubble', msg.dir, { 'active': activeIndex === index }]">
|
||||||
|
{{ msg.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, ref, watch, nextTick } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: Boolean,
|
||||||
|
messages: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const close = () => emit('close')
|
||||||
|
|
||||||
|
let activeIndex = ref(null)
|
||||||
|
const scrollbarRef = ref(null) // 引用 el-scrollbar
|
||||||
|
|
||||||
|
// 兜底复制
|
||||||
|
function fallbackCopyTextToClipboard(index, text) {
|
||||||
|
activeIndex.value = index
|
||||||
|
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) {
|
||||||
|
ElMessage.success('复制成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('复制失败1')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('复制失败2')
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 自动滚动到底部 ===
|
||||||
|
watch(
|
||||||
|
() => props.messages,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
const wrap = scrollbarRef.value?.wrapRef // el-scrollbar 内部的原生容器
|
||||||
|
if (wrap) {
|
||||||
|
wrap.scrollTop = wrap.scrollHeight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
margin-top: 5vh;
|
||||||
|
background: rgb(246, 246, 246);
|
||||||
|
padding: 0px 20px 20px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
height: 35vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-message {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-message {
|
||||||
|
align-self: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
max-width: 70%;
|
||||||
|
display: inline-block;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.active {
|
||||||
|
box-shadow: 0 0 15px 3px rgba(74, 144, 226, 0.7);
|
||||||
|
border: 1px solid #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.in {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.out {
|
||||||
|
background-color: rgb(0, 169, 214);
|
||||||
|
color: white;
|
||||||
|
text-align: left;
|
||||||
|
/* ✅ 关键:强制文字左对齐 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #409EFF;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-justify {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
593
src/components/HostListManagerDialog.vue
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="show" width="70vw" :close-on-click-modal="false" :destroy-on-close="true" draggable
|
||||||
|
@open="onOpen">
|
||||||
|
<template #header>
|
||||||
|
<div class="dlg-title">
|
||||||
|
<span>主播管理</span>
|
||||||
|
<span class="muted">(已选 {{ selectedCount }} / 共 {{ hosts.length }})</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<!-- 操作按钮行 -->
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<el-button size="small" @click="selectAll">全选</el-button>
|
||||||
|
<el-button size="small" @click="selectNone">全不选</el-button>
|
||||||
|
<el-button size="small" @click="invertSelect">反选</el-button>
|
||||||
|
<el-button size="small" type="danger" :disabled="!selectedCount"
|
||||||
|
@click="deleteSelected">删除选中</el-button>
|
||||||
|
<el-button size="small" @click="resetFilter">重置</el-button>
|
||||||
|
|
||||||
|
<el-tooltip placement="bottom" effect="dark">
|
||||||
|
<template #content>
|
||||||
|
在空白区域按下左键拖拽进行框选<br />
|
||||||
|
</template>
|
||||||
|
<el-icon class="hint">i</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 金票 / 普票 + 筛选条件行(支持自动换行) -->
|
||||||
|
<div class="toolbar-row toolbar-filter">
|
||||||
|
<el-switch v-model="filters.gold" inline-prompt active-text="金票" inactive-text="金票" size="large"
|
||||||
|
style="--el-switch-on-color: #db9600;" />
|
||||||
|
<el-switch v-model="filters.ordinary" inline-prompt inactive-text="普票" active-text="普票" size="large" />
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 在线人数 -->
|
||||||
|
<span class="filter-label">在线人数</span>
|
||||||
|
<el-input-number v-model="filters.min_onlineFans" :min="0" size="small" controls-position="right"
|
||||||
|
placeholder="最小" class="filter-input-number" />
|
||||||
|
<span>~</span>
|
||||||
|
<el-input-number v-model="filters.max_onlineFans" :min="0" size="small" controls-position="right"
|
||||||
|
placeholder="最大" class="filter-input-number" />
|
||||||
|
|
||||||
|
<!-- 主播等级(多选) -->
|
||||||
|
<span class="filter-label">主播等级</span>
|
||||||
|
<el-tree-select v-model="filters.hostslevel" :data="levelTreeData" multiple show-checkbox collapse-tags
|
||||||
|
collapse-tags-tooltip size="small" placeholder="选择等级" class="filter-select-multi" node-key="value"
|
||||||
|
:render-after-expand="false" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表区域 -->
|
||||||
|
<div ref="gridRef" class="grid" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp"
|
||||||
|
@mouseleave="onMouseUp" @scroll="recalcRectsSoon">
|
||||||
|
<div v-for="it in hosts" :key="it.anchorId" class="item-card" :class="{ selected: isSelected(it.anchorId) }"
|
||||||
|
:ref="el => setCardRef(it.anchorId, el)" @click.stop="toggleSelect(it.anchorId)">
|
||||||
|
<div class="row top">
|
||||||
|
<span class="id" :title="it.anchorId">{{ it.anchorId }}</span>
|
||||||
|
<button v-if="it.state == 0" class="x" title="不执行">X</button>
|
||||||
|
<button v-else class="y" title="执行">√</button>
|
||||||
|
</div>
|
||||||
|
<div class="row meta">
|
||||||
|
<span class="country" :title="it.country">{{ it.country || '—' }}</span>
|
||||||
|
<span class="state" :class="{ done: it.invitationType == 2 }">
|
||||||
|
{{ it.invitationType == 2 ? '金票' : '普票' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 框选矩形 -->
|
||||||
|
<div v-if="selecting" class="selection-rect" :style="selectionStyle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="foot">
|
||||||
|
<el-button type="primary" @click="applyFilter">保存</el-button>
|
||||||
|
<el-button @click="show = false;">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getHostfilters, setHostfilters } from '@/stores/storage'
|
||||||
|
import { anchorList, deleteAnchorWithIds, updateAnchorList, getMQMessagesByFilter } from '@/api/ios'
|
||||||
|
// v-model:visible 接口
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'save', 'invitType'])
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (v) => emit('update:visible', v)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const defaultFilters = {
|
||||||
|
min_onlineFans: null,
|
||||||
|
max_onlineFans: null,
|
||||||
|
hostslevel: [],
|
||||||
|
ordinary: true,
|
||||||
|
gold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const hosts = ref([]) // {country, text, state}
|
||||||
|
const selected = reactive(new Set()) // 选中的 text 集合
|
||||||
|
|
||||||
|
|
||||||
|
// 主播等级树形数据(A / B / C / D)
|
||||||
|
const levelTreeData = [
|
||||||
|
{
|
||||||
|
label: 'A',
|
||||||
|
value: 'A',
|
||||||
|
children: [
|
||||||
|
{ label: 'A1', value: 'A1' },
|
||||||
|
{ label: 'A2', value: 'A2' },
|
||||||
|
{ label: 'A3', value: 'A3' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'B',
|
||||||
|
value: 'B',
|
||||||
|
children: [
|
||||||
|
{ label: 'B1', value: 'B1' },
|
||||||
|
{ label: 'B2', value: 'B2' },
|
||||||
|
{ label: 'B3', value: 'B3' },
|
||||||
|
{ label: 'B4', value: 'B4' },
|
||||||
|
{ label: 'B5', value: 'B5' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'C',
|
||||||
|
value: 'C',
|
||||||
|
children: [
|
||||||
|
{ label: 'C1', value: 'C1' },
|
||||||
|
{ label: 'C2', value: 'C2' },
|
||||||
|
{ label: 'C3', value: 'C3' },
|
||||||
|
{ label: 'C4', value: 'C4' },
|
||||||
|
{ label: 'C5', value: 'C5' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'D',
|
||||||
|
value: 'D',
|
||||||
|
children: [
|
||||||
|
{ label: 'D1', value: 'D1' },
|
||||||
|
{ label: 'D2', value: 'D2' },
|
||||||
|
{ label: 'D3', value: 'D3' },
|
||||||
|
{ label: 'D4', value: 'D4' },
|
||||||
|
{ label: 'D5', value: 'D5' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 筛选参数
|
||||||
|
const filters = reactive({ ...defaultFilters })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 卡片 DOM 引用与位置缓存
|
||||||
|
const gridRef = ref(null)
|
||||||
|
const cardRefs = reactive({}) // text -> el
|
||||||
|
const rectCache = reactive({}) // text -> DOMRect
|
||||||
|
let rectRecalcTimer = null
|
||||||
|
|
||||||
|
const processedCount = computed(() => hosts.value.filter(it => !!it?.state).length)
|
||||||
|
function setCardRef(key, el) {
|
||||||
|
if (el) {
|
||||||
|
cardRefs[key] = el
|
||||||
|
} else {
|
||||||
|
delete cardRefs[key]
|
||||||
|
delete rectCache[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoredHostList() {
|
||||||
|
const v = await anchorList()
|
||||||
|
return v ? v : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpen() {
|
||||||
|
hosts.value = await getStoredHostList()
|
||||||
|
console.log(hosts.value)
|
||||||
|
selected.clear()
|
||||||
|
await nextTick()
|
||||||
|
recalcRects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用筛选(通过 getMQMessagesByFilter)
|
||||||
|
async function applyFilter() {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
min_onlineFans: filters.min_onlineFans,
|
||||||
|
max_onlineFans: filters.max_onlineFans,
|
||||||
|
// 多选等级转成 "D5,D4,B3" 这种格式给后端
|
||||||
|
hostslevel: filters.hostslevel,
|
||||||
|
ordinary: filters.ordinary,
|
||||||
|
gold: filters.gold,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 null / 空字符串,避免传一堆 undefined
|
||||||
|
const cleanParams = {}
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
const v = params[key]
|
||||||
|
if (v !== null && v !== '' && v !== undefined) {
|
||||||
|
cleanParams[key] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await getMQMessagesByFilter(cleanParams)
|
||||||
|
console.log("筛选结果:", filters)
|
||||||
|
setHostfilters(filters)
|
||||||
|
show.value = false
|
||||||
|
selected.clear()
|
||||||
|
await nextTick()
|
||||||
|
recalcRects()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
ElMessage.error('筛选失败:' + (error.message || '未知错误'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置筛选条件
|
||||||
|
async function resetFilter() {
|
||||||
|
|
||||||
|
filters.min_onlineFans = null
|
||||||
|
filters.max_onlineFans = null
|
||||||
|
filters.hostslevel = [] // 清空多选
|
||||||
|
filters.ordinary = true
|
||||||
|
filters.gold = true
|
||||||
|
// 恢复默认列表
|
||||||
|
hosts.value = await getStoredHostList()
|
||||||
|
selected.clear()
|
||||||
|
await nextTick()
|
||||||
|
recalcRects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择相关
|
||||||
|
const selectedCount = computed(() => selected.size)
|
||||||
|
function isSelected(id) { return selected.has(id) }
|
||||||
|
function toggleSelect(id) {
|
||||||
|
if (selected.has(id)) selected.delete(id)
|
||||||
|
else selected.add(id)
|
||||||
|
}
|
||||||
|
function selectAll() { selected.clear(); hosts.value.forEach(it => selected.add(it.anchorId)) }
|
||||||
|
function selectNone() { selected.clear() }
|
||||||
|
function invertSelect() {
|
||||||
|
const next = new Set()
|
||||||
|
hosts.value.forEach(it => { if (!selected.has(it.anchorId)) next.add(it.anchorId) })
|
||||||
|
selected.clear(); next.forEach(id => selected.add(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelected() {
|
||||||
|
if (!selected.size) return
|
||||||
|
ElMessageBox.confirm(`确认删除选中的 ${selected.size} 项吗?`, '提示', { type: 'warning' })
|
||||||
|
.then(() => {
|
||||||
|
|
||||||
|
const keep = []
|
||||||
|
const selectHost = []
|
||||||
|
console.log("selected", selected)
|
||||||
|
|
||||||
|
for (const it of hosts.value) {
|
||||||
|
if (selected.has(it.anchorId)) {
|
||||||
|
keep.push(it)
|
||||||
|
} else {
|
||||||
|
selectHost.push(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("keep", keep)
|
||||||
|
|
||||||
|
hosts.value = selectHost
|
||||||
|
selected.clear()
|
||||||
|
deleteAnchorWithIds(keep)
|
||||||
|
recalcRectsSoon()
|
||||||
|
ElMessage.success('已删除选中')
|
||||||
|
})
|
||||||
|
.catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOne(id) {
|
||||||
|
const idx = hosts.value.findIndex(it => it.anchorId === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
hosts.value.splice(idx, 1)
|
||||||
|
selected.delete(id)
|
||||||
|
// setHostList(hosts.value)
|
||||||
|
recalcRectsSoon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 框选逻辑 ——
|
||||||
|
// ... 这部分不变 ...
|
||||||
|
const selecting = ref(false)
|
||||||
|
const anchor = ref({ x: 0, y: 0 })
|
||||||
|
const cursor = ref({ x: 0, y: 0 })
|
||||||
|
const baseSelection = ref(new Set()) // 框选开始时的已有选择(支持累加)
|
||||||
|
|
||||||
|
const selectionStyle = computed(() => {
|
||||||
|
const root = gridRef.value
|
||||||
|
if (!root) return {}
|
||||||
|
// 使用容器坐标系定位矩形
|
||||||
|
const box = root.getBoundingClientRect()
|
||||||
|
const x1 = Math.min(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
|
||||||
|
const y1 = Math.min(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
|
||||||
|
const x2 = Math.max(anchor.value.x, cursor.value.x) - box.left + root.scrollLeft
|
||||||
|
const y2 = Math.max(anchor.value.y, cursor.value.y) - box.top + root.scrollTop
|
||||||
|
return { left: x1 + 'px', top: y1 + 'px', width: (x2 - x1) + 'px', height: (y2 - y1) + 'px' }
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
const root = gridRef.value
|
||||||
|
if (!root) return
|
||||||
|
// 只在空白处拖拽,或按着 Alt 任意处拖拽
|
||||||
|
const onItem = e.target && e.target.closest && e.target.closest('.item-card')
|
||||||
|
if (onItem && !e.altKey) return
|
||||||
|
|
||||||
|
// 记录锚点(client 坐标,方便和 DOMRect 比较)
|
||||||
|
anchor.value = { x: e.clientX, y: e.clientY }
|
||||||
|
cursor.value = { x: e.clientX, y: e.clientY }
|
||||||
|
selecting.value = true
|
||||||
|
// 是否保留原选择
|
||||||
|
baseSelection.value = (e.ctrlKey || e.metaKey) ? new Set(Array.from(selected)) : new Set()
|
||||||
|
// 防止选中文本
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (!selecting.value) return
|
||||||
|
cursor.value = { x: e.clientX, y: e.clientY }
|
||||||
|
updateSelectionByRect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (!selecting.value) return
|
||||||
|
selecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionByRect() {
|
||||||
|
const x1 = Math.min(anchor.value.x, cursor.value.x)
|
||||||
|
const y1 = Math.min(anchor.value.y, cursor.value.y)
|
||||||
|
const x2 = Math.max(anchor.value.x, cursor.value.x)
|
||||||
|
const y2 = Math.max(anchor.value.y, cursor.value.y)
|
||||||
|
|
||||||
|
// 实时选择集合
|
||||||
|
const current = new Set(baseSelection.value)
|
||||||
|
for (const it of hosts.value) {
|
||||||
|
const r = rectCache[it.anchorId]
|
||||||
|
if (!r) continue
|
||||||
|
const hit = !(r.left > x2 || r.right < x1 || r.top > y2 || r.bottom < y1)
|
||||||
|
if (hit) current.add(it.anchorId)
|
||||||
|
}
|
||||||
|
// 覆盖 selected
|
||||||
|
selected.clear(); current.forEach(id => selected.add(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcRects() {
|
||||||
|
for (const [key, el] of Object.entries(cardRefs)) {
|
||||||
|
try { rectCache[key] = el.getBoundingClientRect() } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcRectsSoon() {
|
||||||
|
clearTimeout(rectRecalcTimer)
|
||||||
|
rectRecalcTimer = setTimeout(() => nextTick().then(recalcRects), 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProcessed() {
|
||||||
|
if (!processedCount.value) return
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确认删除所有“已处理”项(${processedCount.value} 个)吗?此操作不可撤销。`,
|
||||||
|
'提示',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
// 仅保留未处理
|
||||||
|
const keep = hosts.value.filter(it => !it?.state)
|
||||||
|
hosts.value = keep
|
||||||
|
|
||||||
|
// 清理已不存在的选中项
|
||||||
|
for (const id of Array.from(selected)) {
|
||||||
|
if (!keep.find(it => it.anchorId === id)) selected.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAnchorWithIds(keep) // 同步回缓存
|
||||||
|
recalcRectsSoon() // 重新计算卡片矩形,保证框选正常
|
||||||
|
ElMessage.success('已删除已处理项')
|
||||||
|
})
|
||||||
|
.catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 从 localStorage 读取并回显
|
||||||
|
function loadFiltersFromCache() {
|
||||||
|
try {
|
||||||
|
const raw = getHostfilters()
|
||||||
|
|
||||||
|
if (!raw) return
|
||||||
|
// 只覆盖存在的字段,防止结构变更报错
|
||||||
|
Object.keys(defaultFilters).forEach((key) => {
|
||||||
|
if (raw[key] !== undefined) {
|
||||||
|
// @ts-ignore
|
||||||
|
filters[key] = raw[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('读取筛选缓存失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 filters 变化,自动持久化
|
||||||
|
|
||||||
|
watch(hosts, () => nextTick().then(recalcRects))
|
||||||
|
// 组件挂载时加载一次缓存
|
||||||
|
onMounted(() => {
|
||||||
|
loadFiltersFromCache()
|
||||||
|
applyFilter()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dlg-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg-title .muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 每一行都是 flex,允许内部自动换行 */
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第二行筛选条件,稍微紧凑一点 */
|
||||||
|
.toolbar-filter {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .hint {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input-number {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-multi {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
position: relative;
|
||||||
|
height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
user-select: none;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 后面样式保持不变 */
|
||||||
|
.item-card {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card.selected {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .row.top {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .id {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .x {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .y {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #00a316;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .x:hover {
|
||||||
|
color: #f33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .meta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .state {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .state.done {
|
||||||
|
color: #db9600;
|
||||||
|
border-color: #db9600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-rect {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px dashed var(--el-color-primary);
|
||||||
|
background: color-mix(in srgb, var(--el-color-primary) 12%, transparent);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
364
src/components/MessageDialogd.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay" @click.self="close">
|
||||||
|
<div class="dialog-content">
|
||||||
|
<!-- 右上角:一键已读 + 静音按钮 + 未读徽标 -->
|
||||||
|
<div class="mark-all">
|
||||||
|
|
||||||
|
<button v-if="unreadCount > 0" class="mark-all-btn" @click.stop="markAllSeen" title="一键已读">一键已读</button>
|
||||||
|
<button class="mark-all-btn2" @click.stop="delAllSeen" title="删除已读">删除已读</button>
|
||||||
|
|
||||||
|
<button class="mute-btn" @click.stop="toggleMute" :title="muted ? '取消静音' : '静音'">
|
||||||
|
<span v-if="muted">🔕</span>
|
||||||
|
<span v-else>🔔</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
|
||||||
|
|
||||||
|
<h3 style="display: flex;" class="text-lg font-bold mb-4 ">
|
||||||
|
<img style="margin: 0 2px;" src="@/assets/video/chatMes.png" />
|
||||||
|
<div>新消息提醒</div>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<el-scrollbar class="chat-box">
|
||||||
|
<div v-for="(item, idx) in normalizedMessages" :key="idx" class="msg-item">
|
||||||
|
<div class="meta" @click="onClickMessage(item)">
|
||||||
|
<span class="name">{{ item.sender || '未知用户' }}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="device">{{ item.device || '未知设备' }}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="device">{{ item.time || '未知时间' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bubble" :class="isSeen(item) ? 'seen' : 'unread'" :style="bubbleStyle(item)"
|
||||||
|
@click="onClickMessage(item)">
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</el-scrollbar>
|
||||||
|
|
||||||
|
<!-- 可选:自定义提示音(静音时也会被静音) -->
|
||||||
|
<audio v-if="soundSrc" ref="audioRef" :src="soundSrc" preload="auto" :muted="muted"></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, ref, computed, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { updatelast, deleteLast } from '@/api/ios'
|
||||||
|
const deleting = ref(false) // 防抖:删除过程中禁用按钮(可选)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: Boolean,
|
||||||
|
messages: { type: Array, required: true, default: () => [] },
|
||||||
|
reminderColor: { type: String, default: 'var(--el-color-warning-light-5, #ffe58f)' },
|
||||||
|
seenColor: { type: String, default: 'var(--el-fill-color-light, #f2f3f5)' },
|
||||||
|
soundSrc: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'seen', 'delete-read'])
|
||||||
|
const close = () => emit('close')
|
||||||
|
|
||||||
|
// —— 静音 —— //
|
||||||
|
const muted = ref(false)
|
||||||
|
function toggleMute() { muted.value = !muted.value }
|
||||||
|
|
||||||
|
// 过滤 type==='time',并保留原始索引;容错旧字段
|
||||||
|
const normalizedMessages = computed(() => {
|
||||||
|
const res = []
|
||||||
|
; (props.messages || []).forEach((m, i) => {
|
||||||
|
if (m?.type === 'time') return
|
||||||
|
res.push({
|
||||||
|
origIndex: i,
|
||||||
|
raw: m,
|
||||||
|
sender: m?.sender ?? m?.name ?? m?.user ?? m?.from ?? '',
|
||||||
|
device: m?.device ?? m?.deviceName ?? m?.udid ?? '',
|
||||||
|
time: m?.time ?? m?.timestamp ?? '',
|
||||||
|
text: m?.text ?? m?.content ?? '',
|
||||||
|
// 统一转成 0/1,兼容旧的 seen/read 布尔
|
||||||
|
status: Number(
|
||||||
|
m?.status ?? ((m?.seen === true || m?.read === true) ? 1 : 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— 基于 status 判断 —— //
|
||||||
|
function isSeen(item) {
|
||||||
|
// 优先读 item.raw.status,其次读规范化的 item.status
|
||||||
|
return Number(item?.raw?.status ?? item?.status ?? 0) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSeen(item) {
|
||||||
|
if (!isSeen(item)) {
|
||||||
|
// 标记为已读
|
||||||
|
item.raw.status = 1
|
||||||
|
try { updatelast(item.raw) } catch (_) { }
|
||||||
|
emit('seen', { index: item.origIndex, message: item.raw })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleStyle(item) {
|
||||||
|
return {
|
||||||
|
backgroundColor: isSeen(item) ? props.seenColor : props.reminderColor,
|
||||||
|
color: '#333'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 未读数量(status === 0)—— //
|
||||||
|
const unreadCount = computed(() =>
|
||||||
|
normalizedMessages.value.reduce((acc, it) => acc + (isSeen(it) ? 0 : 1), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// —— 一键已读:把所有 status=0 的设为 1 —— //
|
||||||
|
async function markAllSeen() {
|
||||||
|
let changed = 0
|
||||||
|
for (const it of normalizedMessages.value) {
|
||||||
|
if (!isSeen(it)) {
|
||||||
|
it.raw.status = 1
|
||||||
|
try { await updatelast(it.raw) } catch (_) { }
|
||||||
|
emit('seen', { index: it.origIndex, message: it.raw })
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed > 0) ElMessage.success(`已标记 ${changed} 条为已读`)
|
||||||
|
else ElMessage.info('没有未读可标记')
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 删除已读:把所有 status=1 的抛给父组件处理 —— //
|
||||||
|
// 子组件不直接改 props.messages,向上抛事件让父组件过滤
|
||||||
|
// —— 一键删除(删除所有 status=1)—— //
|
||||||
|
async function delAllSeen() {
|
||||||
|
// 先拍一张快照,避免删除过程中索引变化
|
||||||
|
const readItems = normalizedMessages.value.filter(it => isSeen(it))
|
||||||
|
if (readItems.length === 0) {
|
||||||
|
ElMessage.info('没有已读可删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleting.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
// 并发删除(每条把“已读的消息体”传给后端)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
readItems.map(it => deleteLast(it.raw))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 统计删除成功/失败
|
||||||
|
const okIndexes = []
|
||||||
|
const okMessages = []
|
||||||
|
let ok = 0, fail = 0
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
ok++
|
||||||
|
okIndexes.push(readItems[i].origIndex)
|
||||||
|
okMessages.push(readItems[i].raw)
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
console.warn('[deleteLast] 失败:', readItems[i].raw, r.reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 通知父组件把成功删除的从列表移除
|
||||||
|
if (okIndexes.length > 0) {
|
||||||
|
emit('delete-read', { indexes: okIndexes, messages: okMessages })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok > 0) ElMessage.success(`已删除 ${ok} 条${fail ? `,失败 ${fail} 条` : ''}`)
|
||||||
|
else ElMessage.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 提示音 —— //
|
||||||
|
const audioRef = ref(null)
|
||||||
|
function playNotice() {
|
||||||
|
if (muted.value) return
|
||||||
|
if (props.soundSrc && audioRef.value) {
|
||||||
|
audioRef.value.currentTime = 0
|
||||||
|
audioRef.value.play().catch(() => { })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||||
|
if (!Ctx) return
|
||||||
|
const ctx = new Ctx()
|
||||||
|
const o = ctx.createOscillator()
|
||||||
|
const g = ctx.createGain()
|
||||||
|
o.type = 'sine'; o.frequency.value = 880
|
||||||
|
o.connect(g); g.connect(ctx.destination)
|
||||||
|
const t0 = ctx.currentTime
|
||||||
|
g.gain.setValueAtTime(0.0001, t0)
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.12, t0 + 0.02)
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.00001, t0 + 0.28)
|
||||||
|
o.start(t0); o.stop(t0 + 0.3)
|
||||||
|
o.onended = () => ctx.close().catch(() => { })
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未读数增加时才提示
|
||||||
|
watch(unreadCount, (now, old) => {
|
||||||
|
if (old !== undefined && now > old) playNotice()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 点击条目 => 单条已读
|
||||||
|
function onClickMessage(item) {
|
||||||
|
markSeen(item)
|
||||||
|
try { ElMessage.success('已读') } catch (_) { }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.dialog-overlay {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 3vh;
|
||||||
|
background: rgb(246, 246, 246);
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 5px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一键已读按钮(靠右,位于静音按钮左侧) */
|
||||||
|
.mark-all-btn {
|
||||||
|
|
||||||
|
/* 未读徽标(10) + 静音按钮(28+间距) 预留 */
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--el-color-primary, #409eff);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn2 {
|
||||||
|
|
||||||
|
/* 未读徽标(10) + 静音按钮(28+间距) 预留 */
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
background: #ff4040;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn:hover {
|
||||||
|
filter: brightness(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 静音按钮 */
|
||||||
|
.mute-btn {
|
||||||
|
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: var(--el-fill-color, #f5f7fa);
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
|
||||||
|
cursor: pointer;
|
||||||
|
/* display: grid; */
|
||||||
|
place-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-btn:hover {
|
||||||
|
filter: brightness(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未读徽标 */
|
||||||
|
.unread-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--el-color-danger, #f56c6c);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
height: 35vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部左对齐 */
|
||||||
|
.msg-item {
|
||||||
|
align-self: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .sep {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .device {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 90%;
|
||||||
|
display: inline-block;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 颜色通过内联样式控制(.unread/.seen 仅语义钩子) */
|
||||||
|
.bubble.unread {}
|
||||||
|
|
||||||
|
.bubble.seen {}
|
||||||
|
</style>
|
||||||
153
src/components/MultiLineInputDialog.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog draggable :title="title" v-model="visibleLocal" width="600px" :close-on-click-modal="false"
|
||||||
|
@closed="onClosed">
|
||||||
|
<!-- <el-input type="textarea" v-model="rawText" :rows="10" :placeholder="placeholder" /> -->
|
||||||
|
<el-input type="textarea" v-model="rawText" :rows="10" :placeholder="placeholder" @input="enforceLineLimit" />
|
||||||
|
<template #footer>
|
||||||
|
<span v-if="title === '主播ID'" style="margin-right: 12px; color:#909399;">
|
||||||
|
{{ lineCount }}/{{ MAX_LINES_FOR_ANCHOR }} 行
|
||||||
|
</span>
|
||||||
|
<span v-if="title === '私信'">
|
||||||
|
<!-- 翻译
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="开启后,打招呼将自动翻译为主播地区使用的语言" placement="bottom">
|
||||||
|
|
||||||
|
<el-switch style="margin-right: 20px;" v-model="data.needTranslate" />
|
||||||
|
</el-tooltip> -->
|
||||||
|
|
||||||
|
自动回复
|
||||||
|
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="开启后,检测到新私信将自动回复" placement="bottom">
|
||||||
|
|
||||||
|
<el-switch style="margin-right: 20px;" v-model="data.auto" />
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
<span v-if="title === '评论' || title === '视频评论'">
|
||||||
|
评论
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="关闭后,将不在评论" placement="bottom">
|
||||||
|
|
||||||
|
<el-switch style="margin-right: 20px;" v-model="data.common" />
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
<el-button v-if="title !== '主播ID'" type="success" @click="exportPrologue(title)">
|
||||||
|
导入{{ title }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button @click="onClickCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { prologue, comment } from '@/api/account';
|
||||||
|
const MAX_LINES_FOR_ANCHOR = 100;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let data = ref({
|
||||||
|
// needTranslate: false,
|
||||||
|
auto: false,
|
||||||
|
common: true,
|
||||||
|
});
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, required: true },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
initialText: { type: String, default: '' },
|
||||||
|
type: { type: Number, default: 10 },
|
||||||
|
index: { type: Number, default: 999 },
|
||||||
|
placeholder: { type: String, default: '每行一条,支持粘贴多行后自动拆分' },
|
||||||
|
dedupe: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'confirm', 'cancel']);
|
||||||
|
|
||||||
|
const rawText = ref(props.initialText);
|
||||||
|
|
||||||
|
// 区分关闭来源:true=通过“确定”关闭;false=取消/遮罩/ESC/右上角关闭
|
||||||
|
const closingByConfirm = ref(false);
|
||||||
|
|
||||||
|
watch(() => props.initialText, (v) => { rawText.value = v; });
|
||||||
|
|
||||||
|
const visibleLocal = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseLines() {
|
||||||
|
const lines = rawText.value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return props.dedupe ? Array.from(new Set(lines)) : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (props.title === '主播ID' && lineCount.value > MAX_LINES_FOR_ANCHOR) {
|
||||||
|
ElMessage.error(`“主播ID”最多 ${MAX_LINES_FOR_ANCHOR} 行,请精简后再提交。`);
|
||||||
|
enforceLineLimit(); // 再次截断以保证安全
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = parseLines();
|
||||||
|
emit('confirm', items, props.title, props.index, data.value);
|
||||||
|
closingByConfirm.value = true;
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickCancel() {
|
||||||
|
closingByConfirm.value = false; // 非确认关闭
|
||||||
|
emit('update:visible', false); // 关弹窗
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有关闭后的统一收尾
|
||||||
|
function onClosed() {
|
||||||
|
const byConfirm = closingByConfirm.value;
|
||||||
|
|
||||||
|
// 重置表单状态
|
||||||
|
rawText.value = '';
|
||||||
|
// data.value = {
|
||||||
|
// needTranslate: false,
|
||||||
|
// auto: false,
|
||||||
|
// common: true,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 只有非“确定”关闭才对外发 cancel
|
||||||
|
if (!byConfirm) emit('cancel');
|
||||||
|
|
||||||
|
// 重置标记
|
||||||
|
closingByConfirm.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPrologue(title) {
|
||||||
|
if (title === '私信') {
|
||||||
|
prologue().then(res => {
|
||||||
|
rawText.value = res.map(item => item).join('\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
comment().then(res => {
|
||||||
|
rawText.value = res.map(item => item).join('\n\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLineLimit() {
|
||||||
|
if (props.title !== '主播ID') return;
|
||||||
|
const lines = (rawText.value || '').split(/\r?\n/);
|
||||||
|
if (lines.length > MAX_LINES_FOR_ANCHOR) {
|
||||||
|
rawText.value = lines.slice(0, MAX_LINES_FOR_ANCHOR).join('\n');
|
||||||
|
ElMessage.warning(`“主播ID”最多 ${MAX_LINES_FOR_ANCHOR} 行,已自动截断。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineCount = computed(() =>
|
||||||
|
(rawText.value || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(s => s.trim().length > 0).length
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 根据需要自定义样式 */
|
||||||
|
</style>
|
||||||
673
src/components/translationDialog.vue
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" width="960px" :close-on-click-modal="false" :destroy-on-close="false"
|
||||||
|
:show-close="false" @close="onClose">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<div class="text-lg font-semibold">打招呼内容</div>
|
||||||
|
<!-- <div class="text-xs text-gray-500">可输入多句文本 → 选择语种 → 一键翻译 → 可编辑&持久化 → 确定返回某语种结果</div> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- 源文本区 -->
|
||||||
|
<el-card shadow="never" class="!border rounded-xl">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">源文本</span>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<el-button size="small" @click="addSentence">新增一行</el-button>
|
||||||
|
<el-button size="small" @click="importFromTextarea">从多行文本导入</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="clearAll">清空</el-button>
|
||||||
|
<el-button type="success" @click="exportPrologue()">
|
||||||
|
导入私信
|
||||||
|
</el-button>
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="关闭后,招呼内容将不再翻译" placement="bottom">
|
||||||
|
<el-switch v-model="isTranslation" class="fancy-switch" :width="70" inline-prompt
|
||||||
|
active-text="翻译" inactive-text="翻译" style="margin-left:16px" />
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<el-text style="margin-left: 10px;" type="primary"> </el-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-12 gap-3">
|
||||||
|
<div class="col-span-12 lg:col-span-6">
|
||||||
|
<el-input v-model="bulkText" :rows="8" type="textarea"
|
||||||
|
placeholder="每行一条句子,可粘贴多行,然后点击『从多行文本导入』" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 lg:col-span-6 space-y-2">
|
||||||
|
<div v-for="(s, i) in sentences" :key="`src-${i}`" class="flex items-start gap-2"
|
||||||
|
style="display: flex;">
|
||||||
|
<el-input v-model="sentences[i]" :placeholder="'第 ' + (i + 1) + ' 句'" />
|
||||||
|
<el-button circle @click="removeSentence(i)" style="color: red;">
|
||||||
|
X
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="sentences.length === 0" class="text-xs text-gray-500">尚未添加句子</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 语种与翻译控制区 -->
|
||||||
|
<el-card v-show="isTranslation" shadow="never" class="!border rounded-xl">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">语种与翻译</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<el-switch v-model="autoSave" active-text="自动保存" />
|
||||||
|
<el-button size="small" @click="saveToLocal">保存</el-button>
|
||||||
|
<el-button size="small" @click="loadFromLocal">载入</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-12 gap-3">
|
||||||
|
<div class="col-span-12 lg:col-span-6 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-24 text-right text-sm text-gray-500">选择语种</div>
|
||||||
|
<el-select v-model="selectedLangs" multiple filterable placeholder="选择需要翻译到的语种"
|
||||||
|
class="w-full">
|
||||||
|
<el-option v-for="opt in languageOptions" :key="opt.value" :label="opt.label"
|
||||||
|
:value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="flex items-center gap-2">
|
||||||
|
<div class="w-24 text-right text-sm text-gray-500">引擎</div>
|
||||||
|
<el-select v-model="engine" class="w-full">
|
||||||
|
<el-option label="自定义回调 (推荐在父组件里接入)" value="custom" />
|
||||||
|
<el-option label="本地占位引擎 (演示)" value="local" />
|
||||||
|
</el-select>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-24"></div>
|
||||||
|
<el-button type="primary" :disabled="!canTranslate || isAnyLoading" @click="onTranslate">
|
||||||
|
<el-icon v-if="isAnyLoading" class="mr-1 is-loading">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
{{ isAnyLoading ? '翻译中...' : '翻译' }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- <el-button :disabled="!Object.keys(translations).length"
|
||||||
|
@click="syncLengths">对齐长度</el-button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 lg:col-span-6">
|
||||||
|
<el-alert title="说明" type="info" :closable="false" show-icon class="mb-3"
|
||||||
|
description="选择语种后点击『翻译』。翻译结果可在下方标签页逐条手动调整,选择对应地区国家所有语言语种。" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-if="selectedLangs.length" v-model="activeTab" class="mt-4">
|
||||||
|
<el-tab-pane v-for="lang in selectedLangs" :key="lang" :name="lang">
|
||||||
|
<template #label>
|
||||||
|
<span>{{ getLangLabel(lang) }}</span>
|
||||||
|
<el-icon v-if="loadingLangs[lang]" class="ml-1 is-loading" style="vertical-align: -2px;">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- ✅ 加载中提示 -->
|
||||||
|
<div v-if="loadingLangs[lang]" class="text-sm text-gray-500 flex items-center gap-2">
|
||||||
|
<el-icon class="is-loading">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
正在翻译 {{ getLangLabel(lang) }} ...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ 加载完成但无结果 -->
|
||||||
|
<div v-else-if="!translations[lang] || translations[lang].length === 0"
|
||||||
|
class="text-xs text-gray-500">
|
||||||
|
无数据,点击『翻译』获取。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ 加载完成且有结果 -->
|
||||||
|
<div v-else v-for="(t, i) in translations[lang]" :key="`${lang}-${i}`"
|
||||||
|
class="flex items-start gap-2">
|
||||||
|
<el-input v-model="translations[lang][i]" :placeholder="'第 ' + (i + 1) + ' 句'" />
|
||||||
|
<el-popover placement="top" trigger="hover">
|
||||||
|
<template #reference>
|
||||||
|
<el-tag type="info" effect="plain">源</el-tag>
|
||||||
|
</template>
|
||||||
|
<div class="max-w-72 text-xs">{{ sentences[i] }}</div>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<div class="text-xs text-gray-500" v-show="isTranslation">
|
||||||
|
共 {{ sentences.length }} 句 · 选择 {{ selectedLangs.length }} 种语言
|
||||||
|
<!-- <template v-if="activeTab"> · 当前返回:{{ getLangLabel(activeTab) }}</template> -->
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
|
||||||
|
<el-button @click="onClose">取消</el-button>
|
||||||
|
<el-button type="primary" @click="onConfirm">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch, computed, onMounted } from 'vue'
|
||||||
|
import { prologue } from '@/api/account';
|
||||||
|
import { Loading } from '@element-plus/icons-vue' // ✅ 旋转图标
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getUser } from '@/stores/storage'
|
||||||
|
let userdata = ref(getUser());
|
||||||
|
|
||||||
|
const suppressCancelNext = ref(false) // ✅ 下次关闭是否屏蔽 cancel 事件
|
||||||
|
/**
|
||||||
|
* Props & Emits(JS 版)
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
// 控制弹窗显示
|
||||||
|
modelValue: { type: Boolean, required: true },
|
||||||
|
/**
|
||||||
|
* 自定义翻译回调:由父组件注入,实现真正的翻译
|
||||||
|
* 入参:sentences 原文数组、targetLang 目标语种值
|
||||||
|
* 返回:与 sentences 等长的目标语言数组
|
||||||
|
*/
|
||||||
|
translateFn: { type: Function, default: null },
|
||||||
|
type: '',
|
||||||
|
// 本地持久化 key 前缀(同一项目可自定义隔离)
|
||||||
|
storageKeyPrefix: { type: String, default: 'translationDialog' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
let auto = ref(false);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态
|
||||||
|
*/
|
||||||
|
let bulkText = ref('')
|
||||||
|
const sentences = reactive([])
|
||||||
|
const selectedLangs = ref([])
|
||||||
|
const translations = reactive({}) // 所有翻译后的内容
|
||||||
|
const loadingLangs = reactive({}) // ✅ { [lang]: boolean } 每个语种的加载中状态
|
||||||
|
const autoSave = ref(true)
|
||||||
|
const activeTab = ref('')
|
||||||
|
const engine = ref('custom') // 'custom' | 'local'
|
||||||
|
|
||||||
|
const MAX_SENTENCES = 30
|
||||||
|
|
||||||
|
function clampSentences(list) {
|
||||||
|
const trimmed = list.slice(0, MAX_SENTENCES)
|
||||||
|
if (list.length > MAX_SENTENCES) {
|
||||||
|
ElMessage({
|
||||||
|
type: 'warning',
|
||||||
|
message: `最多支持 ${MAX_SENTENCES} 条,已保留前 ${MAX_SENTENCES} 条。`,
|
||||||
|
duration: 2500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTranslation = ref(false)
|
||||||
|
watch(selectedLangs, (langs) => {
|
||||||
|
const keep = new Set(langs)
|
||||||
|
|
||||||
|
// 删掉已取消选择的语言对应的数据
|
||||||
|
Object.keys(translations).forEach((k) => {
|
||||||
|
if (!keep.has(k)) delete translations[k]
|
||||||
|
})
|
||||||
|
Object.keys(loadingLangs).forEach((k) => {
|
||||||
|
if (!keep.has(k)) delete loadingLangs[k]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果当前激活标签不在选择里,重置到第一个
|
||||||
|
if (activeTab.value && !keep.has(activeTab.value)) {
|
||||||
|
activeTab.value = langs[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要的话,立刻持久化
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}, { deep: false })
|
||||||
|
/** 默认语言选项 */
|
||||||
|
const defaultLanguages = [
|
||||||
|
{ label: '英语', value: 'en' },
|
||||||
|
{ label: '简体中文', value: 'zh' },
|
||||||
|
{ label: '繁体中文', value: 'zh_tw' },
|
||||||
|
{ label: '俄语', value: 'ru' },
|
||||||
|
{ label: '日语', value: 'ja' },
|
||||||
|
{ label: '韩语', value: 'ko' },
|
||||||
|
{ label: '西班牙语', value: 'es' },
|
||||||
|
{ label: '法语', value: 'fr' },
|
||||||
|
{ label: '葡萄牙语', value: 'pt' },
|
||||||
|
{ label: '德语', value: 'de' },
|
||||||
|
{ label: '意大利语', value: 'it' },
|
||||||
|
{ label: '泰语', value: 'th' },
|
||||||
|
{ label: '越南语', value: 'vi' },
|
||||||
|
{ label: '印度尼西亚语', value: 'id' },
|
||||||
|
{ label: '马来语', value: 'ms' },
|
||||||
|
{ label: '阿拉伯语', value: 'ar' },
|
||||||
|
{ label: '印地语', value: 'hi' },
|
||||||
|
{ label: '希伯来语', value: 'he' },
|
||||||
|
{ label: '缅甸语', value: 'my' },
|
||||||
|
{ label: '泰米尔语', value: 'ta' },
|
||||||
|
{ label: '乌尔都语', value: 'ur' },
|
||||||
|
{ label: '孟加拉语', value: 'bn' },
|
||||||
|
{ label: '波兰语', value: 'pl' },
|
||||||
|
{ label: '荷兰语', value: 'nl' },
|
||||||
|
{ label: '罗马尼亚语', value: 'ro' },
|
||||||
|
{ label: '土耳其语', value: 'tr' },
|
||||||
|
{ label: '高棉语', value: 'km' },
|
||||||
|
{ label: '老挝语', value: 'lo' },
|
||||||
|
{ label: '粤语', value: 'yue' },
|
||||||
|
{ label: '捷克语', value: 'cs' },
|
||||||
|
{ label: '希腊语', value: 'el' },
|
||||||
|
{ label: '瑞典语', value: 'sv' },
|
||||||
|
{ label: '匈牙利语', value: 'hu' },
|
||||||
|
{ label: '丹麦语', value: 'da' },
|
||||||
|
{ label: '芬兰语', value: 'fi' },
|
||||||
|
{ label: '乌克兰语', value: 'uk' },
|
||||||
|
{ label: '保加利亚语', value: 'bg' },
|
||||||
|
{ label: '塞尔维亚语', value: 'sr' },
|
||||||
|
{ label: '泰卢固语', value: 'te' },
|
||||||
|
{ label: '南非荷兰语', value: 'af' },
|
||||||
|
{ label: '亚美尼亚语', value: 'hy' },
|
||||||
|
{ label: '阿萨姆语', value: 'as' },
|
||||||
|
{ label: '阿斯图里亚斯语', value: 'ast' },
|
||||||
|
{ label: '巴斯克语', value: 'eu' },
|
||||||
|
{ label: '白俄罗斯语', value: 'be' },
|
||||||
|
{ label: '波斯尼亚语', value: 'bs' },
|
||||||
|
{ label: '加泰罗尼亚语', value: 'ca' },
|
||||||
|
{ label: '宿务语', value: 'ceb' },
|
||||||
|
{ label: '克罗地亚语', value: 'hr' },
|
||||||
|
{ label: '埃及阿拉伯语', value: 'arz' },
|
||||||
|
{ label: '爱沙尼亚语', value: 'et' },
|
||||||
|
{ label: '加利西亚语', value: 'gl' },
|
||||||
|
{ label: '格鲁吉亚语', value: 'ka' },
|
||||||
|
{ label: '古吉拉特语', value: 'gu' },
|
||||||
|
{ label: '冰岛语', value: 'is' },
|
||||||
|
{ label: '爪哇语', value: 'jv' },
|
||||||
|
{ label: '卡纳达语', value: 'kn' },
|
||||||
|
{ label: '哈萨克语', value: 'kk' },
|
||||||
|
{ label: '拉脱维亚语', value: 'lv' },
|
||||||
|
{ label: '立陶宛语', value: 'lt' },
|
||||||
|
{ label: '卢森堡语', value: 'lb' },
|
||||||
|
{ label: '马其顿语', value: 'mk' },
|
||||||
|
{ label: '马加希语', value: 'mai' },
|
||||||
|
{ label: '马耳他语', value: 'mt' },
|
||||||
|
{ label: '马拉地语', value: 'mr' },
|
||||||
|
{ label: '美索不达米亚阿拉伯语', value: 'acm' },
|
||||||
|
{ label: '摩洛哥阿拉伯语', value: 'ary' },
|
||||||
|
{ label: '内志阿拉伯语', value: 'ars' },
|
||||||
|
{ label: '尼泊尔语', value: 'ne' },
|
||||||
|
{ label: '北阿塞拜疆语', value: 'az' },
|
||||||
|
{ label: '北黎凡特阿拉伯语', value: 'apc' },
|
||||||
|
{ label: '北乌兹别克语', value: 'uz' },
|
||||||
|
{ label: '书面语挪威语', value: 'nb' },
|
||||||
|
{ label: '新挪威语', value: 'nn' },
|
||||||
|
{ label: '奥克语', value: 'oc' },
|
||||||
|
{ label: '奥里亚语', value: 'or' },
|
||||||
|
{ label: '邦阿西楠语', value: 'pag' },
|
||||||
|
{ label: '西西里语', value: 'scn' },
|
||||||
|
{ label: '信德语', value: 'sd' },
|
||||||
|
{ label: '僧伽罗语', value: 'si' },
|
||||||
|
{ label: '斯洛伐克语', value: 'sk' },
|
||||||
|
{ label: '斯洛文尼亚语', value: 'sl' },
|
||||||
|
{ label: '南黎凡特阿拉伯语', value: 'ajp' },
|
||||||
|
{ label: '斯瓦希里语', value: 'sw' },
|
||||||
|
{ label: '他加禄语', value: 'tl' },
|
||||||
|
{ label: '塔伊兹-亚丁阿拉伯语', value: 'acq' },
|
||||||
|
{ label: '托斯克阿尔巴尼亚语', value: 'sq' },
|
||||||
|
{ label: '突尼斯阿拉伯语', value: 'aeb' },
|
||||||
|
{ label: '威尼斯语', value: 'vec' },
|
||||||
|
{ label: '瓦莱语', value: 'war' },
|
||||||
|
{ label: '威尔士语', value: 'cy' },
|
||||||
|
{ label: '西波斯语', value: 'fa' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const languageOptions = computed(() => (defaultLanguages))
|
||||||
|
const getLangLabel = (v) => (languageOptions.value.find((x) => x.value === v)?.label || v)
|
||||||
|
|
||||||
|
/** 计算属性 */
|
||||||
|
const canTranslate = computed(() => sentences.length > 0 && selectedLangs.value.length > 0)
|
||||||
|
const isAnyLoading = computed(() => Object.values(loadingLangs).some(Boolean))
|
||||||
|
// const canConfirm = computed(() => !!activeTab.value && !!(translations[activeTab.value] || []).length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法 - 源文本
|
||||||
|
*/
|
||||||
|
function importFromTextarea() {
|
||||||
|
const lines = bulkText.value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const limited = clampSentences(lines)
|
||||||
|
sentences.splice(0, sentences.length, ...limited)
|
||||||
|
syncLengths()
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
//新增一行时限制
|
||||||
|
function addSentence() {
|
||||||
|
if (sentences.length >= MAX_SENTENCES) {
|
||||||
|
ElMessage({
|
||||||
|
type: 'warning',
|
||||||
|
message: `最多只可添加 ${MAX_SENTENCES} 条。`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sentences.push('')
|
||||||
|
syncLengths()
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function removeSentence(i) {
|
||||||
|
sentences.splice(i, 1)
|
||||||
|
syncLengths()
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
bulkText.value = ''
|
||||||
|
sentences.splice(0)
|
||||||
|
Object.keys(translations).forEach((k) => delete translations[k])
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法 - 翻译
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 执行翻译功能的主函数
|
||||||
|
* 该函数会遍历所选语言,对输入文本进行批量翻译,并处理翻译结果的存储与显示
|
||||||
|
*/
|
||||||
|
async function onTranslate() {
|
||||||
|
// 检查是否满足翻译条件,如果不满足则直接返回
|
||||||
|
if (!canTranslate.value) return
|
||||||
|
// 遍历所有选中的目标语言,依次进行翻译
|
||||||
|
// ✅ 给所有选中语种先置为 loading
|
||||||
|
const langs = selectedLangs.value.slice()
|
||||||
|
langs.forEach(l => loadingLangs[l] = true)
|
||||||
|
|
||||||
|
// ✅ 并行翻译;想要串行也可以改成 for...of + await
|
||||||
|
await Promise.all(langs.map(async (lang) => {
|
||||||
|
try {
|
||||||
|
const arr = await translate(sentences.slice(), lang)
|
||||||
|
translations[lang] = ensureLength(arr, sentences.length)
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
loadingLangs[lang] = false // ✅ 不管成功/失败都清理 loading
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
// 如果未设置返回语言且存在选中的语言,则将第一个选中的语言设为返回语言
|
||||||
|
if (!activeTab.value && selectedLangs.value.length) {
|
||||||
|
activeTab.value = selectedLangs.value[0]
|
||||||
|
}
|
||||||
|
// 如果启用了自动保存功能,则将翻译结果保存到本地存储
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translate(src, lang) {
|
||||||
|
if (engine.value === 'custom' && typeof props.translateFn === 'function') {
|
||||||
|
return await props.translateFn(src, lang)
|
||||||
|
}
|
||||||
|
// 本地占位:仅作演示,将文本附加 [lang] 标记
|
||||||
|
return src.map((s) => (s ? `${s} [${lang}]` : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对齐各语言数组长度到 sentences.length
|
||||||
|
*/
|
||||||
|
function ensureLength(arr, targetLen) {
|
||||||
|
const out = (arr || []).slice(0, targetLen)
|
||||||
|
while (out.length < targetLen) out.push('')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLengths() {
|
||||||
|
const n = sentences.length
|
||||||
|
Object.keys(translations).forEach((k) => {
|
||||||
|
translations[k] = ensureLength(translations[k] || [], n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化(localStorage)
|
||||||
|
*/
|
||||||
|
const storagePrefix = computed(() => props.storageKeyPrefix || 'translationDialog')
|
||||||
|
|
||||||
|
const saveToLocal = debounce(() => {
|
||||||
|
const key = (k) => `${storagePrefix.value}:${k}`
|
||||||
|
localStorage.setItem(key('sentences'), JSON.stringify(sentences))
|
||||||
|
localStorage.setItem(key('selectedLangs'), JSON.stringify(selectedLangs.value))
|
||||||
|
localStorage.setItem(key('activeTab'), JSON.stringify(activeTab.value))
|
||||||
|
localStorage.setItem(key('translations'), JSON.stringify(translations))
|
||||||
|
}, 200) // 200ms 防抖
|
||||||
|
function debounce(fn, wait = 200) {
|
||||||
|
let t
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = setTimeout(() => fn(...args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadFromLocal() {
|
||||||
|
const key = (k) => `${storagePrefix.value}:${k}`
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(key('sentences')) || '[]')
|
||||||
|
const limited = clampSentences(Array.isArray(s) ? s : [])
|
||||||
|
const langs = JSON.parse(localStorage.getItem(key('selectedLangs')) || '[]')
|
||||||
|
const a = JSON.parse(localStorage.getItem(key('activeTab')) || '""')
|
||||||
|
const t = JSON.parse(localStorage.getItem(key('translations')) || '{}')
|
||||||
|
|
||||||
|
sentences.splice(0, sentences.length, ...limited)
|
||||||
|
selectedLangs.value = Array.isArray(langs) ? langs : []
|
||||||
|
activeTab.value = typeof a === 'string' ? a : ''
|
||||||
|
|
||||||
|
Object.keys(translations).forEach((k) => delete translations[k])
|
||||||
|
if (t && typeof t === 'object') {
|
||||||
|
for (const k of Object.keys(t)) {
|
||||||
|
translations[k] = Array.isArray(t[k]) ? t[k] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果本地的 activeTab 不在当前选中语言里,退回到第一个
|
||||||
|
if (activeTab.value && !selectedLangs.value.includes(activeTab.value)) {
|
||||||
|
activeTab.value = selectedLangs.value[0] || ''
|
||||||
|
}
|
||||||
|
syncLengths()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadFromLocal error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([sentences, selectedLangs, translations, activeTab], () => {
|
||||||
|
if (autoSave.value) saveToLocal()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
// if (!activeTab.value) return
|
||||||
|
|
||||||
|
// 如果关闭了翻译功能,则先提示
|
||||||
|
if (!isTranslation.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'当前「翻译」开关已关闭,打招呼内容将不会被翻译,只会使用原文发送。是否继续?',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '继续',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
customClass: 'confirm-box-sm',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// 用户点击“继续” -> 继续执行提交逻辑
|
||||||
|
} catch {
|
||||||
|
// 用户点击“取消” -> 直接退出
|
||||||
|
ElMessage({
|
||||||
|
type: 'info',
|
||||||
|
message: '已取消发送',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 以下是原逻辑 ===
|
||||||
|
suppressCancelNext.value = true
|
||||||
|
const out = JSON.parse(JSON.stringify(translations))
|
||||||
|
|
||||||
|
// 追加原始内容
|
||||||
|
out.yolo = sentences.slice()
|
||||||
|
|
||||||
|
emit('confirm', {
|
||||||
|
type: props.type,
|
||||||
|
strings: out,
|
||||||
|
autoBlo: auto.value,
|
||||||
|
needTranslate: isTranslation.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (suppressCancelNext.value) {
|
||||||
|
suppressCancelNext.value = false // ✅ 仅关闭,不触发 cancel
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('cancel') // ✅ 正常关闭时才触发 cancel(如点击“取消”/Esc/右上角关闭)
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPrologue() {
|
||||||
|
|
||||||
|
prologue().then(res => {
|
||||||
|
bulkText.value = res.map(item => item).join('\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
loadFromLocal()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 轻量美化,依赖页面存在 Tailwind 时会更佳 */
|
||||||
|
.space-y-2>*+* {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-3>*+* {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-5>*+* {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-12 {
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-span-12 {
|
||||||
|
grid-column: span 12 / span 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:col-span-6 {
|
||||||
|
grid-column: span 12 / span 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.lg\:col-span-6 {
|
||||||
|
grid-column: span 6 / span 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-xl {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 紧凑版 fancy-switch,不依赖外部库 === */
|
||||||
|
:deep(.fancy-switch) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 轨道部分(略大于默认) */
|
||||||
|
:deep(.fancy-switch .el-switch__core) {
|
||||||
|
height: 28px;
|
||||||
|
/* 比默认略高 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滑块部分(略缩小) */
|
||||||
|
:deep(.fancy-switch .el-switch__action) {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭态:暗色背景,灰色文字 */
|
||||||
|
:deep(.fancy-switch:not(.is-checked) .el-switch__core) {
|
||||||
|
background: linear-gradient(180deg, #1f2937, #111827) !important;
|
||||||
|
color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开启态:蓝绿渐变高亮 */
|
||||||
|
:deep(.fancy-switch.is-checked .el-switch__core) {
|
||||||
|
background: linear-gradient(90deg, #60a5fa, #34d399) !important;
|
||||||
|
color: #0b1220;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(96, 165, 250, .18),
|
||||||
|
0 4px 18px rgba(52, 211, 153, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滑块开启态:更亮一点 */
|
||||||
|
:deep(.fancy-switch.is-checked .el-switch__action) {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(52, 211, 153, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 呼吸动画(更柔和版本) */
|
||||||
|
@keyframes glowPulseSmall {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, .16), 0 4px 18px rgba(52, 211, 153, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgba(96, 165, 250, .08), 0 4px 22px rgba(52, 211, 153, .30);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, .16), 0 4px 18px rgba(52, 211, 153, .25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.fancy-switch.is-checked .el-switch__core) {
|
||||||
|
animation: glowPulseSmall 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘焦点高亮 */
|
||||||
|
:deep(.fancy-switch .el-switch__input:focus-visible + .el-switch__core) {
|
||||||
|
outline: 2px solid rgba(96, 165, 250, .55);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
src/composables/useDevices.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// src/composables/useDevices.js
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getDeviceList,
|
||||||
|
stopScript,
|
||||||
|
deviceAppList,
|
||||||
|
launchApp,
|
||||||
|
} from '@/api/ios'
|
||||||
|
import { pickTikTokBundleId } from '@/utils/arrUtils'
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
const deviceInformation = ref([]) // 设备列表
|
||||||
|
const getListTimer = ref(null) // 定时器句柄(方便在外面清理)
|
||||||
|
|
||||||
|
// 是否已经提示过 IOSAI 服务错误
|
||||||
|
let isStartLac = false
|
||||||
|
|
||||||
|
// 拉设备列表
|
||||||
|
const getDeviceListFun = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDeviceList()
|
||||||
|
if (res && res.length > 0 && deviceInformation.value.length !== res.length) {
|
||||||
|
console.log('设备变更')
|
||||||
|
deviceInformation.value = res
|
||||||
|
}
|
||||||
|
if (!res || res.length === 0) {
|
||||||
|
deviceInformation.value = []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isStartLac) {
|
||||||
|
ElMessage.error('IOSAI 服务错误')
|
||||||
|
} else {
|
||||||
|
// 第一次忽略,等下次再报(保持你原来的行为)
|
||||||
|
isStartLac = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开 Tiktok
|
||||||
|
const openTk = async () => {
|
||||||
|
if (!deviceInformation.value?.length) {
|
||||||
|
ElMessage.warning('暂无在线设备')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ElLoading } = await import('element-plus')
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
text: '正在打开 TikTok …',
|
||||||
|
background: 'rgba(0,0,0,.35)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const dev of deviceInformation.value) {
|
||||||
|
const udid = dev.deviceId
|
||||||
|
try {
|
||||||
|
const apps = await deviceAppList({ udid })
|
||||||
|
const bundleId = pickTikTokBundleId(apps)
|
||||||
|
|
||||||
|
if (!bundleId) {
|
||||||
|
results.push({ udid, ok: false, msg: '未找到 TikTok' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await launchApp({ udid, bundleId })
|
||||||
|
results.push({ udid, ok: true, msg: `已启动 TikTok (${bundleId})` })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('openTk error', udid, e)
|
||||||
|
results.push({ udid, ok: false, msg: '请求失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const okCount = results.filter(r => r.ok).length
|
||||||
|
const fail = results.filter(r => !r.ok)
|
||||||
|
if (okCount) ElMessage.success(`已在 ${okCount} 台设备启动 TikTok`)
|
||||||
|
if (fail.length) {
|
||||||
|
const udids = fail.map(f => f.udid).join(', ')
|
||||||
|
ElMessage.error(`以下设备未能启动:${udids}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止单台任务
|
||||||
|
const stopOne = async (deviceId) => {
|
||||||
|
try {
|
||||||
|
await stopScript({ udid: deviceId })
|
||||||
|
// 你原来这里没有提示,我也保持不弹
|
||||||
|
// ElMessage.success('停止成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('stopOne error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceInformation,
|
||||||
|
getDeviceListFun,
|
||||||
|
getListTimer,
|
||||||
|
openTk,
|
||||||
|
stopOne,
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/composables/useNetStatus.js
Normal file
0
src/composables/useSSEAnchors.js
Normal file
0
src/composables/useSchedule.js
Normal file
255
src/composables/useScreenStreams.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// src/composables/useScreenStreams.js
|
||||||
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
|
import { tapAction, swipeAction } from '@/api/ios'
|
||||||
|
|
||||||
|
const BASE_W = 320
|
||||||
|
const BASE_H = 720
|
||||||
|
const THUMB_SCALE = 0.6
|
||||||
|
const PER_ROW = 3
|
||||||
|
const BOTTOM_SHIFT = Math.round(BASE_H * (1 - THUMB_SCALE)) // 288
|
||||||
|
|
||||||
|
export function useScreenStreams(deviceInformation) {
|
||||||
|
// 选中的设备索引
|
||||||
|
const selectedDevice = ref(null)
|
||||||
|
|
||||||
|
// 每台设备的 <img> 引用
|
||||||
|
const imgRefs = ref({}) // { [id]: HTMLImageElement }
|
||||||
|
|
||||||
|
// 每台设备当前展示的 URL
|
||||||
|
const imgSrcMap = reactive({}) // { [deviceId]: string }
|
||||||
|
|
||||||
|
// 每台设备循环状态
|
||||||
|
const loops = new Map() // deviceId -> { timer, stopped }
|
||||||
|
|
||||||
|
// const makeUrl = (port) => `http://192.168.1.218:${port}/?t=${Date.now()}`
|
||||||
|
const makeUrl = (port) => `http://localhost:${port}/?t=${Date.now()}`
|
||||||
|
|
||||||
|
function hardCloseImg(deviceId) {
|
||||||
|
const el = imgRefs.value[deviceId]
|
||||||
|
if (el) {
|
||||||
|
el.src = ''
|
||||||
|
el.removeAttribute('src')
|
||||||
|
}
|
||||||
|
imgSrcMap[deviceId] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop(deviceId) {
|
||||||
|
const s = loops.get(deviceId)
|
||||||
|
if (!s) {
|
||||||
|
hardCloseImg(deviceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopped = true
|
||||||
|
if (s.timer) clearTimeout(s.timer)
|
||||||
|
loops.delete(deviceId)
|
||||||
|
hardCloseImg(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop(dev, idx = 0) {
|
||||||
|
stopLoop(dev.deviceId)
|
||||||
|
const state = { timer: null, stopped: false }
|
||||||
|
loops.set(dev.deviceId, state)
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (state.stopped) return
|
||||||
|
const url = makeUrl(dev.screenPort)
|
||||||
|
imgSrcMap[dev.deviceId] = url
|
||||||
|
state.timer = window.setTimeout(tick, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.timer = window.setTimeout(tick, idx * 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileLoopsByDevices(list) {
|
||||||
|
const keep = new Set(list.map(d => d.deviceId))
|
||||||
|
|
||||||
|
// 停掉没了的设备
|
||||||
|
for (const id of Array.from(loops.keys())) {
|
||||||
|
if (!keep.has(id)) stopLoop(id)
|
||||||
|
}
|
||||||
|
// 为新增设备启动循环
|
||||||
|
list.forEach((d, i) => {
|
||||||
|
if (!loops.has(d.deviceId)) startLoop(d, i)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshOneImg(deviceId) {
|
||||||
|
const dev = deviceInformation.value.find(d => d.deviceId === deviceId)
|
||||||
|
if (dev) {
|
||||||
|
stopLoop(deviceId)
|
||||||
|
startLoop(dev, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllImgs() {
|
||||||
|
deviceInformation.value.forEach((d, i) => {
|
||||||
|
stopLoop(d.deviceId)
|
||||||
|
startLoop(d, i)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllStopImgs() {
|
||||||
|
Object.keys(imgRefs.value).forEach(id => hardCloseImg(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备变动时自动对齐循环
|
||||||
|
watch(deviceInformation, (list) => {
|
||||||
|
reconcileLoopsByDevices(list || [])
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// ——— 选中 + 缩放/上移样式 ———
|
||||||
|
const hasTwoRows = computed(() => deviceInformation.value.length > PER_ROW)
|
||||||
|
|
||||||
|
const isBottomRow = (index) => {
|
||||||
|
if (!hasTwoRows.value) return false
|
||||||
|
const lastRow = Math.floor((deviceInformation.value.length - 1) / PER_ROW)
|
||||||
|
return Math.floor(index / PER_ROW) === lastRow
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasStyle(index) {
|
||||||
|
const isSelected = selectedDevice.value === index
|
||||||
|
if (!isSelected) {
|
||||||
|
return { transform: `scale(${THUMB_SCALE})` }
|
||||||
|
}
|
||||||
|
return isBottomRow(index)
|
||||||
|
? { transform: `translateY(-${BOTTOM_SHIFT}px) scale(1)` }
|
||||||
|
: { transform: 'scale(1)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgWH = (index) => {
|
||||||
|
const scale = (selectedDevice.value === index) ? 1 : THUMB_SCALE
|
||||||
|
return {
|
||||||
|
width: `${BASE_W * scale}px`,
|
||||||
|
height: `${BASE_H * scale}px`,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displaySize = (index) => {
|
||||||
|
const scale = (selectedDevice.value === index) ? 1 : THUMB_SCALE
|
||||||
|
return { w: BASE_W * scale, h: BASE_H * scale }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDevice = (index) => {
|
||||||
|
selectedDevice.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— 坐标映射 + 鼠标交互 ———
|
||||||
|
const dragState = ref({}) // index -> { ox, oy, t, udid, ... }
|
||||||
|
|
||||||
|
const mapToDeviceXY = (index, offsetX, offsetY) => {
|
||||||
|
const dev = deviceInformation.value[index] || {}
|
||||||
|
const realW = Number(dev.width) || BASE_W
|
||||||
|
const realH = Number(dev.height) || BASE_H
|
||||||
|
const rotation = Number(dev.rotation || 0)
|
||||||
|
|
||||||
|
const { w: dispW, h: dispH } = displaySize(index)
|
||||||
|
let nx = Math.min(Math.max(offsetX / dispW, 0), 1)
|
||||||
|
let ny = Math.min(Math.max(offsetY / dispH, 0), 1)
|
||||||
|
|
||||||
|
let x, y
|
||||||
|
switch (rotation % 360) {
|
||||||
|
case 90:
|
||||||
|
case -270:
|
||||||
|
x = Math.round(ny * realW)
|
||||||
|
y = Math.round((1 - nx) * realH)
|
||||||
|
break
|
||||||
|
case 180:
|
||||||
|
case -180:
|
||||||
|
x = Math.round((1 - nx) * realW)
|
||||||
|
y = Math.round((1 - ny) * realH)
|
||||||
|
break
|
||||||
|
case 270:
|
||||||
|
case -90:
|
||||||
|
x = Math.round((1 - ny) * realW)
|
||||||
|
y = Math.round(nx * realH)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
x = Math.round(nx * realW)
|
||||||
|
y = Math.round(ny * realH)
|
||||||
|
}
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasDown = (udid, e, index) => {
|
||||||
|
const startDev = mapToDeviceXY(index, e.offsetX, e.offsetY)
|
||||||
|
dragState.value[index] = {
|
||||||
|
ox: e.offsetX,
|
||||||
|
oy: e.offsetY,
|
||||||
|
t: Date.now(),
|
||||||
|
udid,
|
||||||
|
startDevXY: startDev,
|
||||||
|
startOffsetXY: { x: e.offsetX, y: e.offsetY }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasMove = (udid, e, index) => {
|
||||||
|
const st = dragState.value[index]
|
||||||
|
if (!st) return
|
||||||
|
// const curDev = mapToDeviceXY(index, e.offsetX, e.offsetY)
|
||||||
|
// 调试需要再打印
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasUp = async (udid, e, index) => {
|
||||||
|
const st = dragState.value[index]
|
||||||
|
if (!st) return
|
||||||
|
|
||||||
|
const { ox, oy, t, startDevXY, startOffsetXY } = st
|
||||||
|
const dx = e.offsetX - ox
|
||||||
|
const dy = e.offsetY - oy
|
||||||
|
const elapsed = Date.now() - t
|
||||||
|
delete dragState.value[index]
|
||||||
|
|
||||||
|
const endDevXY = mapToDeviceXY(index, e.offsetX, e.offsetY)
|
||||||
|
const endOffsetXY = { x: e.offsetX, y: e.offsetY }
|
||||||
|
|
||||||
|
console.log('[鼠标滑动,起点/终点)+ 耗时]', {
|
||||||
|
udid,
|
||||||
|
start: {
|
||||||
|
offsetXY: startOffsetXY,
|
||||||
|
deviceXY: startDevXY
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
offsetXY: endOffsetXY,
|
||||||
|
deviceXY: endDevXY
|
||||||
|
},
|
||||||
|
deltaOffset: { dx, dy },
|
||||||
|
durationMs: elapsed,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MOVE_THR = 5
|
||||||
|
const isTap = Math.hypot(dx, dy) < MOVE_THR && elapsed < 500
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTap) {
|
||||||
|
await tapAction({ udid, x: endDevXY.x, y: endDevXY.y })
|
||||||
|
} else {
|
||||||
|
await swipeAction({
|
||||||
|
udid,
|
||||||
|
sx: startDevXY.x,
|
||||||
|
sy: startDevXY.y,
|
||||||
|
ex: endDevXY.x,
|
||||||
|
ey: endDevXY.y,
|
||||||
|
duration: elapsed / 1000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedDevice,
|
||||||
|
imgRefs,
|
||||||
|
imgSrcMap,
|
||||||
|
refreshAllImgs,
|
||||||
|
refreshOneImg,
|
||||||
|
refreshAllStopImgs,
|
||||||
|
getCanvasStyle,
|
||||||
|
imgWH,
|
||||||
|
selectDevice,
|
||||||
|
onCanvasDown,
|
||||||
|
onCanvasMove,
|
||||||
|
onCanvasUp,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import store from './store'
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
|
||||||
|
// createApp(App).use(store).use(router).mount('#app')
|
||||||
|
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(ElementPlus) // 注册 ElementPlus
|
||||||
|
app.use(createPinia()); // 注册 Pinia
|
||||||
|
app.use(store); // 注册 store
|
||||||
|
app.use(router);
|
||||||
|
app.config.globalProperties.Buffer = Buffer; // 注册 Buffer
|
||||||
|
|
||||||
|
// window.addEventListener('unhandledrejection', event => event.preventDefault());
|
||||||
|
// window.addEventListener('error', event => event.preventDefault()); // 阻止错误和未处overlay理的 Promise 拒绝事件冒泡到 window 对象
|
||||||
|
app.mount('#app');
|
||||||
|
|
||||||
31
src/router/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '../views/HomeView.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/Video',
|
||||||
|
name: 'Video',
|
||||||
|
component: () => import(/* webpackChunkName: "about" */ '@/views/VideoStream.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/test',
|
||||||
|
name: 'test',
|
||||||
|
component: () => import(/* webpackChunkName: "about" */ '@/views/test.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
54
src/services/websocket.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/services/websocket.js
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
export const connectWebSocket = (port = 8000) => {
|
||||||
|
// 关闭已有连接
|
||||||
|
if (ws) ws.close();
|
||||||
|
|
||||||
|
// 创建新连接
|
||||||
|
ws = new WebSocket(`ws://localhost:${port}`);
|
||||||
|
|
||||||
|
// 连接事件处理
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WS connected');
|
||||||
|
clearInterval(reconnectTimer);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WS error:', error);
|
||||||
|
handleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.log('WS closed:', event.reason);
|
||||||
|
handleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
console.log(12312312, event.data)
|
||||||
|
if (event.data instanceof Blob) {
|
||||||
|
// createImageBitmap(event.data).then(img => {
|
||||||
|
// ctx.drawImage(img, 0, 0);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动重连机制
|
||||||
|
const handleReconnect = () => {
|
||||||
|
clearInterval(reconnectTimer);
|
||||||
|
// reconnectTimer = setInterval(() => {
|
||||||
|
// connectWebSocket(8000);
|
||||||
|
// }, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送控制指令
|
||||||
|
export const sendControl = (data) => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
4
src/static/css/app.less
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@bg-color: #022b4e; // 主色
|
||||||
|
@bg-color-light: #022b4eaf; // 浅主色
|
||||||
|
@bg-color-light-light: #022b4e1c; // 浅浅主色
|
||||||
|
@btn-bg-color: #045dac; // 黄色按钮主色
|
||||||
326
src/static/css/video.less
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
body {
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
// width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #222;
|
||||||
|
background-image: url('../../assets/video/mainBg.png');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
/* 或其他适合的值,例如contain */
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
justify-content: center;
|
||||||
|
/* ⭐ 内容居中 */
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 居中工作台,不再是暗色面板 */
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
/* 不要 center */
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
/* 用 min-height */
|
||||||
|
height: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 允许滚动 */
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 浅色卡片容器 */
|
||||||
|
.content {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 40px rgba(15, 23, 42, 0.16),
|
||||||
|
0 0 0 1px rgba(148, 163, 184, 0.22);
|
||||||
|
transform: translateY(8px);
|
||||||
|
animation: fadeUp 0.35s ease-out forwards;
|
||||||
|
|
||||||
|
/* 把 video.less 里的 grid 清掉,防止影响布局 */
|
||||||
|
grid-template-columns: none !important;
|
||||||
|
grid-auto-rows: auto !important;
|
||||||
|
row-gap: 0 !important;
|
||||||
|
column-gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工作台容器,保证铺满 content */
|
||||||
|
.dashboard {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题栏(浅色文字) */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.title-block {
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部小提示条(浅色背景) */
|
||||||
|
.banner-tip {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(56, 189, 248, 0.08),
|
||||||
|
rgba(52, 211, 153, 0.08));
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.26);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行间距略微紧凑一点 */
|
||||||
|
.dashboard-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片通用样式(浅色) */
|
||||||
|
.panel-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f9fafb);
|
||||||
|
box-shadow: 0 12px 25px rgba(15, 23, 42, 0.08);
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card:hover {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 16px 35px rgba(37, 99, 235, 0.14);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f3f4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单卡片 padding 调整 */
|
||||||
|
.panel-form :deep(.el-card__body) {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题栏 */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #3b82f6, #22c55e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 运行按钮 —— 浅色主题 + 动画点 */
|
||||||
|
.run-btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 6px rgba(34, 197, 94, 0.8);
|
||||||
|
animation: pulseDot 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-btn:hover {
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单标签颜色 */
|
||||||
|
.run-form :deep(.el-form-item__label) {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 账号列表 */
|
||||||
|
.account-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-8 {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整 input 的 wrapper,让它更浅 */
|
||||||
|
.account-row :deep(.el-input__wrapper),
|
||||||
|
.prologue-input :deep(.el-input__wrapper) {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(209, 213, 219, 0.8) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row :deep(.el-input__wrapper:hover),
|
||||||
|
.prologue-input :deep(.el-input__wrapper:hover) {
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.9) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row :deep(.el-input__inner),
|
||||||
|
.prologue-input :deep(.el-input__inner) {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 字段说明 */
|
||||||
|
.tip {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI 自动回复那一行说明 */
|
||||||
|
.inline-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.field-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷操作区域 */
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button[type='primary']) {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button[type='danger']) {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 公共间距 */
|
||||||
|
.mt-16 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseDot {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/store/index.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createStore } from 'vuex'
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
state: {
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
}
|
||||||
|
})
|
||||||
12
src/stores/notice.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export const noticeStore = defineStore('noticeNum', {
|
||||||
|
state: () => {
|
||||||
|
return { data: { num: 0 } };
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
increment() {
|
||||||
|
this.data.num++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
src/stores/storage.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export function setToken(token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUser(user) {
|
||||||
|
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
//获取用户信息
|
||||||
|
export function getUser() {
|
||||||
|
return JSON.parse(localStorage.getItem('user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNumData(numData) {
|
||||||
|
localStorage.setItem('num', JSON.stringify(numData));
|
||||||
|
}
|
||||||
|
export function getNumData() {
|
||||||
|
return JSON.parse(localStorage.getItem('num'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出一个函数,用于设置用户密码
|
||||||
|
export function setUserPass(userdata) {
|
||||||
|
localStorage.setItem('userPass', JSON.stringify(userdata));
|
||||||
|
}
|
||||||
|
// 导出一个函数,用于获取用户密码
|
||||||
|
export function getUserPass() {
|
||||||
|
return JSON.parse(localStorage.getItem('userPass'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出一个函数,用于获取用户密码
|
||||||
|
export function setHostfilters(data) {
|
||||||
|
localStorage.setItem('host_filters_cache', JSON.stringify(data));
|
||||||
|
}
|
||||||
|
// 导出一个函数,用于获取用户密码
|
||||||
|
export function getHostfilters() {
|
||||||
|
return JSON.parse(localStorage.getItem('host_filters_cache'));
|
||||||
|
}
|
||||||
|
|
||||||
17
src/utils/arrUtils.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** 从 app 列表里找 TikTok 的 bundleId */
|
||||||
|
export function pickTikTokBundleId(apps) {
|
||||||
|
if (!Array.isArray(apps)) return null
|
||||||
|
|
||||||
|
// 1) 首选常见的 TikTok 包名
|
||||||
|
const preferred = apps.find(a => a?.bundleId === 'com.zhiliaoapp.musically')
|
||||||
|
if (preferred) return preferred.bundleId
|
||||||
|
|
||||||
|
// 2) 其次按名字匹配(大小写不敏感)
|
||||||
|
const byName = apps.find(a => String(a?.name).toLowerCase() === 'tiktok')
|
||||||
|
|| apps.find(a => String(a?.name).toLowerCase().includes('tiktok'))
|
||||||
|
if (byName) return byName.bundleId
|
||||||
|
|
||||||
|
// 3) 兜底:bundleId 里包含 musically
|
||||||
|
const fuzzy = apps.find(a => String(a?.bundleId || '').includes('musically'))
|
||||||
|
return fuzzy ? fuzzy.bundleId : null
|
||||||
|
}
|
||||||
142
src/utils/axios.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* axios请求封装(双实例)
|
||||||
|
*/
|
||||||
|
import axios from 'axios'
|
||||||
|
import router from '@/router'
|
||||||
|
import { getToken } from '@/stores/storage'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// —— 改为从 .env 文件读取 ——
|
||||||
|
const BASE_LOCAL = process.env.VUE_APP_BASE_LOCAL
|
||||||
|
const BASE_REMOTE = process.env.VUE_APP_BASE_REMOTE
|
||||||
|
const BASE_SPECIAL = process.env.VUE_APP_BASE_SPECIAL
|
||||||
|
|
||||||
|
let isStart = true
|
||||||
|
|
||||||
|
// 公共:给某个 axios 实例挂上拦截器
|
||||||
|
function attachInterceptors(instance) {
|
||||||
|
|
||||||
|
//请求拦截器
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
// 登录/换租户接口可能不需要 token,根据你的需求放行
|
||||||
|
const urlLast = sliceUrl(config.url || '')
|
||||||
|
if ((urlLast === 'prologue' || urlLast === 'comment' || urlLast === 'aiChat-logout' || urlLast === 'updates' || urlLast === 'health')) {
|
||||||
|
config.headers['vvtoken'] = getToken()
|
||||||
|
}
|
||||||
|
// 超时 & 通用头
|
||||||
|
config.timeout = 180000
|
||||||
|
if (!config.headers) config.headers = {}
|
||||||
|
// 大多数 POST 走 x-www-form-urlencoded(保持你原来的行为)
|
||||||
|
// console.log(config.method)
|
||||||
|
// if (config.method == 'post') {
|
||||||
|
// config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
// }
|
||||||
|
// config.headers['Content-type'] = 'application/json'
|
||||||
|
return config
|
||||||
|
}, (error) => Promise.reject(error))
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const data = response.data // 请求的 返回数据
|
||||||
|
const url = response.config.url // 请求的 url
|
||||||
|
if (data?.code === 0 || data?.code === 200) {
|
||||||
|
// 成功:返回业务数据(没有就回传原 data)
|
||||||
|
return (data?.data !== undefined) ? data.data : data
|
||||||
|
} else if (data?.code === 40400) {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务失败:提示 + reject
|
||||||
|
const msg = `${data?.code ?? ''} ${data?.message ?? '请求失败'}`
|
||||||
|
ElMessage.error(msg)
|
||||||
|
|
||||||
|
const err = new Error(msg)
|
||||||
|
// @ts-ignore
|
||||||
|
err.code = data?.code
|
||||||
|
// @ts-ignore
|
||||||
|
err.response = response
|
||||||
|
return Promise.reject(err)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// 网络/超时等:提示 + reject
|
||||||
|
if (error.code === 'ERR_NETWORK') {
|
||||||
|
// 你原来的 isStart 逻辑如果要保留,只控制是否弹 toast;但**不要 return**
|
||||||
|
|
||||||
|
ElMessage.error('网络请求失败')
|
||||||
|
|
||||||
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
|
ElMessage.error('请求超时')
|
||||||
|
}
|
||||||
|
return Promise.reject(error) // 关键:一定要 reject
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建两个独立的实例
|
||||||
|
const remoteAxios = axios.create({ baseURL: BASE_REMOTE })
|
||||||
|
const localAxios = axios.create({ baseURL: BASE_LOCAL })
|
||||||
|
const specialAxios = axios.create({ baseURL: BASE_SPECIAL })
|
||||||
|
|
||||||
|
attachInterceptors(remoteAxios)
|
||||||
|
attachInterceptors(localAxios)
|
||||||
|
attachInterceptors(specialAxios)
|
||||||
|
// —— 导出两套 GET/POST ——
|
||||||
|
// 远端(api.tkpage.yolozs.com)
|
||||||
|
export function getRemote({ url, params }) {
|
||||||
|
return remoteAxios.get(url, { params })
|
||||||
|
}
|
||||||
|
export function postRemote({ url, data }) {
|
||||||
|
return remoteAxios.post(url, data)
|
||||||
|
}
|
||||||
|
export async function downFileRemote(urlstr, data) {
|
||||||
|
const resp = await remoteAxios.post(urlstr, data, { responseType: 'blob' })
|
||||||
|
triggerDownload(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地(192.168.1.218:5000)
|
||||||
|
export function getLocal({ url, params }) {
|
||||||
|
return localAxios.get(url, { params })
|
||||||
|
}
|
||||||
|
export function postLocal({ url, data }) {
|
||||||
|
return localAxios.post(url, data)
|
||||||
|
}
|
||||||
|
export async function downFileLocal(urlstr, data) {
|
||||||
|
const resp = await localAxios.post(urlstr, data, { responseType: 'blob' })
|
||||||
|
triggerDownload(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ai ai翻译等功能的 GET/POST
|
||||||
|
export function getSpecial({ url, params }) {
|
||||||
|
return specialAxios.get(url, { params })
|
||||||
|
}
|
||||||
|
export function postSpecial({ url, data, headers } = {}) {
|
||||||
|
return specialAxios.post(url, data, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 工具函数保留 ——
|
||||||
|
function triggerDownload(response) {
|
||||||
|
const contentDisposition = response.headers['content-disposition']
|
||||||
|
let fileName = 'download'
|
||||||
|
if (contentDisposition) {
|
||||||
|
const m = contentDisposition.match(/filename="(.+)"/)
|
||||||
|
if (m && m[1]) fileName = m[1]
|
||||||
|
}
|
||||||
|
const blob = new Blob([response.data], { type: response.headers['content-type'] })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = fileName
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceUrl(url) {
|
||||||
|
const lastSlash = url.lastIndexOf('/')
|
||||||
|
const questionMark = url.indexOf('?')
|
||||||
|
if (questionMark === -1) return url.slice(lastSlash + 1)
|
||||||
|
return url.slice(lastSlash + 1, questionMark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// export default remoteAxios // 如有需要,默认导出一个实例(可选)
|
||||||
261
src/utils/countryUtil.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// country-utils.js
|
||||||
|
export const CountryCode = {
|
||||||
|
AD: "安道尔",
|
||||||
|
AE: "阿拉伯联合酋长国",
|
||||||
|
AF: "阿富汗",
|
||||||
|
AG: "安提瓜和巴布达",
|
||||||
|
AI: "安圭拉",
|
||||||
|
AL: "阿尔巴尼亚",
|
||||||
|
AM: "亚美尼亚",
|
||||||
|
AO: "安哥拉",
|
||||||
|
AQ: "南极洲",
|
||||||
|
AR: "阿根廷",
|
||||||
|
AS: "美属萨摩亚",
|
||||||
|
AT: "奥地利",
|
||||||
|
AU: "澳大利亚",
|
||||||
|
AU1: "澳大利亚",
|
||||||
|
AW: "阿鲁巴",
|
||||||
|
AX: "奥兰群岛",
|
||||||
|
AZ: "阿塞拜疆",
|
||||||
|
BA: "波斯尼亚和黑塞哥维那",
|
||||||
|
BB: "巴巴多斯",
|
||||||
|
BD: "孟加拉国",
|
||||||
|
BE: "比利时",
|
||||||
|
BF: "布基纳法索",
|
||||||
|
BG: "保加利亚",
|
||||||
|
BH: "巴林",
|
||||||
|
BI: "布隆迪",
|
||||||
|
BJ: "贝宁",
|
||||||
|
BL: "圣巴泰勒米",
|
||||||
|
BM: "百慕大群岛",
|
||||||
|
BN: "文莱达鲁萨兰国",
|
||||||
|
BO: "玻利维亚",
|
||||||
|
BQ: "博奈尔、圣尤斯特歇斯和萨巴",
|
||||||
|
BR: "巴西",
|
||||||
|
BS: "巴哈马",
|
||||||
|
BT: "不丹",
|
||||||
|
BV: "布韦岛",
|
||||||
|
BW: "博茨瓦纳",
|
||||||
|
BY: "白俄罗斯",
|
||||||
|
BZ: "伯利兹",
|
||||||
|
CA: "加拿大",
|
||||||
|
CA1: "加拿大",
|
||||||
|
CC: "科科斯(基林)群岛",
|
||||||
|
CD: "刚果民主共和国",
|
||||||
|
CF: "中非共和国",
|
||||||
|
CG: "刚果共和国",
|
||||||
|
CH: "瑞士",
|
||||||
|
CI: "科特迪瓦",
|
||||||
|
CK: "库克群岛",
|
||||||
|
CL: "智利",
|
||||||
|
CM: "喀麦隆",
|
||||||
|
CN: "中国",
|
||||||
|
CO: "哥伦比亚",
|
||||||
|
CR: "哥斯达黎加",
|
||||||
|
CU: "古巴",
|
||||||
|
CV: "佛得角",
|
||||||
|
CW: "库拉索",
|
||||||
|
CX: "圣诞岛",
|
||||||
|
CY: "塞浦路斯",
|
||||||
|
CZ: "捷克共和国",
|
||||||
|
DE: "德国",
|
||||||
|
DG: "迪戈加西亚岛",
|
||||||
|
DJ: "吉布提",
|
||||||
|
DK: "丹麦",
|
||||||
|
DM: "多米尼克",
|
||||||
|
DO: "多米尼加共和国",
|
||||||
|
DZ: "阿尔及利亚",
|
||||||
|
EC: "厄瓜多尔",
|
||||||
|
EE: "爱沙尼亚",
|
||||||
|
EG: "埃及",
|
||||||
|
EH: "西撒哈拉",
|
||||||
|
ER: "厄立特里亚",
|
||||||
|
ES: "西班牙",
|
||||||
|
ET: "埃塞俄比亚",
|
||||||
|
FI: "芬兰",
|
||||||
|
FJ: "斐济",
|
||||||
|
FK: "福克兰群岛",
|
||||||
|
FM: "密克罗尼西亚",
|
||||||
|
FO: "法罗群岛",
|
||||||
|
FR: "法国",
|
||||||
|
GA: "加蓬",
|
||||||
|
GB: "英国",
|
||||||
|
GD: "格林纳达",
|
||||||
|
GE: "格鲁吉亚",
|
||||||
|
GF: "法属圭亚那",
|
||||||
|
GG: "根西岛",
|
||||||
|
GH: "加纳",
|
||||||
|
GI: "直布罗陀",
|
||||||
|
GL: "格陵兰",
|
||||||
|
GM: "冈比亚",
|
||||||
|
GN: "几内亚",
|
||||||
|
GP: "瓜德罗普",
|
||||||
|
GQ: "赤道几内亚",
|
||||||
|
GR: "希腊",
|
||||||
|
GS: "南乔治亚和南桑德威奇群岛",
|
||||||
|
GT: "危地马拉",
|
||||||
|
GU: "关岛",
|
||||||
|
GW: "几内亚比绍",
|
||||||
|
GY: "圭亚那",
|
||||||
|
HK: "中国香港特别行政区",
|
||||||
|
HM: "赫德岛和麦克唐纳群岛",
|
||||||
|
HN: "洪都拉斯",
|
||||||
|
HR: "克罗地亚",
|
||||||
|
HT: "海地",
|
||||||
|
HU: "匈牙利",
|
||||||
|
ID: "印度尼西亚",
|
||||||
|
IE: "爱尔兰",
|
||||||
|
IL: "以色列",
|
||||||
|
IM: "马恩岛",
|
||||||
|
IN: "印度",
|
||||||
|
IO: "英属印度洋领地",
|
||||||
|
IQ: "伊拉克",
|
||||||
|
IR: "伊朗",
|
||||||
|
IS: "冰岛",
|
||||||
|
IT: "意大利",
|
||||||
|
JE: "泽西岛",
|
||||||
|
JM: "牙买加",
|
||||||
|
JO: "约旦",
|
||||||
|
JP: "日本",
|
||||||
|
JP1: "日本",
|
||||||
|
KE: "肯尼亚",
|
||||||
|
KG: "吉尔吉斯斯坦",
|
||||||
|
KH: "柬埔寨",
|
||||||
|
KI: "基里巴斯",
|
||||||
|
KM: "科摩罗",
|
||||||
|
KN: "圣基茨和尼维斯",
|
||||||
|
KP: "朝鲜",
|
||||||
|
KR: "韩国",
|
||||||
|
KR1: "韩国",
|
||||||
|
KR1_UXWAUDIT: "韩国",
|
||||||
|
KW: "科威特",
|
||||||
|
KY: "开曼群岛",
|
||||||
|
KZ: "哈萨克斯坦",
|
||||||
|
LA: "老挝",
|
||||||
|
LB: "黎巴嫩",
|
||||||
|
LC: "圣卢西亚",
|
||||||
|
LI: "列支敦士登",
|
||||||
|
LK: "斯里兰卡",
|
||||||
|
LR: "利比里亚",
|
||||||
|
LS: "莱索托",
|
||||||
|
LT: "立陶宛",
|
||||||
|
LU: "卢森堡",
|
||||||
|
LV: "拉脱维亚",
|
||||||
|
LY: "利比亚",
|
||||||
|
MA: "摩洛哥",
|
||||||
|
MC: "摩纳哥",
|
||||||
|
MD: "摩尔多瓦",
|
||||||
|
ME: "黑山",
|
||||||
|
MF: "圣马丁",
|
||||||
|
MG: "马达加斯加",
|
||||||
|
MH: "马绍尔群岛",
|
||||||
|
MK: "北马其顿",
|
||||||
|
ML: "马里",
|
||||||
|
MM: "缅甸",
|
||||||
|
MN: "蒙古",
|
||||||
|
MO: "中国澳门特别行政区",
|
||||||
|
MP: "北马里亚纳群岛",
|
||||||
|
MQ: "马提尼克",
|
||||||
|
MR: "毛里塔尼亚",
|
||||||
|
MS: "蒙特塞拉特",
|
||||||
|
MT: "马耳他",
|
||||||
|
MU: "毛里求斯",
|
||||||
|
MV: "马尔代夫",
|
||||||
|
MW: "马拉维",
|
||||||
|
MX: "墨西哥",
|
||||||
|
MY: "马来西亚",
|
||||||
|
MZ: "莫桑比克",
|
||||||
|
NA: "纳米比亚",
|
||||||
|
NC: "新喀里多尼亚",
|
||||||
|
NE: "尼日尔",
|
||||||
|
NF: "诺福克岛",
|
||||||
|
NG: "尼日利亚",
|
||||||
|
NI: "尼加拉瓜",
|
||||||
|
NL: "荷兰",
|
||||||
|
NO: "挪威",
|
||||||
|
NP: "尼泊尔",
|
||||||
|
NR: "瑙鲁",
|
||||||
|
NU: "纽埃",
|
||||||
|
NZ: "新西兰",
|
||||||
|
OM: "阿曼",
|
||||||
|
PA: "巴拿马",
|
||||||
|
PE: "秘鲁",
|
||||||
|
PF: "法属玻利尼西亚",
|
||||||
|
PG: "巴布亚新几内亚",
|
||||||
|
PH: "菲律宾",
|
||||||
|
PK: "巴基斯坦",
|
||||||
|
PL: "波兰",
|
||||||
|
PM: "圣皮埃尔和密克隆群岛",
|
||||||
|
PN: "皮特凯恩群岛",
|
||||||
|
PR: "波多黎各",
|
||||||
|
PS: "巴勒斯坦",
|
||||||
|
PT: "葡萄牙",
|
||||||
|
PW: "帕劳",
|
||||||
|
PY: "巴拉圭",
|
||||||
|
QA: "卡塔尔",
|
||||||
|
RE: "留尼汪",
|
||||||
|
RO: "罗马尼亚",
|
||||||
|
RS: "塞尔维亚",
|
||||||
|
RU: "俄罗斯",
|
||||||
|
RW: "卢旺达",
|
||||||
|
SA: "沙特阿拉伯",
|
||||||
|
SB: "索罗门群岛",
|
||||||
|
SC: "塞舌尔",
|
||||||
|
SD: "苏丹",
|
||||||
|
SE: "瑞典",
|
||||||
|
SG: "新加坡",
|
||||||
|
SI: "斯洛文尼亚",
|
||||||
|
SJ: "斯瓦尔巴和扬马延",
|
||||||
|
SK: "斯洛伐克",
|
||||||
|
SL: "塞拉利昂",
|
||||||
|
SM: "圣马利诺",
|
||||||
|
SN: "塞内加尔",
|
||||||
|
SO: "索马里",
|
||||||
|
SR: "苏里南",
|
||||||
|
SS: "南苏丹",
|
||||||
|
ST: "圣多美和普林西比",
|
||||||
|
SV: "萨尔瓦多",
|
||||||
|
SX: "荷属圣马丁",
|
||||||
|
SY: "叙利亚",
|
||||||
|
SZ: "斯威士兰",
|
||||||
|
TC: "特克斯和凯科斯群岛",
|
||||||
|
TD: "乍得",
|
||||||
|
TF: "法属南部领地",
|
||||||
|
TG: "多哥",
|
||||||
|
TH: "泰国",
|
||||||
|
TJ: "塔吉克斯坦",
|
||||||
|
TK: "托克劳群岛",
|
||||||
|
TL: "东帝汶",
|
||||||
|
TM: "土库曼斯坦",
|
||||||
|
TN: "突尼斯",
|
||||||
|
TO: "汤加",
|
||||||
|
TR: "土耳其",
|
||||||
|
TT: "特立尼达和多巴哥",
|
||||||
|
TV: "图瓦卢",
|
||||||
|
TW: "台湾",
|
||||||
|
TZ: "坦桑尼亚",
|
||||||
|
UA: "乌克兰",
|
||||||
|
UG: "乌干达",
|
||||||
|
UM: "美国本土外小岛屿",
|
||||||
|
US: "美国",
|
||||||
|
UY: "乌拉圭",
|
||||||
|
UZ: "乌兹别克斯坦",
|
||||||
|
VA: "梵蒂冈",
|
||||||
|
VC: "圣文森特",
|
||||||
|
VE: "委内瑞拉",
|
||||||
|
VG: "英属维尔京群岛",
|
||||||
|
VI: "美属维尔京群岛",
|
||||||
|
VN: "越南",
|
||||||
|
VN1: "越南",
|
||||||
|
VU: "瓦努阿图",
|
||||||
|
WS: "萨摩亚",
|
||||||
|
YE: "也门",
|
||||||
|
YT: "马约特岛",
|
||||||
|
ZA: "南非",
|
||||||
|
ZM: "赞比亚",
|
||||||
|
ZW: "津巴布韦"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCountryName(code) {
|
||||||
|
return CountryCode[code] || null;
|
||||||
|
}
|
||||||
46
src/utils/sseUtils.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//初始化sse连接
|
||||||
|
let eventSource = null
|
||||||
|
//重连延迟
|
||||||
|
let retryTimeout = null
|
||||||
|
export function connectSSE(url, onMessage) {
|
||||||
|
// 如果已有连接,先关闭
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SSE] 正在连接:', url)
|
||||||
|
|
||||||
|
eventSource = new EventSource(url)
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('[SSE] 连接已建立')
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[SSE] 收到消息:', event)
|
||||||
|
try {
|
||||||
|
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (onMessage) onMessage(data)
|
||||||
|
} catch (e) {
|
||||||
|
if (onMessage) onMessage(event.data)
|
||||||
|
console.warn('[SSE] 消息解析失败:', event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.error('[SSE] 连接错误:', err)
|
||||||
|
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
|
||||||
|
// 避免重复重连
|
||||||
|
if (retryTimeout) clearTimeout(retryTimeout)
|
||||||
|
|
||||||
|
// 3秒后重连
|
||||||
|
retryTimeout = setTimeout(() => {
|
||||||
|
console.log('[SSE] 尝试重新连接...')
|
||||||
|
connectSSE(url, onMessage)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/views/HomeView.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<div class="container">
|
||||||
|
<div class="right">
|
||||||
|
<img src="../assets/logoBg.png" class="background-video" alt="">
|
||||||
|
<!-- 设置 -->
|
||||||
|
<div class="center-align">
|
||||||
|
<div></div>
|
||||||
|
<div class="setup">
|
||||||
|
<div class="setup-item center-justify">
|
||||||
|
<div></div>
|
||||||
|
<span>
|
||||||
|
网络设置
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="setup-item center-justify">
|
||||||
|
<div></div>
|
||||||
|
<span>
|
||||||
|
简体中文
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="center-line" style="margin-top: 40px;">
|
||||||
|
<!-- logo -->
|
||||||
|
<div class="logo">
|
||||||
|
<div class="center-justify" style="height: 80px; width: 300px;">
|
||||||
|
<!-- <img style="margin-right: 20px;height: 100%;" src="@/assets/logo.png"> -->
|
||||||
|
<img style="height: 100%;" src="@/assets/logotext.png">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- From -->
|
||||||
|
<div class="from">
|
||||||
|
<div class="from-title center-justify">
|
||||||
|
<div>账号登陆</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="from-input">
|
||||||
|
<el-form label-position="left" label-width="100px" :model="formData">
|
||||||
|
<div class="from-input-item1">
|
||||||
|
<img src="@/assets/username.png" alt="">
|
||||||
|
<el-input style="height: 25px;" v-model="formData.tenantName" placeholder="租户号"
|
||||||
|
clearable @keyup.enter="onSubmit" />
|
||||||
|
</div>
|
||||||
|
<div class="from-input-item1">
|
||||||
|
<img src="@/assets/username.png" alt="">
|
||||||
|
<el-input style="height: 25px;" v-model="formData.userId" placeholder="账号" clearable
|
||||||
|
@keyup.enter="onSubmit" />
|
||||||
|
</div>
|
||||||
|
<div class="from-input-item1">
|
||||||
|
<img src="@/assets/password.png" alt="">
|
||||||
|
<el-input style="height: 25px; " v-model="formData.password" type="password"
|
||||||
|
placeholder="密码" show-password @keyup.enter="onSubmit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="from-input-item">
|
||||||
|
<el-button class="loginButton" color="#8f7ee7" type="primary"
|
||||||
|
@click="onSubmit">登录</el-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="from-input-item">
|
||||||
|
<el-button class="loginButton" color="#8f7ee7" type="primary"
|
||||||
|
@click="router.push('/test');">跳转</el-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="version center-justify ">版本号:{{ version }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { login, getIdByName } from '@/api/account';
|
||||||
|
import { getToken, setToken, setUser, setUserPass, getUserPass } from '@/stores/storage';
|
||||||
|
import { ElLoading, ElMessage } from 'element-plus';
|
||||||
|
import { setTenantId } from '@/api/ios';
|
||||||
|
|
||||||
|
let version = ref('0.0.0');
|
||||||
|
onMounted(async () => {
|
||||||
|
await window.electronAPI.isiproxy()
|
||||||
|
version.value = await window.electronAPI.getVersion()
|
||||||
|
console.log(version.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
tenantName: getUserPass() == null ? '' : getUserPass().tenantName,
|
||||||
|
userId: getUserPass() == null ? '' : getUserPass().userId,
|
||||||
|
password: getUserPass() == null ? '' : getUserPass().password,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: 'Loading',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
});
|
||||||
|
setUserPass(formData.value);
|
||||||
|
getIdByName(formData.value.tenantName).then((tenantId) => {
|
||||||
|
console.log("tenantId", tenantId)
|
||||||
|
login({
|
||||||
|
tenantId: Number(tenantId),
|
||||||
|
username: formData.value.userId,
|
||||||
|
password: formData.value.password,
|
||||||
|
}).then((res) => {
|
||||||
|
loading.close();
|
||||||
|
console.log(res)
|
||||||
|
setToken(res.tokenValue);
|
||||||
|
setUser(res);
|
||||||
|
setTimeout(() => {
|
||||||
|
setTenantId({ tenantId: res.tenantId, token: res.tokenValue })
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
router.push('/Video');
|
||||||
|
}).catch((err) => {
|
||||||
|
loading.close();
|
||||||
|
}).finally((err) => {
|
||||||
|
// loading.close();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.main {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
/* 页面无法选中 */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
.right {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 20px 40px 20px 50px;
|
||||||
|
border-left: 3px solid #23516e;
|
||||||
|
position: relative;
|
||||||
|
/* 添加 position: relative */
|
||||||
|
overflow: hidden;
|
||||||
|
/* 防止内容溢出 */
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
font-size: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
left: calc(50% - 50px);
|
||||||
|
// box-sizing: border-box;
|
||||||
|
// width: 1600px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.background-video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
/* 确保视频在内容之下 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup {
|
||||||
|
display: flex;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.setup-item {
|
||||||
|
padding: 10px 6px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
div {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgb(255, 255, 255);
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from {
|
||||||
|
width: 420px;
|
||||||
|
// height: 320px;
|
||||||
|
color: #022b4e;
|
||||||
|
background-color: #ffffff44;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
padding: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.from-title {
|
||||||
|
font-family: Source Han Sans SC;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #022b4e;
|
||||||
|
line-height: 37px;
|
||||||
|
|
||||||
|
|
||||||
|
div {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
// border-bottom: 4px solid #1db97d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px 0;
|
||||||
|
|
||||||
|
.from-input-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.from-input-item-title {
|
||||||
|
color: #022b4e;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
width: 80px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginButton {
|
||||||
|
width: 359px;
|
||||||
|
height: 50px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #FFFFFF;
|
||||||
|
|
||||||
|
|
||||||
|
font-family: Source Han Sans SC;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #022b4e;
|
||||||
|
line-height: 37px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-input-item1 {
|
||||||
|
display: flex;
|
||||||
|
width: 359px;
|
||||||
|
height: 50px;
|
||||||
|
background: #022b4e1c;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #FFFFFF;
|
||||||
|
padding: 12px 25px 13px 25px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-line {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-justify {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-align {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-flex {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
--el-input-focus-border-color: rgba(255, 255, 0, 0);
|
||||||
|
--el-menu-hover-bg-color: rgba(255, 255, 0, 0);
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
::v-deep(.el-input__wrapper) {
|
||||||
|
background-color: rgba(255, 0, 0, 0);
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep(.el-input__inner) {
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep(.el-input__inner::placeholder) {
|
||||||
|
color: #022b4e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
719
src/views/VideoStream.vue
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<!-- 中间:工作台主体 -->
|
||||||
|
<div class="content">
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- 顶部标题栏 -->
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="title-block">
|
||||||
|
<h2>自动私信工作台</h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
配置账号 · 设置 AI 回复策略 · 一键运行任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-tag v-if="aiConfigured" type="success" effect=" ·">
|
||||||
|
AI 人设:已配置
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="warning" effect="dark">
|
||||||
|
AI 人设:未配置
|
||||||
|
</el-tag>
|
||||||
|
<span class="time">
|
||||||
|
{{ printCurrentTime() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 小提示条 -->
|
||||||
|
<div class="banner-tip">
|
||||||
|
<span class="dot"></span>
|
||||||
|
建议:最多配置 3 个账号轮询发送,开启 AI 自动回复可提升转化率。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="18" class="dashboard-body">
|
||||||
|
<!-- 左:运行配置表单 -->
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-card shadow="hover" class="panel-card panel-form">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span>运行配置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="!running" class="run-btn" type="primary" size="small" @click="startRun">
|
||||||
|
<span class="run-dot"></span>
|
||||||
|
开始运行
|
||||||
|
</button>
|
||||||
|
<button v-else class="run-btn" type="error" size="small" @click="stop">
|
||||||
|
<span class="run-dot"></span>
|
||||||
|
停止运行
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :model="runForm" label-width="120px" class="run-form">
|
||||||
|
<!-- 账号组列表 -->
|
||||||
|
<el-form-item label="账号组(最多3组)">
|
||||||
|
<div class="group-wrap">
|
||||||
|
<div v-for="(group, gIndex) in visibleGroups" :key="gIndex" class="group-block">
|
||||||
|
<div class="group-head">
|
||||||
|
<span class="group-title">
|
||||||
|
{{ group.name }}
|
||||||
|
<el-tag v-if="running && currentGroupIndex === gIndex" type="success" effect="dark"
|
||||||
|
style="margin-left:8px">
|
||||||
|
运行中
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<el-button type="primary" link @click="addAccount(gIndex)"
|
||||||
|
:disabled="group.accounts.length >= 3">
|
||||||
|
新增账号
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(acc, aIndex) in group.accounts" :key="aIndex" class="account-row">
|
||||||
|
<el-input v-model="acc.email" placeholder="邮箱(email)" class="mr-8" />
|
||||||
|
<el-input v-model="acc.pwd" placeholder="密码(pwd)" show-password class="mr-8" />
|
||||||
|
|
||||||
|
<el-button type="danger" link @click="removeAccount(gIndex, aIndex)"
|
||||||
|
:disabled="group.accounts.length === 1">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tip">每组最多 3 个账号,将按组轮换运行。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 是否轮换 / 组数量 -->
|
||||||
|
<el-form-item label="轮换账号组">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-switch v-model="runForm.rotateEnabled" active-text="开启轮换" inactive-text="不轮换" />
|
||||||
|
<span class="field-desc">关闭时只跑当前组(第1组)不切换。</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="runForm.rotateEnabled" label="账号组数量">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-radio-group v-model="runForm.groupCount">
|
||||||
|
<el-radio :value="2">2组</el-radio>
|
||||||
|
<el-radio :value="3">3组</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 轮换时间(小时) -->
|
||||||
|
<el-form-item v-if="runForm.rotateEnabled" label="轮换间隔(分钟)">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-input-number v-model="runForm.switchHours" :min="1" :step="1" controls-position="right" />
|
||||||
|
<span class="field-desc">每隔 N 分钟自动停止当前组并切换到下一组循环运行。</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- AI 回复开关 -->
|
||||||
|
<el-form-item label="AI 自动回复">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-switch v-model="runForm.aiReply" active-text="开启" inactive-text="关闭" />
|
||||||
|
<span class="field-desc">
|
||||||
|
开启后由 AI 自动根据对话内容生成回复。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 第一条是否发送邀请链接 -->
|
||||||
|
<el-form-item label="第一条消息">
|
||||||
|
<el-radio-group v-model="runForm.sendInviteFirst">
|
||||||
|
<el-radio :value="false">打招呼</el-radio>
|
||||||
|
<el-radio :value="true">发送邀请链接</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 添加睡眠时间输入框 -->
|
||||||
|
<el-form-item label="睡眠时间(秒)">
|
||||||
|
<el-input-number v-model="runForm.sleepTime" :min="0" :step="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 打招呼后回复几句发送邀请链接 -->
|
||||||
|
<el-form-item v-if="!runForm.sendInviteFirst" label="链接发送时机">
|
||||||
|
<div class="inline-field">
|
||||||
|
<el-input-number v-model="runForm.inviteThreshold" :min="1" :step="1" controls-position="right" />
|
||||||
|
<span class="field-desc">打招呼后回复 N 句再发送邀请链接</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 右:快捷操作 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="panel-card mt-16 panel-actions">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span>快捷操作</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<el-button style="margin-left: 12px;" type="primary" text @click="showHostDlg = true">
|
||||||
|
执行主播库
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button type="primary" text @click="showMyInfo = true">
|
||||||
|
配置 / 修改 AI 人设
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" text @click="doLogout">
|
||||||
|
退出登录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 人设配置弹窗 -->
|
||||||
|
<AgentGuildDialog v-model="showMyInfo" :model="{
|
||||||
|
agentName: borkerConfig.agentName,
|
||||||
|
guildName: borkerConfig.guildName,
|
||||||
|
contactTool: borkerConfig.contactTool,
|
||||||
|
contact: borkerConfig.contact
|
||||||
|
}" @save="onSave" />
|
||||||
|
|
||||||
|
<HostListManagerDialog v-model:visible="showHostDlg" />
|
||||||
|
<TranslationDialog v-model="showtransDlg" :translateFn="doTranslate" storage-key-prefix="demo-translation"
|
||||||
|
@confirm="onConfirm" @cancel="" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElLoading } from 'element-plus'
|
||||||
|
|
||||||
|
import { getUser } from '@/stores/storage'
|
||||||
|
import { logout, health } from '@/api/account'
|
||||||
|
import { aiConfig, run, shutdown } from '@/api/ios'
|
||||||
|
import { customTranslation } from '@/api/chat'
|
||||||
|
|
||||||
|
import AgentGuildDialog from '@/components/AgentGuildDialog.vue'
|
||||||
|
import HostListManagerDialog from '@/components/HostListManagerDialog.vue'
|
||||||
|
import TranslationDialog from '@/components/translationDialog.vue'
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 基础状态 / 常量
|
||||||
|
* ========================= */
|
||||||
|
|
||||||
|
// 路由实例:用于退出登录后跳转
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 当前登录用户信息(本地缓存)
|
||||||
|
const userdata = getUser()
|
||||||
|
|
||||||
|
// localStorage 存储 key:保存运行配置(账号组/开关/间隔等)
|
||||||
|
const RUN_CONFIG_CACHE_KEY = 'autoDm_runConfig'
|
||||||
|
|
||||||
|
// 弹窗控制:AI人设 / 主播库 / 翻译弹窗
|
||||||
|
const showMyInfo = ref(false)
|
||||||
|
const showHostDlg = ref(false)
|
||||||
|
const showtransDlg = ref(false)
|
||||||
|
|
||||||
|
// AI 人设是否已配置(用于顶部 tag 显示)
|
||||||
|
const aiConfigured = ref(false)
|
||||||
|
|
||||||
|
// 心跳定时器(health 检测)
|
||||||
|
const getListTimer = ref(null)
|
||||||
|
|
||||||
|
// AI 人设配置表单(给 AgentGuildDialog 用)
|
||||||
|
const borkerConfig = reactive({
|
||||||
|
agentName: '',
|
||||||
|
guildName: '',
|
||||||
|
contactTool: '',
|
||||||
|
contact: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 运行配置
|
||||||
|
* - rotateEnabled:是否启用账号组轮换
|
||||||
|
* - groupCount:轮换时启用的组数量(1~3)
|
||||||
|
* - accountGroups:账号组列表,每组最多3个账号
|
||||||
|
* - switchHours:轮换间隔(小时)
|
||||||
|
* ========================= */
|
||||||
|
const runForm = reactive({
|
||||||
|
rotateEnabled: false, // 关闭时:只跑第1组,不切换
|
||||||
|
groupCount: 1, // 开启轮换时:2 或 3(你 UI 里限制了)
|
||||||
|
|
||||||
|
accountGroups: [
|
||||||
|
{ name: '第1组', accounts: [{ email: '', pwd: '' }] },
|
||||||
|
],
|
||||||
|
|
||||||
|
aiReply: true,
|
||||||
|
sendInviteFirst: false,
|
||||||
|
sleepTime: 0,
|
||||||
|
inviteThreshold: 3,
|
||||||
|
switchHours: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
// 不轮换:只显示第1组
|
||||||
|
if (!runForm.rotateEnabled) return runForm.accountGroups.slice(0, 1)
|
||||||
|
// 轮换:显示前 groupCount 组
|
||||||
|
return runForm.accountGroups.slice(0, runForm.groupCount || 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 运行状态(轮换引擎使用)
|
||||||
|
* ========================= */
|
||||||
|
//“加载中”标记
|
||||||
|
const isHydrating = ref(true)
|
||||||
|
// 是否处于运行中(用于控制开始/停止按钮、轮换检测)
|
||||||
|
const running = ref(false)
|
||||||
|
|
||||||
|
// 当前正在运行的组下标(0/1/2)
|
||||||
|
const currentGroupIndex = ref(0)
|
||||||
|
|
||||||
|
// 上一次切组的时间戳(ms)
|
||||||
|
const lastSwitchAt = ref(0)
|
||||||
|
|
||||||
|
// 轮换检测 timer:每分钟检查一次是否到达切换时间
|
||||||
|
const switchCheckTimer = ref(null)
|
||||||
|
|
||||||
|
// 可选:记录 run 接口返回/错误(目前未展示)
|
||||||
|
const runResult = ref('')
|
||||||
|
|
||||||
|
// 切换锁:防止切换过程中重复触发切换
|
||||||
|
let switchingLock = false
|
||||||
|
|
||||||
|
// 停止时 loading 实例
|
||||||
|
let stopLoading = null
|
||||||
|
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 配置持久化:读取/保存
|
||||||
|
* ========================= */
|
||||||
|
function loadRunConfig() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RUN_CONFIG_CACHE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const cfg = JSON.parse(raw)
|
||||||
|
|
||||||
|
// 兼容旧版:cfg.account -> 第1组
|
||||||
|
if (Array.isArray(cfg.account) && !cfg.accountGroups) {
|
||||||
|
const normalized = cfg.account
|
||||||
|
.filter(item => item && typeof item.email === 'string' && typeof item.pwd === 'string')
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
if (normalized.length) {
|
||||||
|
runForm.accountGroups[0].accounts.splice(
|
||||||
|
0,
|
||||||
|
runForm.accountGroups[0].accounts.length,
|
||||||
|
...normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新版:accountGroups
|
||||||
|
if (Array.isArray(cfg.accountGroups)) {
|
||||||
|
const groups = cfg.accountGroups.slice(0, 3).map((g, idx) => ({
|
||||||
|
name: g?.name || `第${idx + 1}组`,
|
||||||
|
accounts: Array.isArray(g?.accounts) && g.accounts.length
|
||||||
|
? g.accounts
|
||||||
|
.filter(a => a && typeof a.email === 'string' && typeof a.pwd === 'string')
|
||||||
|
.slice(0, 3)
|
||||||
|
: [{ email: '', pwd: '' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
while (groups.length < 3) {
|
||||||
|
groups.push({ name: `第${groups.length + 1}组`, accounts: [{ email: '', pwd: '' }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
runForm.accountGroups.splice(0, runForm.accountGroups.length, ...groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof cfg.aiReply === 'boolean') runForm.aiReply = cfg.aiReply
|
||||||
|
if (typeof cfg.sendInviteFirst === 'boolean') runForm.sendInviteFirst = cfg.sendInviteFirst
|
||||||
|
if (typeof cfg.sleepTime === 'number') runForm.sleepTime = cfg.sleepTime
|
||||||
|
if (typeof cfg.inviteThreshold === 'number') runForm.inviteThreshold = cfg.inviteThreshold
|
||||||
|
if (typeof cfg.switchHours === 'number' && cfg.switchHours >= 1) runForm.switchHours = cfg.switchHours
|
||||||
|
if (typeof cfg.rotateEnabled === 'boolean') runForm.rotateEnabled = cfg.rotateEnabled
|
||||||
|
if (typeof cfg.groupCount === 'number') runForm.groupCount = Math.min(3, Math.max(1, cfg.groupCount))
|
||||||
|
ensureGroups(runForm.groupCount)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载运行配置失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRunConfig() {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
accountGroups: runForm.accountGroups.map(g => ({
|
||||||
|
name: g.name || '',
|
||||||
|
accounts: (g.accounts || []).slice(0, 3).map(a => ({
|
||||||
|
email: a.email || '',
|
||||||
|
pwd: a.pwd || '',
|
||||||
|
}))
|
||||||
|
})).slice(0, 3),
|
||||||
|
aiReply: runForm.aiReply,
|
||||||
|
sendInviteFirst: runForm.sendInviteFirst,
|
||||||
|
sleepTime: runForm.sleepTime,
|
||||||
|
inviteThreshold: runForm.inviteThreshold,
|
||||||
|
switchHours: runForm.switchHours,
|
||||||
|
rotateEnabled: runForm.rotateEnabled,
|
||||||
|
groupCount: runForm.groupCount,
|
||||||
|
}
|
||||||
|
localStorage.setItem(RUN_CONFIG_CACHE_KEY, JSON.stringify(payload))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存运行配置失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 根据 groupCount 自动维护账号组数量(1~3)
|
||||||
|
* - 不足则补组(默认每组1个空账号输入框)
|
||||||
|
* - 多余则裁剪
|
||||||
|
* - 当前运行组越界时重置为 0
|
||||||
|
*/
|
||||||
|
function ensureGroups(count) {
|
||||||
|
const c = Math.min(3, Math.max(1, Number(count) || 1))
|
||||||
|
|
||||||
|
// 补足
|
||||||
|
while (runForm.accountGroups.length < c) {
|
||||||
|
const idx = runForm.accountGroups.length
|
||||||
|
runForm.accountGroups.push({
|
||||||
|
name: `第${idx + 1}组`,
|
||||||
|
accounts: [{ email: '', pwd: '' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裁剪
|
||||||
|
if (runForm.accountGroups.length > c) {
|
||||||
|
runForm.accountGroups.splice(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止当前组越界
|
||||||
|
if (currentGroupIndex.value >= c) currentGroupIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupCount 变化:调整组数量并保存
|
||||||
|
watch(
|
||||||
|
() => runForm.groupCount,
|
||||||
|
(val) => {
|
||||||
|
ensureGroups(val)
|
||||||
|
if (isHydrating.value) return
|
||||||
|
saveRunConfig()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任一配置变化:自动保存到 localStorage(✅补上 rotateEnabled/groupCount)
|
||||||
|
watch(
|
||||||
|
() => ({
|
||||||
|
accountGroups: runForm.accountGroups,
|
||||||
|
aiReply: runForm.aiReply,
|
||||||
|
sendInviteFirst: runForm.sendInviteFirst,
|
||||||
|
sleepTime: runForm.sleepTime,
|
||||||
|
inviteThreshold: runForm.inviteThreshold,
|
||||||
|
switchHours: runForm.switchHours,
|
||||||
|
rotateEnabled: runForm.rotateEnabled,
|
||||||
|
groupCount: runForm.groupCount,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
if (isHydrating.value) return
|
||||||
|
saveRunConfig()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => runForm.rotateEnabled,
|
||||||
|
(enabled) => {
|
||||||
|
if (!enabled) {
|
||||||
|
runForm.groupCount = 1
|
||||||
|
ensureGroups(1) // 只保留第1组
|
||||||
|
stopSwitchTimer() // 双保险:确保不会有轮换timer
|
||||||
|
} else {
|
||||||
|
// 开启轮换时,至少2组(你UI只有2/3)
|
||||||
|
if (runForm.groupCount < 2) runForm.groupCount = 2
|
||||||
|
ensureGroups(runForm.groupCount)
|
||||||
|
}
|
||||||
|
if (isHydrating.value) return
|
||||||
|
saveRunConfig()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 生命周期
|
||||||
|
* ========================= */
|
||||||
|
onMounted(async () => {
|
||||||
|
loadRunConfig()
|
||||||
|
isHydrating.value = false
|
||||||
|
// MQ 链接
|
||||||
|
window.electronAPI.startMq(userdata.tenantId, userdata.id)
|
||||||
|
|
||||||
|
// 心跳
|
||||||
|
getListTimer.value = setInterval(() => {
|
||||||
|
health().catch(() => stop())
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
// 检查 AI 人设
|
||||||
|
if (!(await isAiConfig())) {
|
||||||
|
showMyInfo.value = true
|
||||||
|
aiConfigured.value = false
|
||||||
|
} else {
|
||||||
|
aiConfigured.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (getListTimer.value) clearInterval(getListTimer.value)
|
||||||
|
getListTimer.value = null
|
||||||
|
stopSwitchTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 业务:AI配置/退出
|
||||||
|
* ========================= */
|
||||||
|
function onSave(payload) {
|
||||||
|
aiConfig(payload).then(() => {
|
||||||
|
aiConfigured.value = true
|
||||||
|
ElMessage.success('AI 人设配置已保存')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAiConfig() {
|
||||||
|
const res = await window.electronAPI.fileExists()
|
||||||
|
return res.exists
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
if (getListTimer.value) clearInterval(getListTimer.value)
|
||||||
|
getListTimer.value = null
|
||||||
|
await logout({ userId: userdata.id, tenantId: userdata.tenantId })
|
||||||
|
} finally {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 账号组增删
|
||||||
|
* ========================= */
|
||||||
|
function addAccount(groupIndex) {
|
||||||
|
const group = runForm.accountGroups[groupIndex]
|
||||||
|
if (!group) return
|
||||||
|
if (group.accounts.length >= 3) return
|
||||||
|
group.accounts.push({ email: '', pwd: '' })
|
||||||
|
saveRunConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAccount(groupIndex, accountIndex) {
|
||||||
|
const group = runForm.accountGroups[groupIndex]
|
||||||
|
if (!group) return
|
||||||
|
if (group.accounts.length === 1) return
|
||||||
|
group.accounts.splice(accountIndex, 1)
|
||||||
|
saveRunConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 核心:按组运行
|
||||||
|
* ========================= */
|
||||||
|
async function runGroup(groupIndex, strings, needTranslate) {
|
||||||
|
const group = runForm.accountGroups[groupIndex]
|
||||||
|
if (!group) return false
|
||||||
|
|
||||||
|
const validAccounts = (group.accounts || []).filter(a => a.email && a.pwd)
|
||||||
|
if (!validAccounts.length) {
|
||||||
|
ElMessage.error(`第${groupIndex + 1}组没有可用账号(邮箱和密码不能为空)`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = runForm.sendInviteFirst ? [] : (strings || [])
|
||||||
|
const payload = {
|
||||||
|
account: validAccounts,
|
||||||
|
aiReply: runForm.aiReply,
|
||||||
|
sendInviteFirst: runForm.sendInviteFirst,
|
||||||
|
prologueList: list,
|
||||||
|
needTranslate,
|
||||||
|
sleepTime: runForm.sleepTime,
|
||||||
|
inviteThreshold: runForm.inviteThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(payload)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动轮换检测定时器:每分钟检查一次是否到达切换点
|
||||||
|
* 注意:不用 setTimeout(1小时) 是为了减少计时漂移影响
|
||||||
|
*/
|
||||||
|
function startSwitchTimer(strings, needTranslate) {
|
||||||
|
stopSwitchTimer()
|
||||||
|
lastSwitchAt.value = Date.now()
|
||||||
|
|
||||||
|
switchCheckTimer.value = setInterval(async () => {
|
||||||
|
if (!running.value) return
|
||||||
|
|
||||||
|
const intervalMs = (runForm.switchHours || 1) * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 到达切换点 -> 切到下一组
|
||||||
|
if (now - lastSwitchAt.value >= intervalMs) {
|
||||||
|
await switchToNextGroup(strings, needTranslate)
|
||||||
|
}
|
||||||
|
}, 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止轮换检测定时器 */
|
||||||
|
function stopSwitchTimer() {
|
||||||
|
if (switchCheckTimer.value) {
|
||||||
|
clearInterval(switchCheckTimer.value)
|
||||||
|
switchCheckTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到下一组:
|
||||||
|
* 1) shutdown 当前任务
|
||||||
|
* 2) 按 groupCount 范围内寻找下一组有有效账号的组并 run
|
||||||
|
* 3) 若都无效 -> stop
|
||||||
|
*/
|
||||||
|
async function switchToNextGroup(strings, needTranslate) {
|
||||||
|
if (switchingLock) return
|
||||||
|
switchingLock = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupCount = runForm.rotateEnabled ? (runForm.groupCount || 1) : 1
|
||||||
|
const nextIndex = (currentGroupIndex.value + 1) % groupCount
|
||||||
|
|
||||||
|
// 先停
|
||||||
|
try {
|
||||||
|
await shutdown()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('切换时 shutdown 失败(继续尝试 run 下一组)', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再跑:最多尝试 groupCount 次
|
||||||
|
for (let i = 0; i < groupCount; i++) {
|
||||||
|
const idx = (nextIndex + i) % groupCount
|
||||||
|
const ok = await runGroup(idx, strings, needTranslate)
|
||||||
|
if (ok) {
|
||||||
|
currentGroupIndex.value = idx
|
||||||
|
lastSwitchAt.value = Date.now()
|
||||||
|
ElMessage.success(`已切换到第 ${idx + 1} 组`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error('所有账号组都没有可用账号,已停止轮换')
|
||||||
|
await stop()
|
||||||
|
} finally {
|
||||||
|
switchingLock = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动运行:
|
||||||
|
* - 默认从第1组开始
|
||||||
|
* - 如果开启轮换:启动轮换检测 timer
|
||||||
|
* - 如果不轮换:确保轮换 timer 关闭
|
||||||
|
*/
|
||||||
|
async function startRotation(strings, needTranslate) {
|
||||||
|
runResult.value = ''
|
||||||
|
currentGroupIndex.value = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await runGroup(currentGroupIndex.value, strings, needTranslate)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
running.value = true
|
||||||
|
showtransDlg.value = false
|
||||||
|
|
||||||
|
if (runForm.rotateEnabled) {
|
||||||
|
startSwitchTimer(strings, needTranslate)
|
||||||
|
ElMessage.success(`启动成功:轮换开启(第 ${currentGroupIndex.value + 1} 组运行中)`)
|
||||||
|
} else {
|
||||||
|
stopSwitchTimer()
|
||||||
|
ElMessage.success(`启动成功:不轮换(第1组持续运行)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
runResult.value = String(err)
|
||||||
|
ElMessage.error('启动失败')
|
||||||
|
running.value = false
|
||||||
|
stopSwitchTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRun() {
|
||||||
|
if (runForm.sendInviteFirst) {
|
||||||
|
startRotation(undefined, false)
|
||||||
|
} else {
|
||||||
|
showtransDlg.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
stopSwitchTimer()
|
||||||
|
switchingLock = false
|
||||||
|
|
||||||
|
stopLoading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '停止中',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await shutdown()
|
||||||
|
running.value = false
|
||||||
|
ElMessage.success('停止成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
running.value = false
|
||||||
|
ElMessage.error('停止失败')
|
||||||
|
} finally {
|
||||||
|
stopLoading?.close?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* 翻译弹窗相关
|
||||||
|
* ========================= */
|
||||||
|
function onConfirm(data) {
|
||||||
|
startRotation(data.strings, data.needTranslate)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doTranslate(sentences, targetLang) {
|
||||||
|
const str = arrayToString(sentences)
|
||||||
|
try {
|
||||||
|
const response = await customTranslation({ msg: str, language: targetLang })
|
||||||
|
|
||||||
|
const raw = response.replace(/^{|}$/g, '')
|
||||||
|
const arr = raw.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
return arr
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayToString(arr) {
|
||||||
|
return arr.filter(Boolean).join(' \n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* UI小工具
|
||||||
|
* ========================= */
|
||||||
|
function printCurrentTime() {
|
||||||
|
return new Date().toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
@import '../static/css/video.less';
|
||||||
|
</style>
|
||||||
182
src/views/test.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero__title">Live Overview Wall</div>
|
||||||
|
<div class="hero__sub">6 identical external panels in a clean, responsive grid</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="n in 6" :key="n" class="card">
|
||||||
|
<div class="card__badge">Panel {{ n }}</div>
|
||||||
|
<div class="frame">
|
||||||
|
<webview :ref="el => setWvRef(el, n)" :src="src" :partition="`persist:tiktok_panel_${n}`"
|
||||||
|
:useragent="userAgent" allowpopups webpreferences="contextIsolation=yes, nodeIntegration=no"
|
||||||
|
style="width:100%;height:100%;border:0;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "TestView",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
src: "https://live-backstage.tiktok.com/portal/overview",
|
||||||
|
|
||||||
|
// ✅ 用一个“正常 Chrome”的 UA(别带 Electron 字样)
|
||||||
|
userAgent:
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||||
|
|
||||||
|
wvMap: new Map(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setWvRef(el, n) {
|
||||||
|
if (el) this.wvMap.set(n, el);
|
||||||
|
},
|
||||||
|
|
||||||
|
bindWebview(wv, idx) {
|
||||||
|
// 1) SSO / window.open:尽量在当前 webview 内打开
|
||||||
|
wv.addEventListener("new-window", (e) => {
|
||||||
|
try {
|
||||||
|
if (e?.url) wv.loadURL(e.url);
|
||||||
|
} catch { }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) 有些站点会走 will-navigate(跳转链路)
|
||||||
|
wv.addEventListener("will-navigate", (e) => {
|
||||||
|
// 这里一般不拦截,主要是为了日志
|
||||||
|
console.log(`[webview ${idx}] will-navigate ->`, e?.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) 打印 webview 内的 console(便于定位)
|
||||||
|
wv.addEventListener("console-message", (e) => {
|
||||||
|
// e.level: 0=log,1=warn,2=error,3=info
|
||||||
|
if (e.level === 2) {
|
||||||
|
console.warn(`[webview ${idx}] console error:`, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) 关键:加载失败日志(你现在的 ERR_BLOCKED_BY_RESPONSE 就能看到)
|
||||||
|
wv.addEventListener("did-fail-load", (e) => {
|
||||||
|
console.warn(
|
||||||
|
`[webview ${idx}] did-fail-load`,
|
||||||
|
"code=", e?.errorCode,
|
||||||
|
"desc=", e?.errorDescription,
|
||||||
|
"url=", e?.validatedURL
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) 只打开第一个面板 DevTools(避免 6 个一起开)
|
||||||
|
if (idx === 1) {
|
||||||
|
wv.addEventListener("dom-ready", () => {
|
||||||
|
try {
|
||||||
|
wv.openDevTools();
|
||||||
|
} catch { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
// 等 webview refs 收集完后绑定事件
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
const wv = this.wvMap.get(i);
|
||||||
|
if (wv) this.bindWebview(wv, i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px 24px 40px;
|
||||||
|
color: #1b1b1b;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 500px at 10% -10%, #f1f7ff 0%, rgba(241, 247, 255, 0) 60%),
|
||||||
|
radial-gradient(800px 400px at 95% 0%, #fff0e6 0%, rgba(255, 240, 230, 0) 55%),
|
||||||
|
#f7f3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5c5c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 24px rgba(35, 25, 15, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5a3b1f;
|
||||||
|
background: rgba(255, 226, 189, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
height: 44vh;
|
||||||
|
min-height: 280px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #efe6dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 你原来写的是 iframe,这里改成 webview */
|
||||||
|
.frame webview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page {
|
||||||
|
padding: 18px 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
height: 52vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
vue.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { defineConfig } = require('@vue/cli-service');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
devServer: {
|
||||||
|
// client: {
|
||||||
|
// overlay: false, // 重点:新版写法
|
||||||
|
|
||||||
|
// },
|
||||||
|
historyApiFallback: true,
|
||||||
|
},
|
||||||
|
transpileDependencies: true,
|
||||||
|
publicPath: './', // 必须是相对路径,否则打包后图片路径会出错
|
||||||
|
css: {
|
||||||
|
loaderOptions: {
|
||||||
|
postcss: {
|
||||||
|
postcssOptions: {
|
||||||
|
plugins: [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
less: {
|
||||||
|
additionalData: `@import "@/static/css/app.less";` // 注入全局变量文件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productionSourceMap: false
|
||||||
|
});
|
||||||