Compare commits
23 Commits
shihezi
...
75c9ea0c3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c9ea0c3c | |||
| e514536d1b | |||
|
|
ed53ca187f | ||
| 42dafbf7ef | |||
| 1aa1cc9d4a | |||
| f75d607576 | |||
| e05ccf555a | |||
| 7f720eb2ad | |||
| 8494210a5d | |||
| ffcf34fb10 | |||
| 8f1dbc28f7 | |||
| c9420b28e9 | |||
| 73a6692998 | |||
| 5d0b9c5a3a | |||
| 8dea3294a5 | |||
| 577ec92ac8 | |||
|
|
79fb997640 | ||
|
|
8cce64b005 | ||
|
|
1bacbe4936 | ||
|
|
02fef1700b | ||
|
|
99cb6b4710 | ||
|
|
1c05aa1f5b | ||
| 009b4840ad |
53
App.vue
53
App.vue
@@ -6,25 +6,35 @@ import useUserStore from './stores/useUserStore';
|
|||||||
import usePageAnimation from './hook/usePageAnimation';
|
import usePageAnimation from './hook/usePageAnimation';
|
||||||
import useDictStore from './stores/useDictStore';
|
import useDictStore from './stores/useDictStore';
|
||||||
import { GlobalInactivityManager } from '@/utils/GlobalInactivityManager';
|
import { GlobalInactivityManager } from '@/utils/GlobalInactivityManager';
|
||||||
const { $api, navTo, appendScriptTagElement, aes_Decrypt, sm2_Decrypt, safeReLaunch } = inject('globalFunction');
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
const {
|
||||||
|
$api,
|
||||||
|
navTo,
|
||||||
|
appendScriptTagElement,
|
||||||
|
aes_Decrypt,
|
||||||
|
sm2_Decrypt,
|
||||||
|
safeReLaunch,
|
||||||
|
isY9MachineType,
|
||||||
|
isAsdMachineType,
|
||||||
|
} = inject('globalFunction');
|
||||||
import config from '@/config.js';
|
import config from '@/config.js';
|
||||||
import baseDB from '@/utils/db.js';
|
import baseDB from '@/utils/db.js';
|
||||||
import { $confirm } from '@/utils/modal.js';
|
import { $confirm } from '@/utils/modal.js';
|
||||||
import useLocationStore from '@/stores/useLocationStore';
|
import useLocationStore from '@/stores/useLocationStore';
|
||||||
usePageAnimation();
|
|
||||||
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
|
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
|
||||||
let uQRListen = null;
|
let uQRListen = null;
|
||||||
let inactivityManager = null;
|
let inactivityManager = null;
|
||||||
let inactivityModalTimer = null;
|
let inactivityModalTimer = null;
|
||||||
|
|
||||||
|
usePageAnimation();
|
||||||
onLaunch((options) => {
|
onLaunch((options) => {
|
||||||
useDictStore().getDictData();
|
useDictStore().getDictData();
|
||||||
if (lightAppJssdk.user) {
|
if (isAsdMachineType()) {
|
||||||
console.warn('爱山东环境');
|
console.warn('爱山东环境');
|
||||||
getUserInfo();
|
getUserInfo();
|
||||||
useUserStore().changMiniProgramAppStatus(false);
|
useUserStore().changMiniProgramAppStatus(false);
|
||||||
useUserStore().changMachineEnv(false);
|
useUserStore().changMachineEnv(false);
|
||||||
useLocationStore().getLocationLoop()//循环获取定位
|
useLocationStore().getLocationLoop(); //循环获取定位
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isY9MachineType()) {
|
if (isY9MachineType()) {
|
||||||
@@ -34,17 +44,19 @@ onLaunch((options) => {
|
|||||||
useUserStore().changMiniProgramAppStatus(true);
|
useUserStore().changMiniProgramAppStatus(true);
|
||||||
useUserStore().changMachineEnv(true);
|
useUserStore().changMachineEnv(true);
|
||||||
(function loop() {
|
(function loop() {
|
||||||
console.log('📍一体机尝试获取定位')
|
console.log('📍一体机尝试获取定位');
|
||||||
useLocationStore().getLocation().then(({longitude,latitude})=>{
|
useLocationStore()
|
||||||
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`)
|
.getLocation()
|
||||||
})
|
.then(({ longitude, latitude }) => {
|
||||||
.catch(err=>{
|
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`);
|
||||||
console.log('❌一体机获取定位失败,30s后尝试重新获取')
|
})
|
||||||
setTimeout(() => {
|
.catch((err) => {
|
||||||
loop()
|
console.log('❌一体机获取定位失败,30s后尝试重新获取');
|
||||||
}, 3000);
|
setTimeout(() => {
|
||||||
})
|
loop();
|
||||||
})()
|
}, 3000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
uQRListen = new IncreaseRevie();
|
uQRListen = new IncreaseRevie();
|
||||||
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
|
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
|
||||||
inactivityManager.start();
|
inactivityManager.start();
|
||||||
@@ -52,7 +64,7 @@ onLaunch((options) => {
|
|||||||
}
|
}
|
||||||
// 正式上线去除此方法
|
// 正式上线去除此方法
|
||||||
console.warn('浏览器环境');
|
console.warn('浏览器环境');
|
||||||
useLocationStore().getLocationLoop()//循环获取定位
|
useLocationStore().getLocationLoop(); //循环获取定位
|
||||||
useUserStore().changMiniProgramAppStatus(true);
|
useUserStore().changMiniProgramAppStatus(true);
|
||||||
useUserStore().changMachineEnv(false);
|
useUserStore().changMachineEnv(false);
|
||||||
useUserStore().initSeesionId(); //更新
|
useUserStore().initSeesionId(); //更新
|
||||||
@@ -70,7 +82,6 @@ onLaunch((options) => {
|
|||||||
|
|
||||||
onMounted(() => {});
|
onMounted(() => {});
|
||||||
|
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
console.log('App Show');
|
console.log('App Show');
|
||||||
});
|
});
|
||||||
@@ -88,6 +99,7 @@ function handleInactivity() {
|
|||||||
|
|
||||||
if (useUserStore().hasLogin) {
|
if (useUserStore().hasLogin) {
|
||||||
// 1. 正常弹出确认框
|
// 1. 正常弹出确认框
|
||||||
|
playTextDirectly('长时间无操作,是否继续使用?');
|
||||||
$confirm({
|
$confirm({
|
||||||
title: '会话即将过期',
|
title: '会话即将过期',
|
||||||
content: '长时间无操作,是否继续使用?',
|
content: '长时间无操作,是否继续使用?',
|
||||||
@@ -133,13 +145,6 @@ function performLogout() {
|
|||||||
inactivityManager?.resume(); // 恢复监听
|
inactivityManager?.resume(); // 恢复监听
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一体机环境判断
|
|
||||||
function isY9MachineType() {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
const isY9Machine = /Y9-ZYYH/i.test(ua); // 匹配机器型号
|
|
||||||
return isY9Machine;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 爱山东环境登录
|
// 爱山东环境登录
|
||||||
function getUserInfo() {
|
function getUserInfo() {
|
||||||
lightAppJssdk.user.getUserInfoWithEncryptedParamByAppId({
|
lightAppJssdk.user.getUserInfoWithEncryptedParamByAppId({
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
$api
|
$api,
|
||||||
|
safeReLaunch
|
||||||
} from "./globalFunction";
|
} from "./globalFunction";
|
||||||
import baseDB from '@/utils/db.js';
|
import baseDB from '@/utils/db.js';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
|
import {
|
||||||
|
playTextDirectly
|
||||||
|
} from '@/hook/useTTSPlayer-all-in-one'
|
||||||
|
|
||||||
export class IncreaseRevie {
|
export class IncreaseRevie {
|
||||||
constructor(arg) {
|
constructor(arg) {
|
||||||
@@ -43,23 +47,27 @@ export class IncreaseRevie {
|
|||||||
|
|
||||||
async handleDebouncedCallback(res) {
|
async handleDebouncedCallback(res) {
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const code = res.data.qrQode
|
const code = res.data.qrCode
|
||||||
console.log('二维码code', code);
|
if (/^\d{6}$/.test(String(code))) {
|
||||||
// 把code给到后端,后端拿code兑换用户信息,给前端返回token进行登录
|
// 把code给到后端,后端拿code兑换用户信息,给前端返回token进行登录
|
||||||
// 一体机用户需要清空indexDB
|
// 一体机用户需要清空indexDB
|
||||||
// useUserStore()
|
$api.createRequest(`/app/qrcodeLogin/${code}`, {}, 'get').then((resData) => {
|
||||||
// .loginSetToken(resData.token)
|
useUserStore()
|
||||||
// .then((resume) => {
|
.loginSetToken(resData.token)
|
||||||
// if (resume.data.jobTitleId) {
|
.then((resume) => {
|
||||||
// useUserStore().initSeesionId();
|
playTextDirectly('登录成功')
|
||||||
// safeReLaunch('/pages/index/index');
|
if (resume.data.jobTitleId) {
|
||||||
// } else {
|
useUserStore().initSeesionId();
|
||||||
// safeReLaunch('/pages/login/login');
|
safeReLaunch('/pages/index/index');
|
||||||
// }
|
} else {
|
||||||
// });
|
safeReLaunch('/pages/login/login');
|
||||||
// baseDB.resetAndReinit(); // 清空indexdb
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$api.msg('识别失败')
|
$api.msg('识别失败')
|
||||||
|
playTextDirectly('识别失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,6 +634,21 @@ export function reloadBrowser() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一体机环境判断
|
||||||
|
export function isY9MachineType() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const isY9Machine = /Y9-ZYYH/i.test(ua); // 匹配机器型号
|
||||||
|
return isY9Machine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一体机环境判断
|
||||||
|
export function isAsdMachineType() {
|
||||||
|
// const ua = navigator.userAgent;
|
||||||
|
// const isY9Machine = /asd_hanweb/i.test(ua); // 匹配机器型号
|
||||||
|
// return isY9Machine;
|
||||||
|
return !!lightAppJssdk.user
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const $api = {
|
export const $api = {
|
||||||
msg,
|
msg,
|
||||||
@@ -684,5 +699,7 @@ export default {
|
|||||||
sm2_Decrypt,
|
sm2_Decrypt,
|
||||||
sm2_Encrypt,
|
sm2_Encrypt,
|
||||||
safeReLaunch,
|
safeReLaunch,
|
||||||
reloadBrowser
|
reloadBrowser,
|
||||||
|
isAsdMachineType,
|
||||||
|
isY9MachineType
|
||||||
}
|
}
|
||||||
@@ -42,3 +42,27 @@ uni-modal .uni-modal__ft{
|
|||||||
font-size: 36rpx !important;
|
font-size: 36rpx !important;
|
||||||
line-height: 80rpx !important;
|
line-height: 80rpx !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uni-popup-dialog{
|
||||||
|
width: 420rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-title{
|
||||||
|
padding-top: 40rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-title-text{
|
||||||
|
font-size: 34rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-content{
|
||||||
|
padding: 25rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-content-text{
|
||||||
|
font-size: 30rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-button{
|
||||||
|
height: 80rpx !important;
|
||||||
|
}
|
||||||
|
.uni-dialog-button-text{
|
||||||
|
font-size: 36rpx !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ onMounted(() => {
|
|||||||
state.resolve = options.resolve;
|
state.resolve = options.resolve;
|
||||||
popup.value.open();
|
popup.value.open();
|
||||||
});
|
});
|
||||||
|
uni.$on('hide-global-popup',()=>{
|
||||||
|
popup.value.close()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
uni.$off('show-global-popup');
|
uni.$off('show-global-popup');
|
||||||
|
uni.$off('hide-global-popup');
|
||||||
});
|
});
|
||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
longitude: {
|
longitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120.382665,
|
default: 120.366085,
|
||||||
},
|
},
|
||||||
latitude: {
|
latitude: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 36.066938,
|
default: 36.086656,
|
||||||
},
|
},
|
||||||
seeDate: {
|
seeDate: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { useReadMsg } from '@/stores/useReadMsg';
|
|||||||
import useScreenStore from '@/stores/useScreenStore'
|
import useScreenStore from '@/stores/useScreenStore'
|
||||||
|
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
const { isMachineEnv, hasLogin } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
const screenStore = useScreenStore()
|
const screenStore = useScreenStore()
|
||||||
const {isWideScreen} = screenStore
|
const {isWideScreen} = screenStore
|
||||||
@@ -95,7 +95,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const changeItem = (item) => {
|
const changeItem = (item) => {
|
||||||
if(isMachineEnv.value && item.needLogin){
|
if(isMachineEnv.value && item.needLogin && !hasLogin.value){
|
||||||
useUserStore().logOut()
|
useUserStore().logOut()
|
||||||
}else{
|
}else{
|
||||||
uni.switchTab({
|
uni.switchTab({
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ export default {
|
|||||||
// baseUrl: 'http://192.168.3.29:8081',
|
// baseUrl: 'http://192.168.3.29:8081',
|
||||||
// baseUrl: 'http://10.213.6.207:19010/api',
|
// baseUrl: 'http://10.213.6.207:19010/api',
|
||||||
// 语音转文字
|
// 语音转文字
|
||||||
vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/app/asr/connect', // 自定义
|
// vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/app/asr/connect', // 测试
|
||||||
// vioceBaseURl: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/asr/connect', // 内网
|
vioceBaseURl: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/asr/connect', // 正式
|
||||||
// 语音合成
|
// 语音合成
|
||||||
// speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis',
|
// speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis', // 测试
|
||||||
// speechSynthesis2: 'wss://resource.zhuoson.com/synthesis/', //直接替换即可
|
speechSynthesis: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/tts/connect', // 正式
|
||||||
speechSynthesis2: 'http://39.98.44.136:19527', //直接替换即可
|
|
||||||
// indexedDB
|
// indexedDB
|
||||||
DBversion: 3,
|
DBversion: 3,
|
||||||
// 只使用本地缓寸的数据
|
// 只使用本地缓寸的数据
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ class PiperTTS {
|
|||||||
this.isRecording = true;
|
this.isRecording = true;
|
||||||
this.onStart();
|
this.onStart();
|
||||||
|
|
||||||
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/synthesize';
|
const wsUrl = this.baseUrl.replace(/^http/, 'ws');
|
||||||
this.worker.postMessage({
|
this.worker.postMessage({
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* PiperTTS SDK - 兼容移动端的流式语音合成客户端
|
|
||||||
* 特性:
|
|
||||||
* 1. Web Audio API 实时调度,解决移动端不支持 MSE 的问题
|
|
||||||
* 2. 头部注入 (Header Injection) 技术,解决分片解码错误
|
|
||||||
* 3. 自动状态管理与事件回调
|
|
||||||
*/
|
|
||||||
export class PiperTTS {
|
|
||||||
constructor(config = {}) {
|
|
||||||
this.baseUrl = config.baseUrl || 'http://localhost:5001';
|
|
||||||
this.wsUrl = config.wsUrl || '/ws/synthesize'
|
|
||||||
this.audioCtx = config.audioCtx || new(window.AudioContext || window.webkitAudioContext)();
|
|
||||||
this.onStatus = config.onStatus || ((msg, type) => console.log(`[Piper] ${msg}`));
|
|
||||||
this.onStart = config.onStart || (() => {});
|
|
||||||
this.onEnd = config.onEnd || (() => {});
|
|
||||||
|
|
||||||
|
|
||||||
// 内部状态
|
|
||||||
this.ws = null;
|
|
||||||
this.nextTime = 0; // 下一段音频的预定播放时间
|
|
||||||
this.audioHeader = null; // 保存WAV/MP3头部
|
|
||||||
this.chunkQueue = []; // 数据缓冲队列
|
|
||||||
this.queueSize = 0; // 当前缓冲区字节数
|
|
||||||
this.analyser = null; // 可视化分析器节点
|
|
||||||
|
|
||||||
// 配置参数
|
|
||||||
this.flushThreshold = 8 * 1024; // 8KB 阈值
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [重要] 初始化音频引擎
|
|
||||||
* 必须在用户点击事件(click/touch)中调用一次,否则手机上没声音
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
if (this.audioCtx.state === 'suspended') {
|
|
||||||
await this.audioCtx.resume();
|
|
||||||
this.onStatus('音频引擎已激活', 'success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定可视化分析器
|
|
||||||
* @param {AnalyserNode} analyserNode - Web Audio Analyser节点
|
|
||||||
*/
|
|
||||||
attachVisualizer(analyserNode) {
|
|
||||||
this.analyser = analyserNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始合成并播放
|
|
||||||
* @param {string} text - 要合成的文本
|
|
||||||
* @param {object} options - 可选参数 {speaker_id, noise_scale, etc.}
|
|
||||||
*/
|
|
||||||
speak(text, options = {}) {
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
this.stop(); // 清理上一次播放
|
|
||||||
this.onStatus('正在建立连接...', 'processing');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + this.wsUrl;
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.onStatus('连接成功,请求生成...', 'processing');
|
|
||||||
// 初始化时间轴:当前时间 + 缓冲延迟
|
|
||||||
this.nextTime = this.audioCtx.currentTime + 0.1;
|
|
||||||
this.onStart();
|
|
||||||
|
|
||||||
this.ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
text: text,
|
|
||||||
speaker_id: options.speakerId || null,
|
|
||||||
length_scale: options.lengthScale || 1.0,
|
|
||||||
noise_scale: options.noiseScale || 0.667,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => this._handleMessage(event);
|
|
||||||
|
|
||||||
this.ws.onclose = async () => {
|
|
||||||
// 处理剩余残余数据
|
|
||||||
if (this.chunkQueue.length > 0) {
|
|
||||||
await this._processQueue(true);
|
|
||||||
}
|
|
||||||
this.onStatus('播放结束', 'success');
|
|
||||||
this.onEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
console.error(err);
|
|
||||||
this.onStatus('连接发生错误', 'error');
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
this.onStatus(`启动失败: ${e.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止播放并重置状态
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
// 重置缓冲
|
|
||||||
this.chunkQueue = [];
|
|
||||||
this.queueSize = 0;
|
|
||||||
this.audioHeader = null;
|
|
||||||
// 注意:Web Audio API 很难"立即停止"已经在 flight 中的 node,
|
|
||||||
// 除非我们追踪所有的 sourceNode 并调用 .stop()。
|
|
||||||
// 简单实现:suspend 再 resume 或者关闭 context (不推荐频繁关闭)。
|
|
||||||
// 这里的 stop 主要停止数据接收。
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 内部私有方法 ---
|
|
||||||
|
|
||||||
async _handleMessage(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
|
|
||||||
const chunk = event.data;
|
|
||||||
|
|
||||||
// 1. 捕获头部 (Header Injection 核心)
|
|
||||||
if (!this.audioHeader) {
|
|
||||||
// 截取前100字节作为通用头
|
|
||||||
this.audioHeader = chunk.slice(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 入队
|
|
||||||
this.chunkQueue.push(chunk);
|
|
||||||
this.queueSize += chunk.byteLength;
|
|
||||||
|
|
||||||
// 3. 达到阈值则解码播放
|
|
||||||
if (this.queueSize >= this.flushThreshold) {
|
|
||||||
await this._processQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _processQueue(isLast = false) {
|
|
||||||
if (this.chunkQueue.length === 0) return;
|
|
||||||
|
|
||||||
// 1. 合并 Buffer
|
|
||||||
const rawData = new Uint8Array(this.queueSize);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of this.chunkQueue) {
|
|
||||||
rawData.set(new Uint8Array(chunk), offset);
|
|
||||||
offset += chunk.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空队列
|
|
||||||
this.chunkQueue = [];
|
|
||||||
this.queueSize = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. 构造带头部的 Buffer
|
|
||||||
let decodeTarget;
|
|
||||||
// 简单的头部检测逻辑,如果没有头,就拼上去
|
|
||||||
if (this.audioHeader && !this._hasHeader(rawData)) {
|
|
||||||
const newBuffer = new Uint8Array(this.audioHeader.byteLength + rawData.byteLength);
|
|
||||||
newBuffer.set(new Uint8Array(this.audioHeader), 0);
|
|
||||||
newBuffer.set(rawData, this.audioHeader.byteLength);
|
|
||||||
decodeTarget = newBuffer.buffer;
|
|
||||||
} else {
|
|
||||||
decodeTarget = rawData.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 解码
|
|
||||||
const decodedBuffer = await this.audioCtx.decodeAudioData(decodeTarget);
|
|
||||||
|
|
||||||
// 4. 播放调度
|
|
||||||
this._scheduleBuffer(decodedBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
// 解码失败处理:如果是中间数据,放回队列头部等待拼接
|
|
||||||
if (!isLast) {
|
|
||||||
this.chunkQueue.unshift(rawData);
|
|
||||||
this.queueSize += rawData.byteLength;
|
|
||||||
} else {
|
|
||||||
console.warn('最后一段数据解码失败,丢弃', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_scheduleBuffer(decodedBuffer) {
|
|
||||||
const source = this.audioCtx.createBufferSource();
|
|
||||||
source.buffer = decodedBuffer;
|
|
||||||
|
|
||||||
// 连接可视化
|
|
||||||
if (this.analyser) {
|
|
||||||
source.connect(this.analyser);
|
|
||||||
this.analyser.connect(this.audioCtx.destination);
|
|
||||||
} else {
|
|
||||||
source.connect(this.audioCtx.destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算播放时间:如果发生卡顿,立即播放;否则无缝衔接
|
|
||||||
const scheduleTime = Math.max(this.audioCtx.currentTime, this.nextTime);
|
|
||||||
source.start(scheduleTime);
|
|
||||||
|
|
||||||
// 更新下一段的开始时间
|
|
||||||
this.nextTime = scheduleTime + decodedBuffer.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
_hasHeader(uint8Arr) {
|
|
||||||
if (uint8Arr.byteLength < 4) return false;
|
|
||||||
// Check "RIFF" (WAV)
|
|
||||||
if (uint8Arr[0] === 82 && uint8Arr[1] === 73 && uint8Arr[2] === 70) return true;
|
|
||||||
// Check "ID3" (MP3)
|
|
||||||
if (uint8Arr[0] === 73 && uint8Arr[1] === 68 && uint8Arr[2] === 51) return true;
|
|
||||||
// Check MP3 Sync Word (Simplify)
|
|
||||||
if (uint8Arr[0] === 0xff && (uint8Arr[1] & 0xe0) === 0xe0) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
726
hook/useAudioSpeak.js
Normal file
726
hook/useAudioSpeak.js
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
// useAudioSpeak.js
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTS语音合成Hook
|
||||||
|
* @param {Object} config - TTS配置
|
||||||
|
* @param {string} config.apiUrl - 语音合成API地址
|
||||||
|
* @param {number} config.maxSegmentLength - 最大分段长度
|
||||||
|
* @returns {Object} TTS相关方法和状态
|
||||||
|
*/
|
||||||
|
export const useAudioSpeak = (config = {}) => {
|
||||||
|
const {
|
||||||
|
apiUrl = 'http://39.98.44.136:19527/synthesize',
|
||||||
|
maxSegmentLength = 30
|
||||||
|
} = config
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isSpeaking = ref(false)
|
||||||
|
const isPaused = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// 播放状态
|
||||||
|
const currentText = ref('')
|
||||||
|
const currentSegmentIndex = ref(0)
|
||||||
|
const totalSegments = ref(0)
|
||||||
|
const progress = ref(0)
|
||||||
|
|
||||||
|
// 音频相关
|
||||||
|
let audioContext = null
|
||||||
|
let audioSource = null
|
||||||
|
let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isMerged?}, ...]
|
||||||
|
let isPlayingQueue = false
|
||||||
|
let isCancelled = false
|
||||||
|
let segments = []
|
||||||
|
let allRequestsCompleted = false
|
||||||
|
let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}]
|
||||||
|
let firstSegmentHeader = null
|
||||||
|
let lastPlayedIndex = -1
|
||||||
|
let currentPlayingIndex = -1 // 当前正在播放的片段索引
|
||||||
|
let isInterrupted = false // 是否被中断
|
||||||
|
|
||||||
|
// 按标点分割文本
|
||||||
|
const splitTextByPunctuation = (text) => {
|
||||||
|
const segments = []
|
||||||
|
const punctuation = /[,。!?;!?;\n]/g
|
||||||
|
let lastIndex = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const match = punctuation.exec(text)
|
||||||
|
if (!match) break
|
||||||
|
|
||||||
|
const isChinesePunctuation = /[,。!?;]/.test(match[0])
|
||||||
|
const endIndex = isChinesePunctuation ? match.index + 1 : match.index
|
||||||
|
|
||||||
|
const segment = text.substring(lastIndex, endIndex)
|
||||||
|
if (segment.trim()) {
|
||||||
|
segments.push(segment.trim())
|
||||||
|
}
|
||||||
|
lastIndex = endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = text.substring(lastIndex)
|
||||||
|
if (lastSegment.trim()) {
|
||||||
|
segments.push(lastSegment.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预处理文本
|
||||||
|
const preprocessText = (text) => {
|
||||||
|
if (!text || typeof text !== 'string') return []
|
||||||
|
|
||||||
|
const cleanText = text.replace(/\s+/g, ' ').trim()
|
||||||
|
let segments = splitTextByPunctuation(cleanText)
|
||||||
|
|
||||||
|
const finalSegments = []
|
||||||
|
segments.forEach(segment => {
|
||||||
|
if (segment.length <= maxSegmentLength) {
|
||||||
|
finalSegments.push(segment)
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < segment.length; i += maxSegmentLength) {
|
||||||
|
finalSegments.push(segment.substring(i, i + maxSegmentLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return finalSegments.filter(seg => seg && seg.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测WAV头部大小
|
||||||
|
const detectWavHeaderSize = (arrayBuffer) => {
|
||||||
|
try {
|
||||||
|
const header = new Uint8Array(arrayBuffer.slice(0, 100))
|
||||||
|
|
||||||
|
if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) {
|
||||||
|
for (let i = 36; i < 60; i++) {
|
||||||
|
if (header[i] === 0x64 && header[i+1] === 0x61 && header[i+2] === 0x74 && header[i+3] === 0x61) {
|
||||||
|
return i + 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 44
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检测WAV头部大小失败:', error)
|
||||||
|
return 44
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求音频片段
|
||||||
|
const fetchAudioSegment = async (text, index) => {
|
||||||
|
try {
|
||||||
|
console.log(`📶正在请求第${index + 1}段音频: "${text}"`)
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: text,
|
||||||
|
speed: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
pitch: 1.0,
|
||||||
|
voice_type: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP错误! 状态码: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBlob = await response.blob()
|
||||||
|
|
||||||
|
if (!audioBlob || audioBlob.size < 100) {
|
||||||
|
throw new Error('音频数据太小或无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`第${index + 1}段音频获取成功,大小: ${audioBlob.size} 字节`)
|
||||||
|
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||||
|
|
||||||
|
// 保存原始数据用于可能的合并
|
||||||
|
pendingMergeSegments.push({
|
||||||
|
arrayBuffer: arrayBuffer,
|
||||||
|
segmentIndex: index
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是第一个片段,保存其WAV头部用于合并
|
||||||
|
if (index === 0) {
|
||||||
|
const headerSize = detectWavHeaderSize(arrayBuffer)
|
||||||
|
firstSegmentHeader = new Uint8Array(arrayBuffer.slice(0, headerSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: audioBlob,
|
||||||
|
segmentIndex: index,
|
||||||
|
text: text,
|
||||||
|
arrayBuffer: arrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取第${index + 1}段音频失败:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化音频上下文
|
||||||
|
const initAudioContext = () => {
|
||||||
|
if (!audioContext || audioContext.state === 'closed') {
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
console.log('音频上下文已初始化')
|
||||||
|
}
|
||||||
|
return audioContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码并播放音频Blob
|
||||||
|
const decodeAndPlayBlob = async (audioBlob, segmentIndex) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (isCancelled || !audioContext) {
|
||||||
|
console.log('播放已取消或音频上下文不存在,跳过播放')
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置当前正在播放的片段索引
|
||||||
|
currentPlayingIndex = segmentIndex
|
||||||
|
console.log(`设置当前播放索引为: ${currentPlayingIndex}`)
|
||||||
|
|
||||||
|
const fileReader = new FileReader()
|
||||||
|
|
||||||
|
fileReader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = e.target.result
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||||
|
|
||||||
|
// 如果在此期间被取消,直接返回
|
||||||
|
if (isCancelled) {
|
||||||
|
console.log('播放过程中被取消')
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioSource = audioContext.createBufferSource()
|
||||||
|
audioSource.buffer = audioBuffer
|
||||||
|
audioSource.connect(audioContext.destination)
|
||||||
|
|
||||||
|
audioSource.onended = () => {
|
||||||
|
console.log(`第${segmentIndex + 1}个片段播放完成`)
|
||||||
|
audioSource = null
|
||||||
|
lastPlayedIndex = segmentIndex
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
audioSource.onerror = (error) => {
|
||||||
|
console.error('音频播放错误:', error)
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`▶️开始播放第${segmentIndex + 1}个片段`)
|
||||||
|
|
||||||
|
// 如果音频上下文被暂停,先恢复
|
||||||
|
if (audioContext.state === 'suspended') {
|
||||||
|
await audioContext.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
audioSource.start(0)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解码或播放音频失败:', error)
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileReader.onerror = (error) => {
|
||||||
|
console.error('读取音频文件失败:', error)
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileReader.readAsArrayBuffer(audioBlob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并剩余音频片段
|
||||||
|
const mergeRemainingSegments = (segmentsToMerge) => {
|
||||||
|
if (segmentsToMerge.length === 0 || !firstSegmentHeader) {
|
||||||
|
console.log('没有待合并的片段或缺少头部信息')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 按segmentIndex排序
|
||||||
|
segmentsToMerge.sort((a, b) => a.segmentIndex - b.segmentIndex)
|
||||||
|
|
||||||
|
console.log(`开始合并${segmentsToMerge.length}个剩余片段`)
|
||||||
|
|
||||||
|
// 计算总数据大小
|
||||||
|
let totalAudioDataSize = 0
|
||||||
|
for (const segment of segmentsToMerge) {
|
||||||
|
const headerSize = detectWavHeaderSize(segment.arrayBuffer)
|
||||||
|
totalAudioDataSize += segment.arrayBuffer.byteLength - headerSize
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`剩余音频数据总大小: ${totalAudioDataSize}字节`)
|
||||||
|
|
||||||
|
// 创建合并后的数组
|
||||||
|
const headerSize = firstSegmentHeader.length
|
||||||
|
const totalSize = headerSize + totalAudioDataSize
|
||||||
|
const mergedArray = new Uint8Array(totalSize)
|
||||||
|
|
||||||
|
// 设置头部
|
||||||
|
mergedArray.set(firstSegmentHeader, 0)
|
||||||
|
|
||||||
|
// 更新头部中的data大小
|
||||||
|
const view = new DataView(mergedArray.buffer)
|
||||||
|
view.setUint32(40, totalAudioDataSize, true)
|
||||||
|
view.setUint32(4, 36 + totalAudioDataSize, true)
|
||||||
|
|
||||||
|
// 合并所有音频数据
|
||||||
|
let offset = headerSize
|
||||||
|
for (const segment of segmentsToMerge) {
|
||||||
|
const segmentHeaderSize = detectWavHeaderSize(segment.arrayBuffer)
|
||||||
|
const segmentData = new Uint8Array(segment.arrayBuffer.slice(segmentHeaderSize))
|
||||||
|
mergedArray.set(segmentData, offset)
|
||||||
|
offset += segmentData.length
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`音频合并完成,总大小: ${mergedArray.length}字节`)
|
||||||
|
|
||||||
|
// 创建Blob
|
||||||
|
return new Blob([mergedArray], { type: 'audio/wav' })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('合并音频片段失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从队列播放音频
|
||||||
|
const playFromQueue = async () => {
|
||||||
|
if (isPlayingQueue || audioQueue.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlayingQueue = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
|
||||||
|
const audioItem = audioQueue[0]
|
||||||
|
currentSegmentIndex.value = audioItem.segmentIndex
|
||||||
|
|
||||||
|
// 播放第一个音频
|
||||||
|
console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`)
|
||||||
|
await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex)
|
||||||
|
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放被中断,退出播放队列')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放完成后移除
|
||||||
|
audioQueue.shift()
|
||||||
|
console.log(`片段${audioItem.segmentIndex + 1}播放完成,队列剩余: ${audioQueue.length}`)
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
progress.value = Math.floor(((audioItem.segmentIndex + 1) / totalSegments.value) * 100)
|
||||||
|
|
||||||
|
// 短暂延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否所有片段都播放完成
|
||||||
|
if (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) {
|
||||||
|
console.log('所有音频片段播放完成')
|
||||||
|
progress.value = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放队列出错:', error)
|
||||||
|
} finally {
|
||||||
|
isPlayingQueue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向队列添加音频
|
||||||
|
const addToQueue = (audioItem) => {
|
||||||
|
// 如果被取消或中断,不添加到队列
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放已中断,不添加到队列')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按segmentIndex插入到正确位置
|
||||||
|
let insertIndex = 0
|
||||||
|
for (let i = audioQueue.length - 1; i >= 0; i--) {
|
||||||
|
if (audioQueue[i].segmentIndex < audioItem.segmentIndex) {
|
||||||
|
insertIndex = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioQueue.splice(insertIndex, 0, audioItem)
|
||||||
|
console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列,队列长度: ${audioQueue.length}`)
|
||||||
|
|
||||||
|
// 如果队列中有音频且当前没有在播放,开始播放
|
||||||
|
if (!isPlayingQueue && audioQueue.length === 1) {
|
||||||
|
playFromQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试合并剩余片段
|
||||||
|
const tryMergeRemainingSegments = () => {
|
||||||
|
if (!allRequestsCompleted) {
|
||||||
|
console.log('合并检查: 请求未完成,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取真正未播放的片段(不包括已经播放的和正在播放的)
|
||||||
|
const trulyUnplayedSegments = audioQueue.filter(item => {
|
||||||
|
// 排除已经播放完成的
|
||||||
|
if (item.segmentIndex <= lastPlayedIndex) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 排除当前正在播放的
|
||||||
|
if (currentPlayingIndex !== -1 && item.segmentIndex === currentPlayingIndex) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldMerge = trulyUnplayedSegments.length > 1
|
||||||
|
|
||||||
|
console.log(`🔀合并检查: 已播放到=${lastPlayedIndex}, 正在播放=${currentPlayingIndex}, 真正未播放片段=${trulyUnplayedSegments.length}, 应该合并=${shouldMerge}`)
|
||||||
|
|
||||||
|
if (!shouldMerge) {
|
||||||
|
console.log('不符合合并条件(真正未播放片段数量 <= 1)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✔️符合合并条件,开始合并剩余片段')
|
||||||
|
|
||||||
|
// 获取这些片段的原始数据
|
||||||
|
const segmentsToMergeData = []
|
||||||
|
for (const item of trulyUnplayedSegments) {
|
||||||
|
const segmentData = pendingMergeSegments.find(s => s.segmentIndex === item.segmentIndex)
|
||||||
|
if (segmentData) {
|
||||||
|
segmentsToMergeData.push(segmentData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentsToMergeData.length === 0) {
|
||||||
|
console.log('没有找到待合并的原始数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并这些片段
|
||||||
|
const mergedBlob = mergeRemainingSegments(segmentsToMergeData)
|
||||||
|
|
||||||
|
if (mergedBlob) {
|
||||||
|
// 从audioQueue中移除这些将被合并的片段
|
||||||
|
const segmentIndicesToRemove = trulyUnplayedSegments.map(s => s.segmentIndex)
|
||||||
|
|
||||||
|
for (let i = audioQueue.length - 1; i >= 0; i--) {
|
||||||
|
if (segmentIndicesToRemove.includes(audioQueue[i].segmentIndex)) {
|
||||||
|
audioQueue.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将合并后的音频添加到队列的合适位置
|
||||||
|
const firstSegmentIndex = Math.min(...segmentIndicesToRemove)
|
||||||
|
const mergedText = trulyUnplayedSegments.map(s => s.text).join(' ')
|
||||||
|
|
||||||
|
const mergedAudioItem = {
|
||||||
|
blob: mergedBlob,
|
||||||
|
segmentIndex: firstSegmentIndex,
|
||||||
|
text: mergedText,
|
||||||
|
isMerged: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入到正确位置(按segmentIndex)
|
||||||
|
let insertIndex = 0
|
||||||
|
for (let i = audioQueue.length - 1; i >= 0; i--) {
|
||||||
|
if (audioQueue[i].segmentIndex < firstSegmentIndex) {
|
||||||
|
insertIndex = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioQueue.splice(insertIndex, 0, mergedAudioItem)
|
||||||
|
console.log(`合并后的音频已添加到队列位置${insertIndex},包含${trulyUnplayedSegments.length}个原始片段,队列长度: ${audioQueue.length}`)
|
||||||
|
} else {
|
||||||
|
console.log('合并失败,保持原始片段')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本提取工具函数
|
||||||
|
function extractSpeechText(markdown) {
|
||||||
|
if (!markdown || markdown.indexOf('job-json') === -1) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||||
|
const jobs = [];
|
||||||
|
let match;
|
||||||
|
let lastJobEndIndex = 0;
|
||||||
|
let firstJobStartIndex = -1;
|
||||||
|
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||||
|
const jobStr = match[1];
|
||||||
|
try {
|
||||||
|
const job = JSON.parse(jobStr);
|
||||||
|
jobs.push(job);
|
||||||
|
if (firstJobStartIndex === -1) {
|
||||||
|
firstJobStartIndex = match.index;
|
||||||
|
}
|
||||||
|
lastJobEndIndex = jobRegex.lastIndex;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('JSON 解析失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const guideText = firstJobStartIndex > 0 ?
|
||||||
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
||||||
|
const endingText = lastJobEndIndex < markdown.length ?
|
||||||
|
markdown.slice(lastJobEndIndex).trim() : '';
|
||||||
|
const jobTexts = jobs.map((job, index) => {
|
||||||
|
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||||
|
});
|
||||||
|
const finalTextParts = [];
|
||||||
|
if (guideText) finalTextParts.push(guideText);
|
||||||
|
finalTextParts.push(...jobTexts);
|
||||||
|
if (endingText) finalTextParts.push(endingText);
|
||||||
|
return finalTextParts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
const cleanup = () => {
|
||||||
|
console.log('开始清理资源')
|
||||||
|
isCancelled = true
|
||||||
|
isInterrupted = true
|
||||||
|
|
||||||
|
if (audioSource) {
|
||||||
|
try {
|
||||||
|
audioSource.stop()
|
||||||
|
console.log('音频源已停止')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('停止音频源失败:', e)
|
||||||
|
}
|
||||||
|
audioSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioContext && audioContext.state !== 'closed') {
|
||||||
|
audioContext.close()
|
||||||
|
console.log('音频上下文已关闭')
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContext = null
|
||||||
|
audioQueue = []
|
||||||
|
segments = []
|
||||||
|
isPlayingQueue = false
|
||||||
|
allRequestsCompleted = false
|
||||||
|
pendingMergeSegments = []
|
||||||
|
firstSegmentHeader = null
|
||||||
|
lastPlayedIndex = -1
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
|
||||||
|
isSpeaking.value = false
|
||||||
|
isPaused.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
progress.value = 0
|
||||||
|
console.log('资源清理完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止播放
|
||||||
|
const stopAudio = () => {
|
||||||
|
console.log('停止音频播放')
|
||||||
|
isCancelled = true
|
||||||
|
isInterrupted = true
|
||||||
|
|
||||||
|
if (audioSource) {
|
||||||
|
try {
|
||||||
|
audioSource.stop()
|
||||||
|
console.log('音频源已停止')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('停止音频源失败:', e)
|
||||||
|
}
|
||||||
|
audioSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
audioQueue = []
|
||||||
|
isPlayingQueue = false
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
isSpeaking.value = false
|
||||||
|
isPaused.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
|
||||||
|
// 恢复中断标志,为下一次播放准备
|
||||||
|
setTimeout(() => {
|
||||||
|
isCancelled = false
|
||||||
|
isInterrupted = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主speak方法
|
||||||
|
const speak = async (text) => {
|
||||||
|
console.log('开始新的语音播报')
|
||||||
|
|
||||||
|
// 先停止当前播放
|
||||||
|
if (isSpeaking.value || audioQueue.length > 0) {
|
||||||
|
console.log('检测到正在播放,先停止')
|
||||||
|
stopAudio()
|
||||||
|
// 等待一小段时间确保资源清理完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
text = extractSpeechText(text)
|
||||||
|
console.log('开始语音播报:', text)
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
isCancelled = false
|
||||||
|
isInterrupted = false
|
||||||
|
currentText.value = text
|
||||||
|
isLoading.value = true
|
||||||
|
isSpeaking.value = true
|
||||||
|
progress.value = 0
|
||||||
|
audioQueue = []
|
||||||
|
allRequestsCompleted = false
|
||||||
|
pendingMergeSegments = []
|
||||||
|
firstSegmentHeader = null
|
||||||
|
lastPlayedIndex = -1
|
||||||
|
currentPlayingIndex = -1
|
||||||
|
|
||||||
|
// 预处理文本
|
||||||
|
segments = preprocessText(text)
|
||||||
|
console.log('文本分段结果:', segments)
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
console.warn('没有有效的文本可以播报')
|
||||||
|
isLoading.value = false
|
||||||
|
isSpeaking.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSegments.value = segments.length
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 初始化音频上下文
|
||||||
|
initAudioContext()
|
||||||
|
|
||||||
|
// 1. 串行请求所有音频片段
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放被取消或中断,停止请求')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`串行请求第${i + 1}/${segments.length}个片段`)
|
||||||
|
|
||||||
|
// 更新进度(请求进度)
|
||||||
|
progress.value = Math.floor(((i + 1) / segments.length) * 50)
|
||||||
|
|
||||||
|
// 请求音频片段
|
||||||
|
const audioItem = await fetchAudioSegment(segments[i], i)
|
||||||
|
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放被取消或中断,停止添加队列')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到播放队列
|
||||||
|
addToQueue({
|
||||||
|
blob: audioItem.blob,
|
||||||
|
segmentIndex: i,
|
||||||
|
text: segments[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是第一个片段,取消loading状态
|
||||||
|
if (i === 0 && isLoading.value) {
|
||||||
|
console.log('第一个音频片段已就绪,开始播放')
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放被取消或中断,退出播放')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 所有请求完成
|
||||||
|
console.log('所有音频片段请求完成')
|
||||||
|
allRequestsCompleted = true
|
||||||
|
|
||||||
|
// 3. 立即检查是否可以合并剩余片段
|
||||||
|
tryMergeRemainingSegments()
|
||||||
|
|
||||||
|
// 4. 等待所有音频播放完成
|
||||||
|
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('音频播放完成')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('语音播报失败:', error)
|
||||||
|
} finally {
|
||||||
|
// 最终清理
|
||||||
|
if (isCancelled || isInterrupted) {
|
||||||
|
console.log('播放被取消或中断,进行清理')
|
||||||
|
cleanup()
|
||||||
|
} else {
|
||||||
|
console.log('播放正常完成')
|
||||||
|
isSpeaking.value = false
|
||||||
|
isPaused.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
progress.value = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停播放
|
||||||
|
const pause = () => {
|
||||||
|
if (audioContext && isSpeaking.value && !isPaused.value) {
|
||||||
|
audioContext.suspend().then(() => {
|
||||||
|
isPaused.value = true
|
||||||
|
console.log('播放已暂停')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复播放
|
||||||
|
const resume = () => {
|
||||||
|
if (audioContext && isSpeaking.value && isPaused.value) {
|
||||||
|
audioContext.resume().then(() => {
|
||||||
|
isPaused.value = false
|
||||||
|
console.log('播放已恢复')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消音频
|
||||||
|
const cancelAudio = () => {
|
||||||
|
console.log('取消音频播放')
|
||||||
|
stopAudio()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isSpeaking,
|
||||||
|
isPaused,
|
||||||
|
isLoading,
|
||||||
|
currentText,
|
||||||
|
currentSegmentIndex,
|
||||||
|
totalSegments,
|
||||||
|
progress,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
speak,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
cancelAudio,
|
||||||
|
cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,15 +53,25 @@ export function useColumnCount(onChange = () => {}) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
columnCount.value = 2
|
columnCount.value = 2
|
||||||
calcColumn()
|
calcColumn()
|
||||||
// if (process.client) {
|
try {
|
||||||
window.addEventListener('resize', calcColumn)
|
// if (process.client) {
|
||||||
// }
|
window.addEventListener('resize', calcColumn)
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// if (process.client) {
|
try {
|
||||||
window.removeEventListener('resize', calcColumn)
|
// if (process.client) {
|
||||||
// }
|
window.removeEventListener('resize', calcColumn)
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 列数变化时执行回调
|
// 列数变化时执行回调
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
$api
|
$api
|
||||||
} from '../common/globalFunction'; // 你的请求封装
|
} from '../common/globalFunction';
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
|
|
||||||
// 开源
|
|
||||||
export function useAudioRecorder() {
|
export function useAudioRecorder() {
|
||||||
// --- 状态定义 ---
|
// --- 状态定义 ---
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false)
|
||||||
|
|||||||
21
hook/useTTS.js
Normal file
21
hook/useTTS.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
useTTSPlayer as useWebTTS
|
||||||
|
} from '@/hook/useTTSPlayer-web.js'
|
||||||
|
import {
|
||||||
|
useTTSPlayer as useHardwareTTS
|
||||||
|
} from '@/hook/useTTSPlayer-all-in-one.js'
|
||||||
|
import {
|
||||||
|
isY9MachineType
|
||||||
|
} from '../common/globalFunction';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能 TTS 适配器 Hook
|
||||||
|
* 自动判断环境并返回对应的播放器实现
|
||||||
|
*/
|
||||||
|
export function useTTSPlayer() {
|
||||||
|
if (isY9MachineType()) {
|
||||||
|
return useHardwareTTS()
|
||||||
|
} else {
|
||||||
|
return useWebTTS()
|
||||||
|
}
|
||||||
|
}
|
||||||
274
hook/useTTSPlayer-all-in-one.js
Normal file
274
hook/useTTSPlayer-all-in-one.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import {
|
||||||
|
ref,
|
||||||
|
onUnmounted
|
||||||
|
} from 'vue'
|
||||||
|
import {
|
||||||
|
onHide,
|
||||||
|
onUnload
|
||||||
|
} from '@dcloudio/uni-app'
|
||||||
|
import {
|
||||||
|
isY9MachineType
|
||||||
|
} from '../common/globalFunction';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装 hh.call 通用调用
|
||||||
|
*/
|
||||||
|
const callBridge = (params) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof window !== 'undefined' && window.hh && window.hh.call) {
|
||||||
|
// 打印日志方便调试
|
||||||
|
console.log('[TTS Bridge Send]:', params)
|
||||||
|
window.hh.call("ampeHHCommunication", params, (res) => {
|
||||||
|
console.log('[TTS Bridge Res]:', res)
|
||||||
|
resolve(res)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.warn('当前环境不支持 hh.call,模拟成功')
|
||||||
|
resolve({
|
||||||
|
success: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机 TaskId
|
||||||
|
*/
|
||||||
|
const generateTaskId = () => {
|
||||||
|
return 'task_' + Date.now() + '_' + Math.floor(Math.random() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接播放文本 (静态方法,无需实例化 Hook)
|
||||||
|
* @param {string} text - 需要朗读的文本
|
||||||
|
*/
|
||||||
|
export async function playTextDirectly(text) {
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
if (!isY9MachineType()) return
|
||||||
|
|
||||||
|
const processedText = extractSpeechText(text)
|
||||||
|
if (!processedText) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构造播放参数
|
||||||
|
const params = {
|
||||||
|
"action": "speech",
|
||||||
|
"event": "open",
|
||||||
|
"taskId": generateTaskId(),
|
||||||
|
"params": {
|
||||||
|
"text": processedText,
|
||||||
|
"pitch": 1.0,
|
||||||
|
"rate": 1.0,
|
||||||
|
"speechQueue": 1 // 1表示打断当前播放,立即播放新的
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 直接调用
|
||||||
|
await callBridge(params)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Direct Play Error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一体机 TTS 播放器 Hook (修复版)
|
||||||
|
*/
|
||||||
|
export function useTTSPlayer() {
|
||||||
|
// UI 状态
|
||||||
|
const isSpeaking = ref(false) // 是否正在交互
|
||||||
|
const isPaused = ref(false) // 是否处于暂停状态
|
||||||
|
const isLoading = ref(false) // 是否正在加载
|
||||||
|
|
||||||
|
// 记录最后一次播放的文本
|
||||||
|
const lastText = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 (中断) - 内部使用
|
||||||
|
*/
|
||||||
|
const executeStop = async () => {
|
||||||
|
const params = {
|
||||||
|
"action": "speechStop",
|
||||||
|
"event": "open",
|
||||||
|
"taskId": generateTaskId()
|
||||||
|
}
|
||||||
|
await callBridge(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心朗读方法
|
||||||
|
*/
|
||||||
|
const speak = async (text) => {
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const processedText = extractSpeechText(text)
|
||||||
|
if (!processedText) return
|
||||||
|
|
||||||
|
// 1. 立即更新记忆文本 (确保即使后续失败,resume也能读到新的)
|
||||||
|
lastText.value = processedText
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2.【关键修复】先强制停止上一次播报
|
||||||
|
// 虽然 speechQueue:1 理论上可以打断,但显式停止更稳健
|
||||||
|
await executeStop()
|
||||||
|
|
||||||
|
// 3. 构造播放参数
|
||||||
|
const params = {
|
||||||
|
"action": "speech",
|
||||||
|
"event": "open",
|
||||||
|
"taskId": generateTaskId(),
|
||||||
|
"params": {
|
||||||
|
"text": processedText,
|
||||||
|
"pitch": 1.0,
|
||||||
|
"rate": 1.0,
|
||||||
|
"speechQueue": 1 // 1表示打断
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 发送播放指令
|
||||||
|
await callBridge(params)
|
||||||
|
|
||||||
|
// 5. 更新状态
|
||||||
|
isSpeaking.value = true
|
||||||
|
isPaused.value = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('TTS Speak Error:', e)
|
||||||
|
resetState()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停
|
||||||
|
*/
|
||||||
|
const pause = async () => {
|
||||||
|
if (!isSpeaking.value || isPaused.value) return
|
||||||
|
try {
|
||||||
|
await executeStop()
|
||||||
|
isPaused.value = true
|
||||||
|
// 注意:不要设置 isSpeaking = false,因为逻辑上只是暂停
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Pause failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复 (由于硬件限制,实际上是从头播放当前文本)
|
||||||
|
*/
|
||||||
|
const resume = async () => {
|
||||||
|
if (!isPaused.value) return
|
||||||
|
|
||||||
|
// 如果有缓存的文本,重新触发 speak
|
||||||
|
if (lastText.value) {
|
||||||
|
console.log('[TTS Resume] Re-speaking text:', lastText.value.substring(0, 20) + '...')
|
||||||
|
// 调用 speak 会自动处理 stop 和状态更新
|
||||||
|
await speak(lastText.value)
|
||||||
|
} else {
|
||||||
|
isPaused.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 播放/暂停
|
||||||
|
* 注意:如果是切换了新文章,请直接调用 speak(newText),不要调用 togglePlay
|
||||||
|
*/
|
||||||
|
const togglePlay = () => {
|
||||||
|
// 如果当前是暂停状态,则恢复
|
||||||
|
if (isPaused.value) {
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
// 如果当前正在播放,则暂停
|
||||||
|
else if (isSpeaking.value) {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
// 如果既没播放也没暂停(比如刚进页面),需要业务层调用 speak 启动,这里无法自动推断文本
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止并重置
|
||||||
|
*/
|
||||||
|
const stop = async () => {
|
||||||
|
await executeStop()
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
isSpeaking.value = false
|
||||||
|
isPaused.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 生命周期管理 ===
|
||||||
|
onUnmounted(() => stop())
|
||||||
|
if (typeof onHide === 'function') onHide(() => stop())
|
||||||
|
if (typeof onUnload === 'function') onUnload(() => stop())
|
||||||
|
|
||||||
|
return {
|
||||||
|
speak,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
togglePlay,
|
||||||
|
stop,
|
||||||
|
cancelAudio: stop,
|
||||||
|
isSpeaking,
|
||||||
|
isPaused,
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本提取工具函数 (保持不变)
|
||||||
|
*/
|
||||||
|
function extractSpeechText(markdown) {
|
||||||
|
if (!markdown || typeof markdown !== 'string') return ''; // 增加类型安全检查
|
||||||
|
if (markdown.indexOf('job-json') === -1) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||||
|
const jobs = [];
|
||||||
|
let match;
|
||||||
|
let lastJobEndIndex = 0;
|
||||||
|
let firstJobStartIndex = -1;
|
||||||
|
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||||
|
const jobStr = match[1];
|
||||||
|
try {
|
||||||
|
const job = JSON.parse(jobStr);
|
||||||
|
jobs.push(job);
|
||||||
|
if (firstJobStartIndex === -1) {
|
||||||
|
firstJobStartIndex = match.index;
|
||||||
|
}
|
||||||
|
lastJobEndIndex = jobRegex.lastIndex;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('JSON 解析失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const guideText = firstJobStartIndex > 0 ?
|
||||||
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
||||||
|
const endingText = lastJobEndIndex < markdown.length ?
|
||||||
|
markdown.slice(lastJobEndIndex).trim() : '';
|
||||||
|
const jobTexts = jobs.map((job, index) => {
|
||||||
|
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||||
|
});
|
||||||
|
const finalTextParts = [];
|
||||||
|
if (guideText) finalTextParts.push(guideText);
|
||||||
|
finalTextParts.push(...jobTexts);
|
||||||
|
if (endingText) finalTextParts.push(endingText);
|
||||||
|
return finalTextParts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 使用方法 1
|
||||||
|
// import { useTTSPlayer } from '@/hook/useTTSPlayer-all-in-one'
|
||||||
|
|
||||||
|
// const { speak, stop, isSpeaking } = useTTSPlayer()
|
||||||
|
|
||||||
|
// // 调用
|
||||||
|
// speak('你好,这是一段测试文本')
|
||||||
|
|
||||||
|
// 使用方法 2
|
||||||
|
// import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one'
|
||||||
|
|
||||||
|
// // 直接调用即可,无需实例化
|
||||||
|
// playTextDirectly('直接朗读这段话,不需要处理暂停和UI状态')
|
||||||
@@ -18,12 +18,28 @@ export function useTTSPlayer() {
|
|||||||
// 单例 Piper 实例
|
// 单例 Piper 实例
|
||||||
let piper = null
|
let piper = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 地址 (含 Token)
|
||||||
|
*/
|
||||||
|
const getWsUrl = async () => {
|
||||||
|
let wsUrl = config.speechSynthesis
|
||||||
|
|
||||||
|
// 拼接 Token
|
||||||
|
const token = uni.getStorageSync('token') || '';
|
||||||
|
if (token) {
|
||||||
|
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||||
|
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
return wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建 SDK 实例
|
* 获取或创建 SDK 实例
|
||||||
*/
|
*/
|
||||||
const getPiperInstance = () => {
|
const getPiperInstance = async () => {
|
||||||
if (!piper) {
|
if (!piper) {
|
||||||
let baseUrl = config.speechSynthesis2 || ''
|
|
||||||
|
let baseUrl = await getWsUrl()
|
||||||
baseUrl = baseUrl.replace(/\/$/, '')
|
baseUrl = baseUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
piper = new PiperTTS({
|
piper = new PiperTTS({
|
||||||
@@ -61,7 +77,7 @@ export function useTTSPlayer() {
|
|||||||
const processedText = extractSpeechText(text)
|
const processedText = extractSpeechText(text)
|
||||||
if (!processedText) return
|
if (!processedText) return
|
||||||
|
|
||||||
const instance = getPiperInstance()
|
const instance = await getPiperInstance()
|
||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
2
main.js
2
main.js
@@ -28,7 +28,7 @@ import MyIcons from '@/components/My-icons/my-icons.vue';
|
|||||||
import GlobalPopup from '@/components/GlobalPopup/GlobalPopup.vue'
|
import GlobalPopup from '@/components/GlobalPopup/GlobalPopup.vue'
|
||||||
// import Tabbar from '@/components/tabbar/midell-box.vue'
|
// import Tabbar from '@/components/tabbar/midell-box.vue'
|
||||||
// 自动导入 directives 目录下所有指令
|
// 自动导入 directives 目录下所有指令
|
||||||
console.log(lightAppJssdk)
|
|
||||||
const directives = import.meta.glob('./directives/*.js', {
|
const directives = import.meta.glob('./directives/*.js', {
|
||||||
eager: true
|
eager: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"quickapp" : {},
|
"quickapp" : {},
|
||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "",
|
"appid" : "wxdbdcc6a10153c99b",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false,
|
"urlCheck" : false,
|
||||||
"es6" : true,
|
"es6" : true,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import useUserStore from '@/stores/useUserStore';
|
|||||||
import useLocationStore from '@/stores/useLocationStore';
|
import useLocationStore from '@/stores/useLocationStore';
|
||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
|
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
const pageState = reactive({
|
const pageState = reactive({
|
||||||
@@ -119,11 +120,13 @@ function companyCollection() {
|
|||||||
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
||||||
getCompanyInfo(companyId, zphId);
|
getCompanyInfo(companyId, zphId);
|
||||||
$api.msg('取消收藏成功');
|
$api.msg('取消收藏成功');
|
||||||
|
playTextDirectly('取消收藏成功');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'POST').then((resData) => {
|
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'POST').then((resData) => {
|
||||||
getCompanyInfo(companyId, zphId);
|
getCompanyInfo(companyId, zphId);
|
||||||
$api.msg('收藏成功');
|
$api.msg('收藏成功');
|
||||||
|
playTextDirectly('收藏成功');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -133,11 +136,13 @@ function companyCollection() {
|
|||||||
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'DELETE').then((resData) => {
|
||||||
getCompanyInfo(companyId);
|
getCompanyInfo(companyId);
|
||||||
$api.msg('取消收藏成功');
|
$api.msg('取消收藏成功');
|
||||||
|
playTextDirectly('取消收藏成功');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'POST').then((resData) => {
|
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'POST').then((resData) => {
|
||||||
getCompanyInfo(companyId);
|
getCompanyInfo(companyId);
|
||||||
$api.msg('收藏成功');
|
$api.msg('收藏成功');
|
||||||
|
playTextDirectly('收藏成功');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ import { storeToRefs } from 'pinia';
|
|||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { getUserResume } = useUserStore();
|
const { getUserResume } = useUserStore();
|
||||||
const { userInfo, isMiniProgram ,hasLogin,isMachineEnv} = storeToRefs(useUserStore());
|
const { userInfo, isMiniProgram ,hasLogin,isMachineEnv} = storeToRefs(useUserStore());
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
|
|
||||||
const popup = ref(null);
|
const popup = ref(null);
|
||||||
const selectJobsModel = ref(null);
|
const selectJobsModel = ref(null);
|
||||||
|
|
||||||
@@ -112,12 +115,17 @@ function handleFocus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
|
if(!hasLogin.value){
|
||||||
|
useUserStore().logOut()
|
||||||
|
return
|
||||||
|
}
|
||||||
const { id } = dataItem.value;
|
const { id } = dataItem.value;
|
||||||
let ids = userInfo.value.jobTitleId + `,${id}`;
|
let ids = userInfo.value.jobTitleId + `,${id}`;
|
||||||
const result = dedupeAndCheck(ids);
|
const result = dedupeAndCheck(ids);
|
||||||
if (result.hasDuplicate) {
|
if (result.hasDuplicate) {
|
||||||
popup.value.close();
|
popup.value.close();
|
||||||
$api.msg('期望岗位已重复');
|
$api.msg('期望岗位已重复');
|
||||||
|
playTextDirectly('期望岗位已重复')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
complete({ jobTitleId: result.deduplicated });
|
complete({ jobTitleId: result.deduplicated });
|
||||||
@@ -171,14 +179,17 @@ function handelChangeInpute(e) {
|
|||||||
function handelClickItem(item) {
|
function handelClickItem(item) {
|
||||||
dataItem.value = item;
|
dataItem.value = item;
|
||||||
popup.value.open();
|
popup.value.open();
|
||||||
|
playTextDirectly('确添加该期望岗位吗?')
|
||||||
}
|
}
|
||||||
|
|
||||||
function complete(values) {
|
function complete(values) {
|
||||||
if (!values.jobTitleId.length) {
|
if (!values.jobTitleId.length) {
|
||||||
|
playTextDirectly('至少添加一份期望岗位?')
|
||||||
return $api.msg('至少添加一份期望岗位');
|
return $api.msg('至少添加一份期望岗位');
|
||||||
}
|
}
|
||||||
$api.createRequest('/app/user/resume', values, 'post').then((resData) => {
|
$api.createRequest('/app/user/resume', values, 'post').then((resData) => {
|
||||||
$api.msg('完成');
|
$api.msg('操作成功');
|
||||||
|
playTextDirectly('操作成功')
|
||||||
getUserResume();
|
getUserResume();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ import useLocationStore from '@/stores/useLocationStore';
|
|||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
const title = ref('事业单位');
|
const title = ref('事业单位');
|
||||||
const cardInfo = ref({});
|
const cardInfo = ref({});
|
||||||
@@ -66,6 +69,7 @@ onLoad(() => {
|
|||||||
|
|
||||||
// search
|
// search
|
||||||
function searchCollection(e) {
|
function searchCollection(e) {
|
||||||
|
playTextDirectly('正在为您查找…')
|
||||||
const value = e.detail.value;
|
const value = e.detail.value;
|
||||||
pageState.search.companyName = value;
|
pageState.search.companyName = value;
|
||||||
getDataList('refresh');
|
getDataList('refresh');
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
const fairInfo = ref({});
|
const fairInfo = ref({});
|
||||||
@@ -201,19 +202,20 @@ function expand() {
|
|||||||
isExpanded.value = !isExpanded.value;
|
isExpanded.value = !isExpanded.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消/收藏岗位
|
// 取消/预约招聘会
|
||||||
function applyExhibitors() {
|
function applyExhibitors() {
|
||||||
const fairId = fairInfo.value.zphID;
|
const fairId = fairInfo.value.zphID;
|
||||||
if (fairInfo.value.isCollection) {
|
if (fairInfo.value.isCollection) {
|
||||||
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
|
||||||
getJobFairInfo(fairId);
|
getJobFairInfo(fairId);
|
||||||
$api.msg('取消预约成功');
|
$api.msg('取消预约成功');
|
||||||
|
playTextDirectly('取消预约成功')
|
||||||
});
|
});
|
||||||
$api.msg('已预约成功');
|
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'POST').then((resData) => {
|
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'POST').then((resData) => {
|
||||||
getJobFairInfo(fairId);
|
getJobFairInfo(fairId);
|
||||||
$api.msg('预约成功');
|
$api.msg('预约成功');
|
||||||
|
playTextDirectly('预约成功')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
|||||||
import RadarMap from './component/radarMap.vue';
|
import RadarMap from './component/radarMap.vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
|
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
|
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
|
||||||
@@ -331,10 +332,12 @@ function jobApply() {
|
|||||||
};
|
};
|
||||||
if (jobInfo.value.isApply) {
|
if (jobInfo.value.isApply) {
|
||||||
$api.msg('已经投递过该岗位了~');
|
$api.msg('已经投递过该岗位了~');
|
||||||
|
playTextDirectly('已经投递过该岗位了');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/internal/sendResume`, params, 'POST').then((resData) => {
|
$api.createRequest(`/app/internal/sendResume`, params, 'POST').then((resData) => {
|
||||||
$api.msg('投递成功');
|
$api.msg('投递成功');
|
||||||
|
playTextDirectly('投递成功');
|
||||||
getDetail(jobIdRef.value);
|
getDetail(jobIdRef.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -348,6 +351,7 @@ function jobApply() {
|
|||||||
$api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => {
|
$api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => {
|
||||||
getDetail(jobId);
|
getDetail(jobId);
|
||||||
$api.msg('申请成功');
|
$api.msg('申请成功');
|
||||||
|
playTextDirectly('申请成功');
|
||||||
const jobUrl = jobInfo.value.jobUrl;
|
const jobUrl = jobInfo.value.jobUrl;
|
||||||
return window.open(jobUrl);
|
return window.open(jobUrl);
|
||||||
});
|
});
|
||||||
@@ -364,11 +368,13 @@ function jobCollection() {
|
|||||||
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
||||||
getDetail(jobIdRef.value);
|
getDetail(jobIdRef.value);
|
||||||
$api.msg('取消收藏成功');
|
$api.msg('取消收藏成功');
|
||||||
|
playTextDirectly('取消收藏成功');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'POST').then((resData) => {
|
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'POST').then((resData) => {
|
||||||
getDetail(jobIdRef.value);
|
getDetail(jobIdRef.value);
|
||||||
$api.msg('收藏成功');
|
$api.msg('收藏成功');
|
||||||
|
playTextDirectly('收藏成功');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -378,11 +384,13 @@ function jobCollection() {
|
|||||||
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'DELETE').then((resData) => {
|
||||||
getDetail(jobId);
|
getDetail(jobId);
|
||||||
$api.msg('取消收藏成功');
|
$api.msg('取消收藏成功');
|
||||||
|
playTextDirectly('取消收藏成功');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'POST').then((resData) => {
|
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'POST').then((resData) => {
|
||||||
getDetail(jobId);
|
getDetail(jobId);
|
||||||
$api.msg('收藏成功');
|
$api.msg('收藏成功');
|
||||||
|
playTextDirectly('收藏成功');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,12 @@
|
|||||||
src="/static/icon/stop.png"
|
src="/static/icon/stop.png"
|
||||||
@click="stopMarkdown(msg.displayText, index)"
|
@click="stopMarkdown(msg.displayText, index)"
|
||||||
></image>
|
></image>
|
||||||
|
<image
|
||||||
|
v-else-if="isLoading && speechIndex === index"
|
||||||
|
class="controll-icon mar_le10 btn-light"
|
||||||
|
src="/static/icon/audio-fetching.png"
|
||||||
|
@click="stopMarkdown(msg.displayText, index)"
|
||||||
|
></image>
|
||||||
<image
|
<image
|
||||||
class="controll-icon mar_le10 btn-light"
|
class="controll-icon mar_le10 btn-light"
|
||||||
src="/static/icon/broadcast.png"
|
src="/static/icon/broadcast.png"
|
||||||
@@ -271,9 +277,8 @@ import FileIcon from './fileIcon.vue';
|
|||||||
import FileText from './fileText.vue';
|
import FileText from './fileText.vue';
|
||||||
import useScreenStore from '@/stores/useScreenStore'
|
import useScreenStore from '@/stores/useScreenStore'
|
||||||
const screenStore = useScreenStore();
|
const screenStore = useScreenStore();
|
||||||
// 系统功能hook和阿里云hook
|
|
||||||
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
|
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
|
||||||
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
|
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
|
||||||
// 全局
|
// 全局
|
||||||
const { $api, navTo, throttle } = inject('globalFunction');
|
const { $api, navTo, throttle } = inject('globalFunction');
|
||||||
const emit = defineEmits(['onConfirm']);
|
const emit = defineEmits(['onConfirm']);
|
||||||
@@ -296,7 +301,7 @@ const {
|
|||||||
lastFinalText,
|
lastFinalText,
|
||||||
} = useAudioRecorder();
|
} = useAudioRecorder();
|
||||||
// 语音合成
|
// 语音合成
|
||||||
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
|
const { speak, pause, resume, isSpeaking, isPaused, isLoading, cancelAudio,cleanup } = useAudioSpeak();
|
||||||
|
|
||||||
// state
|
// state
|
||||||
const queries = ref([]);
|
const queries = ref([]);
|
||||||
@@ -349,7 +354,13 @@ onMounted(async () => {
|
|||||||
changeQueries();
|
changeQueries();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
isAudioPermission.value = await requestMicPermission();
|
isAudioPermission.value = await requestMicPermission();
|
||||||
});
|
})
|
||||||
|
|
||||||
|
onUnmounted(()=>{
|
||||||
|
console.log('清理TTS资源')
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const requestMicPermission = async () => {
|
const requestMicPermission = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -723,8 +734,8 @@ function colseFeeBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readMarkdown(value, index) {
|
function readMarkdown(value, index) {
|
||||||
speechIndex.value = index;
|
|
||||||
if (speechIndex.value !== index) {
|
if (speechIndex.value !== index) {
|
||||||
|
speechIndex.value = index;
|
||||||
speak(value);
|
speak(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ const pollingTimer = ref(null);
|
|||||||
const isPolling = ref(false);
|
const isPolling = ref(false);
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(false);
|
||||||
const uuid = ref(null);
|
const uuid = ref(null);
|
||||||
const fileCount = ref(0);
|
|
||||||
const fileList = ref([]);
|
const fileList = ref([]);
|
||||||
const delFiles = ref([]); //本地记录删除的文件
|
const deleting = ref(false);
|
||||||
|
|
||||||
|
const fileCount = computed(() => {
|
||||||
|
return fileList.value.length ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
// 计算加载文本
|
// 计算加载文本
|
||||||
const loadingText = computed(() => ({
|
const loadingText = computed(() => ({
|
||||||
@@ -113,10 +116,19 @@ function preViewImage(file) {
|
|||||||
$api.msg('文件地址丢失');
|
$api.msg('文件地址丢失');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function delFile(file, idx) {
|
async function delFile(file, idx) {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true);
|
||||||
|
} catch (error) {
|
||||||
|
$api.msg(error);
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
fileList.value.splice(idx, 1);
|
fileList.value.splice(idx, 1);
|
||||||
fileCount.value = fileList.value.length
|
if(fileList.value.length == 0){
|
||||||
delFiles.value.push(file.fileUrl);
|
open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
@@ -149,9 +161,7 @@ function handleConfirm() {
|
|||||||
|
|
||||||
// 重置所有状态
|
// 重置所有状态
|
||||||
function resetState() {
|
function resetState() {
|
||||||
delFiles.value = []
|
|
||||||
fileList.value = [];
|
fileList.value = [];
|
||||||
fileCount.value = 0;
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
@@ -179,23 +189,23 @@ async function initQrCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeQrcode() {
|
function makeQrcode() {
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
const host = window.location.host;
|
const host = window.location.host;
|
||||||
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||||
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
||||||
let pathPrefix = '';
|
let pathPrefix = '';
|
||||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||||
pathPrefix = '';
|
pathPrefix = '';
|
||||||
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
||||||
// 外网测试环境
|
// 外网测试环境
|
||||||
pathPrefix = '/app';
|
pathPrefix = '/app';
|
||||||
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
||||||
// 青岛政务网环境
|
// 青岛政务网环境
|
||||||
pathPrefix = '/rgpp-api/all-in-one';
|
pathPrefix = '/rgpp-api/all-in-one';
|
||||||
} else {
|
} else {
|
||||||
pathPrefix = '';
|
pathPrefix = '';
|
||||||
}
|
}
|
||||||
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload&fileCount=${props.leaveFileCount}`;
|
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload&fileCount=${props.leaveFileCount}`;
|
||||||
|
|
||||||
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
|
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
|
||||||
// config.baseUrl + '/app/kiosk/upload'
|
// config.baseUrl + '/app/kiosk/upload'
|
||||||
@@ -204,7 +214,7 @@ function makeQrcode() {
|
|||||||
// config.baseUrl + '/app/kiosk/upload'
|
// config.baseUrl + '/app/kiosk/upload'
|
||||||
// }`;
|
// }`;
|
||||||
console.log(htmlPath);
|
console.log(htmlPath);
|
||||||
console.log('剩余可上传文件数量:',props.leaveFileCount)
|
console.log('剩余可上传文件数量:', props.leaveFileCount);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uQRCode.make({
|
uQRCode.make({
|
||||||
@@ -237,20 +247,16 @@ function startPolling() {
|
|||||||
|
|
||||||
// 轮询检查上传状态
|
// 轮询检查上传状态
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (!isPolling.value || !isVisible.value) return;
|
if (!isPolling.value || !isVisible.value || deleting.value) return;
|
||||||
const { data } = await $api.createRequest('/app/kiosk/list', { sessionId: uuid.value });
|
const { data } = await $api.createRequest('/app/kiosk/list', { sessionId: uuid.value });
|
||||||
// const { data } = await $api.createRequest('/app/kiosk/list',{sessionId:props.sessionId});
|
// const { data } = await $api.createRequest('/app/kiosk/list',{sessionId:props.sessionId});
|
||||||
if (data && data.length) {
|
if (data && data.length) {
|
||||||
// 上传完成,触发事件
|
fileList.value = data;
|
||||||
fileList.value = data.filter((item) => !delFiles.value.includes(item.fileUrl))
|
|
||||||
fileCount.value = fileList.value.length;
|
|
||||||
// emit('onSend', data);
|
|
||||||
}
|
}
|
||||||
if (isPolling.value && isVisible.value) {
|
if (isPolling.value && isVisible.value) {
|
||||||
pollingTimer.value = setTimeout(poll, 2000); // 每2秒轮询一次
|
pollingTimer.value = setTimeout(poll, 2000); // 每2秒轮询一次
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
395
pages/index/components/AIMatch copy.vue
Normal file
395
pages/index/components/AIMatch copy.vue
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<view class="out">
|
||||||
|
<view v-if="loading" class="loading">
|
||||||
|
<view class="semicircle-loader"></view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="container" id="pixi-box" ref="pixiContainerRef"></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tags: {
|
||||||
|
type: Array,
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['tag-click']);
|
||||||
|
|
||||||
|
// DOM Ref
|
||||||
|
const pixiContainerRef = ref(null);
|
||||||
|
|
||||||
|
// PIXI 变量
|
||||||
|
let app = null;
|
||||||
|
let tagsContainer = null;
|
||||||
|
let activeTagInstances = [];
|
||||||
|
|
||||||
|
// 配置数据
|
||||||
|
const tagsConfig = ref([
|
||||||
|
{ bgColor: 0x0069fe, fontColor: 0xffffff, size: 16, opacity: 1.0, angle: 0, radius: 0 },
|
||||||
|
{
|
||||||
|
bgColor: 0x87e2ec,
|
||||||
|
fontColor: 0xffffff,
|
||||||
|
size: 14,
|
||||||
|
opacity: 1,
|
||||||
|
angle: -Math.PI / 2,
|
||||||
|
radius: 68,
|
||||||
|
tailRotation: Math.PI / 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xffebeb,
|
||||||
|
tailColor: 0xffe1e1,
|
||||||
|
fontColor: 0xff6969,
|
||||||
|
size: 11.5,
|
||||||
|
opacity: 1,
|
||||||
|
angle: -Math.PI / 4.2,
|
||||||
|
radius: 125,
|
||||||
|
tailRotation: (3 * Math.PI) / 4.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0x21ea85,
|
||||||
|
fontColor: 0xffffff,
|
||||||
|
size: 15,
|
||||||
|
opacity: 1,
|
||||||
|
angle: -Math.PI / 10,
|
||||||
|
radius: 130,
|
||||||
|
tailRotation: (3 * Math.PI) / 4.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xebf3ff,
|
||||||
|
tailColor: 0xb9d3ff,
|
||||||
|
fontColor: 0x1d71ef,
|
||||||
|
size: 12,
|
||||||
|
opacity: 1,
|
||||||
|
angle: Math.PI / 18,
|
||||||
|
radius: 135,
|
||||||
|
tailRotation: (3 * Math.PI) / 4.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xffd4b6,
|
||||||
|
fontColor: 0xffffff,
|
||||||
|
size: 14,
|
||||||
|
opacity: 1,
|
||||||
|
angle: Math.PI / 4.3,
|
||||||
|
radius: 100,
|
||||||
|
tailRotation: -(3 * Math.PI) / 4.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
bgColor: 0xff9400,
|
||||||
|
fontColor: 0xffffff,
|
||||||
|
size: 14,
|
||||||
|
opacity: 1,
|
||||||
|
angle: (2 * Math.PI) / 3,
|
||||||
|
radius: 92,
|
||||||
|
tailRotation: -Math.PI / 2.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xebf3ff,
|
||||||
|
tailColor: 0xb9d3ff,
|
||||||
|
fontColor: 0x1d71ef,
|
||||||
|
size: 10.5,
|
||||||
|
opacity: 1,
|
||||||
|
angle: (5.4 * Math.PI) / 6,
|
||||||
|
radius: 110,
|
||||||
|
tailRotation: (3 * Math.PI) / 1.79,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xff6969,
|
||||||
|
fontColor: 0xffffff,
|
||||||
|
size: 14,
|
||||||
|
opacity: 1,
|
||||||
|
angle: (6.3 * Math.PI) / 5.8,
|
||||||
|
radius: 120,
|
||||||
|
tailRotation: Math.PI / 2.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bgColor: 0xfce9c9,
|
||||||
|
fontColor: 0xfbc55f,
|
||||||
|
size: 13,
|
||||||
|
opacity: 1,
|
||||||
|
angle: (7.2 * Math.PI) / 5.9,
|
||||||
|
radius: 120,
|
||||||
|
tailRotation: Math.PI / 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.tags,
|
||||||
|
(val) => {
|
||||||
|
if (val && val.length) {
|
||||||
|
tagsConfig.value.map((tag, index) => {
|
||||||
|
// console.log(val[index])
|
||||||
|
tag.name = val[index]?.job_name;
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
initPixi();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (app) {
|
||||||
|
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||||
|
app = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getContainerDOM = () => {
|
||||||
|
const refVal = pixiContainerRef.value;
|
||||||
|
if (!refVal) return document.getElementById('pixi-box');
|
||||||
|
if (refVal.$el) return refVal.$el;
|
||||||
|
return refVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||||
|
|
||||||
|
const initPixi = () => {
|
||||||
|
const container = getContainerDOM();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const width = container.clientWidth || 300;
|
||||||
|
const height = container.clientHeight || 300;
|
||||||
|
|
||||||
|
if (app) return;
|
||||||
|
|
||||||
|
app = new PIXI.Application({
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
backgroundAlpha: 0,
|
||||||
|
backgroundColor: 0xf5f7fa,
|
||||||
|
antialias: true,
|
||||||
|
resolution: window.devicePixelRatio || 1,
|
||||||
|
autoDensity: true,
|
||||||
|
});
|
||||||
|
app.view.style.touchAction = 'auto';
|
||||||
|
|
||||||
|
container.appendChild(app.view);
|
||||||
|
|
||||||
|
tagsContainer = new PIXI.Container();
|
||||||
|
app.stage.addChild(tagsContainer);
|
||||||
|
|
||||||
|
renderScene(width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScene = (sw, sh) => {
|
||||||
|
let ratio = window.innerWidth / 400;
|
||||||
|
if (ratio < 1) ratio = 1;
|
||||||
|
tagsContainer.removeChildren();
|
||||||
|
activeTagInstances = [];
|
||||||
|
|
||||||
|
tagsConfig.value.forEach((data, index) => {
|
||||||
|
const scaledRadius = data.radius * ratio;
|
||||||
|
|
||||||
|
let x = sw / 2 + scaledRadius * Math.cos(data.angle);
|
||||||
|
let y = sh / 2 + scaledRadius * Math.sin(data.angle);
|
||||||
|
|
||||||
|
const tag = createTag(data, index, ratio);
|
||||||
|
|
||||||
|
tagsContainer.addChild(tag);
|
||||||
|
|
||||||
|
const safeW = tag.width / 2 + 10;
|
||||||
|
const safeH = tag.height / 2 + 10;
|
||||||
|
|
||||||
|
// 强制修正 x 和 y,使其不超出屏幕
|
||||||
|
x = clamp(x, safeW, sw - safeW);
|
||||||
|
y = clamp(y, safeH, sh - safeH);
|
||||||
|
|
||||||
|
tag.x = x;
|
||||||
|
tag.y = y;
|
||||||
|
|
||||||
|
// 4. 保存元数据
|
||||||
|
tag.userData = {
|
||||||
|
originalX: x,
|
||||||
|
originalY: y,
|
||||||
|
angle: data.angle,
|
||||||
|
radius: scaledRadius,
|
||||||
|
floatOffset: Math.random() * Math.PI * 2,
|
||||||
|
floatSpeed: 0.01 + Math.random() * 0.02,
|
||||||
|
floatRange: 2 * ratio + Math.random() * 2,
|
||||||
|
safeH: safeH,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.radius > 0) {
|
||||||
|
const tail = createCometTail(
|
||||||
|
data.tailColor || data.bgColor,
|
||||||
|
data.tailRotation,
|
||||||
|
tag.width,
|
||||||
|
tag.height,
|
||||||
|
ratio
|
||||||
|
);
|
||||||
|
tag.addChildAt(tail, 0);
|
||||||
|
tag.updateTail = () => tail.updateAnim();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTagInstances.push(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动画循环
|
||||||
|
app.ticker.add(() => {
|
||||||
|
const screenH = app.screen.height;
|
||||||
|
activeTagInstances.forEach((tag) => {
|
||||||
|
const meta = tag.userData;
|
||||||
|
if (meta) {
|
||||||
|
// 计算新的浮动位置
|
||||||
|
meta.floatOffset += meta.floatSpeed;
|
||||||
|
let nextY = meta.originalY + Math.sin(meta.floatOffset) * meta.floatRange;
|
||||||
|
|
||||||
|
// 再次进行边界检查
|
||||||
|
if (nextY < meta.safeH) nextY = meta.safeH;
|
||||||
|
if (nextY > screenH - meta.safeH) nextY = screenH - meta.safeH;
|
||||||
|
|
||||||
|
tag.y = nextY;
|
||||||
|
|
||||||
|
if (tag.updateTail) tag.updateTail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTag = (tagData, index, ratio) => {
|
||||||
|
if (ratio > 1) ratio = ratio * 0.9;
|
||||||
|
const tagGroup = new PIXI.Container();
|
||||||
|
tagGroup.eventMode = 'static';
|
||||||
|
tagGroup.cursor = 'pointer';
|
||||||
|
|
||||||
|
tagGroup.on('pointertap', () => emit('tag-click', tagData));
|
||||||
|
|
||||||
|
const text = new PIXI.Text(tagData.name, {
|
||||||
|
fontFamily: ['PingFang SC', 'Microsoft YaHei', 'Arial'],
|
||||||
|
fontSize: tagData.size * ratio,
|
||||||
|
fill: tagData.fontColor,
|
||||||
|
padding: 4 * ratio,
|
||||||
|
resolution: 2,
|
||||||
|
});
|
||||||
|
text.anchor.set(0.5);
|
||||||
|
|
||||||
|
const paddingH = 26 * ratio;
|
||||||
|
const paddingV = 10 * ratio;
|
||||||
|
let bgWidth = text.width + paddingH;
|
||||||
|
let bgHeight = text.height + paddingV;
|
||||||
|
|
||||||
|
if (index === 0) bgWidth = Math.max(bgWidth, tagData.size * 4.5);
|
||||||
|
|
||||||
|
const bg = new PIXI.Graphics();
|
||||||
|
bg.beginFill(tagData.bgColor, tagData.opacity ?? 1);
|
||||||
|
bg.drawRoundedRect(-bgWidth / 2, -bgHeight / 2, bgWidth, bgHeight, bgHeight / 2);
|
||||||
|
bg.endFill();
|
||||||
|
|
||||||
|
tagGroup.addChild(bg);
|
||||||
|
tagGroup.addChild(text);
|
||||||
|
|
||||||
|
return tagGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCometTail = (bgColor, tailRotation, parentWidth, parentHeight, ratio) => {
|
||||||
|
if (ratio > 1) ratio = ratio;
|
||||||
|
const tailGroup = new PIXI.Container();
|
||||||
|
const graphics = new PIXI.Graphics();
|
||||||
|
tailGroup.addChild(graphics);
|
||||||
|
|
||||||
|
const baseLength = 45 * ratio;
|
||||||
|
const startWidth = parentHeight + 20 * ratio;
|
||||||
|
const endWidth = parentHeight * 0.4;
|
||||||
|
|
||||||
|
let breathPhase = Math.random() * Math.PI * 2;
|
||||||
|
const breathSpeed = 0.04;
|
||||||
|
|
||||||
|
tailGroup.updateAnim = () => {
|
||||||
|
breathPhase += breathSpeed;
|
||||||
|
const breathScale = 0.85 + 0.15 * Math.sin(breathPhase);
|
||||||
|
graphics.clear();
|
||||||
|
const currentLength = baseLength * breathScale;
|
||||||
|
|
||||||
|
const cos = Math.cos(tailRotation);
|
||||||
|
const sin = Math.sin(tailRotation);
|
||||||
|
const perpX = -sin;
|
||||||
|
const perpY = cos;
|
||||||
|
|
||||||
|
const p1 = { x: perpX * (startWidth / 2), y: perpY * (startWidth / 2) };
|
||||||
|
const p2 = { x: -perpX * (startWidth / 2), y: -perpY * (startWidth / 2) };
|
||||||
|
const endCX = cos * currentLength;
|
||||||
|
const endCY = sin * currentLength;
|
||||||
|
const p3 = { x: endCX - perpX * (endWidth / 2), y: endCY - perpY * (endWidth / 2) };
|
||||||
|
const p4 = { x: endCX + perpX * (endWidth / 2), y: endCY + perpY * (endWidth / 2) };
|
||||||
|
|
||||||
|
const segments = 8;
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
const t1 = i / segments;
|
||||||
|
const t2 = (i + 1) / segments;
|
||||||
|
const alpha = 0.4 * (1 - t1);
|
||||||
|
const sp1 = { x: p1.x + (p4.x - p1.x) * t1, y: p1.y + (p4.y - p1.y) * t1 };
|
||||||
|
const sp2 = { x: p2.x + (p3.x - p2.x) * t1, y: p2.y + (p3.y - p2.y) * t1 };
|
||||||
|
const ep1 = { x: p1.x + (p4.x - p1.x) * t2, y: p1.y + (p4.y - p1.y) * t2 };
|
||||||
|
const ep2 = { x: p2.x + (p3.x - p2.x) * t2, y: p2.y + (p3.y - p2.y) * t2 };
|
||||||
|
graphics.beginFill(bgColor, alpha);
|
||||||
|
graphics.moveTo(sp1.x, sp1.y);
|
||||||
|
graphics.lineTo(sp2.x, sp2.y);
|
||||||
|
graphics.lineTo(ep2.x, ep2.y);
|
||||||
|
graphics.lineTo(ep1.x, ep1.y);
|
||||||
|
graphics.endFill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tailGroup.updateAnim();
|
||||||
|
return tailGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
const container = getContainerDOM();
|
||||||
|
if (!app || !container) return;
|
||||||
|
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||||
|
app = null;
|
||||||
|
initPixi();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.out {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.semicircle-loader {
|
||||||
|
width: 150rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
border: 12rpx solid #f3f3f3;
|
||||||
|
border-top: 12rpx solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #b9d3ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,39 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="out">
|
<view class="out">
|
||||||
<view v-if="loading" class="loading">
|
<view v-if="loading" class="loading">
|
||||||
<view class="semicircle-loader"></view>
|
<view class="semicircle-loader"></view>
|
||||||
</view>
|
|
||||||
<view v-else class="container" id="pixi-box" ref="pixiContainerRef"></view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="container" id="pixi-box" ref="pixiContainerRef"></view>
|
||||||
|
<!-- 通信组件 -->
|
||||||
|
<view style="display: none" :random="random" :change:random="appModule.initPixi" :tagsConfig="tagsConfig" :change:tagsConfig="appModule.setTags" />
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script>
|
||||||
import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue';
|
export default {
|
||||||
|
props: {
|
||||||
const props = defineProps({
|
|
||||||
tags: {
|
tags: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: () => false,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
const emit = defineEmits(['tag-click']);
|
watch: {
|
||||||
|
tags: {
|
||||||
// DOM Ref
|
handler(val) {
|
||||||
const pixiContainerRef = ref(null);
|
if (val && val.length) {
|
||||||
|
this.tagsConfig.map((tag, index) => {
|
||||||
// PIXI 变量
|
tag.name = val[index]?.job_name;
|
||||||
let app = null;
|
});
|
||||||
let tagsContainer = null;
|
setTimeout(() => {
|
||||||
let activeTagInstances = [];
|
this.random++;
|
||||||
|
}, 100);
|
||||||
// 配置数据
|
}
|
||||||
const tagsConfig = ref([
|
},
|
||||||
{ bgColor: 0x0069fe, fontColor: 0xffffff, size: 16, opacity: 1.0, angle: 0, radius: 0 },
|
deep: true,
|
||||||
{
|
},
|
||||||
|
},
|
||||||
|
data: () => ({
|
||||||
|
random: 0,
|
||||||
|
tagsConfig: [
|
||||||
|
{ bgColor: 0x0069fe, fontColor: 0xffffff, size: 16, opacity: 1.0, angle: 0, radius: 0 },
|
||||||
|
{
|
||||||
bgColor: 0x87e2ec,
|
bgColor: 0x87e2ec,
|
||||||
fontColor: 0xffffff,
|
fontColor: 0xffffff,
|
||||||
size: 14,
|
size: 14,
|
||||||
@@ -41,8 +48,8 @@ const tagsConfig = ref([
|
|||||||
angle: -Math.PI / 2,
|
angle: -Math.PI / 2,
|
||||||
radius: 68,
|
radius: 68,
|
||||||
tailRotation: Math.PI / 2,
|
tailRotation: Math.PI / 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xffebeb,
|
bgColor: 0xffebeb,
|
||||||
tailColor: 0xffe1e1,
|
tailColor: 0xffe1e1,
|
||||||
fontColor: 0xff6969,
|
fontColor: 0xff6969,
|
||||||
@@ -51,8 +58,8 @@ const tagsConfig = ref([
|
|||||||
angle: -Math.PI / 4.2,
|
angle: -Math.PI / 4.2,
|
||||||
radius: 125,
|
radius: 125,
|
||||||
tailRotation: (3 * Math.PI) / 4.5,
|
tailRotation: (3 * Math.PI) / 4.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0x21ea85,
|
bgColor: 0x21ea85,
|
||||||
fontColor: 0xffffff,
|
fontColor: 0xffffff,
|
||||||
size: 15,
|
size: 15,
|
||||||
@@ -60,8 +67,8 @@ const tagsConfig = ref([
|
|||||||
angle: -Math.PI / 10,
|
angle: -Math.PI / 10,
|
||||||
radius: 130,
|
radius: 130,
|
||||||
tailRotation: (3 * Math.PI) / 4.2,
|
tailRotation: (3 * Math.PI) / 4.2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xebf3ff,
|
bgColor: 0xebf3ff,
|
||||||
tailColor: 0xb9d3ff,
|
tailColor: 0xb9d3ff,
|
||||||
fontColor: 0x1d71ef,
|
fontColor: 0x1d71ef,
|
||||||
@@ -70,8 +77,8 @@ const tagsConfig = ref([
|
|||||||
angle: Math.PI / 18,
|
angle: Math.PI / 18,
|
||||||
radius: 135,
|
radius: 135,
|
||||||
tailRotation: (3 * Math.PI) / 4.3,
|
tailRotation: (3 * Math.PI) / 4.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xffd4b6,
|
bgColor: 0xffd4b6,
|
||||||
fontColor: 0xffffff,
|
fontColor: 0xffffff,
|
||||||
size: 14,
|
size: 14,
|
||||||
@@ -79,9 +86,8 @@ const tagsConfig = ref([
|
|||||||
angle: Math.PI / 4.3,
|
angle: Math.PI / 4.3,
|
||||||
radius: 100,
|
radius: 100,
|
||||||
tailRotation: -(3 * Math.PI) / 4.5,
|
tailRotation: -(3 * Math.PI) / 4.5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
{
|
|
||||||
bgColor: 0xff9400,
|
bgColor: 0xff9400,
|
||||||
fontColor: 0xffffff,
|
fontColor: 0xffffff,
|
||||||
size: 14,
|
size: 14,
|
||||||
@@ -89,8 +95,8 @@ const tagsConfig = ref([
|
|||||||
angle: (2 * Math.PI) / 3,
|
angle: (2 * Math.PI) / 3,
|
||||||
radius: 92,
|
radius: 92,
|
||||||
tailRotation: -Math.PI / 2.4,
|
tailRotation: -Math.PI / 2.4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xebf3ff,
|
bgColor: 0xebf3ff,
|
||||||
tailColor: 0xb9d3ff,
|
tailColor: 0xb9d3ff,
|
||||||
fontColor: 0x1d71ef,
|
fontColor: 0x1d71ef,
|
||||||
@@ -99,8 +105,8 @@ const tagsConfig = ref([
|
|||||||
angle: (5.4 * Math.PI) / 6,
|
angle: (5.4 * Math.PI) / 6,
|
||||||
radius: 110,
|
radius: 110,
|
||||||
tailRotation: (3 * Math.PI) / 1.79,
|
tailRotation: (3 * Math.PI) / 1.79,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xff6969,
|
bgColor: 0xff6969,
|
||||||
fontColor: 0xffffff,
|
fontColor: 0xffffff,
|
||||||
size: 14,
|
size: 14,
|
||||||
@@ -108,8 +114,8 @@ const tagsConfig = ref([
|
|||||||
angle: (6.3 * Math.PI) / 5.8,
|
angle: (6.3 * Math.PI) / 5.8,
|
||||||
radius: 120,
|
radius: 120,
|
||||||
tailRotation: Math.PI / 2.9,
|
tailRotation: Math.PI / 2.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bgColor: 0xfce9c9,
|
bgColor: 0xfce9c9,
|
||||||
fontColor: 0xfbc55f,
|
fontColor: 0xfbc55f,
|
||||||
size: 13,
|
size: 13,
|
||||||
@@ -117,89 +123,105 @@ const tagsConfig = ref([
|
|||||||
angle: (7.2 * Math.PI) / 5.9,
|
angle: (7.2 * Math.PI) / 5.9,
|
||||||
radius: 120,
|
radius: 120,
|
||||||
tailRotation: Math.PI / 3,
|
tailRotation: Math.PI / 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
methods: {
|
||||||
|
tagClick(tagData) {
|
||||||
|
this.$emit("tag-click", tagData);
|
||||||
},
|
},
|
||||||
]);
|
},
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.tags,
|
|
||||||
(val) => {
|
|
||||||
if (val && val.length) {
|
|
||||||
tagsConfig.value.map((tag, index) => {
|
|
||||||
// console.log(val[index])
|
|
||||||
tag.name = val[index]?.job_name;
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
initPixi();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
if (app) {
|
|
||||||
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
|
||||||
app = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const getContainerDOM = () => {
|
|
||||||
const refVal = pixiContainerRef.value;
|
|
||||||
if (!refVal) return document.getElementById('pixi-box');
|
|
||||||
if (refVal.$el) return refVal.$el;
|
|
||||||
return refVal;
|
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script module="appModule" lang="renderjs">
|
||||||
|
|
||||||
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||||
|
|
||||||
const initPixi = () => {
|
export default {
|
||||||
const container = getContainerDOM();
|
data() {
|
||||||
if (!container) return;
|
return {
|
||||||
|
app:null,
|
||||||
|
finallyTags:[],
|
||||||
|
tagsContainer:null,
|
||||||
|
activeTagInstances:[],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener('resize', this.handleResize);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.app) {
|
||||||
|
this.app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||||
|
this.app = null;
|
||||||
|
}
|
||||||
|
window.removeEventListener("resize", this.handleResize);
|
||||||
|
},
|
||||||
|
methods:{
|
||||||
|
setTags(tags){
|
||||||
|
this.finallyTags = [...tags]
|
||||||
|
console.log(this.finallyTags,'+++++')
|
||||||
|
},
|
||||||
|
getContainerDOM(){
|
||||||
|
return document.getElementById('pixi-box');
|
||||||
|
},
|
||||||
|
initPixi(random){
|
||||||
|
if(!random > 0) return
|
||||||
|
const container = this.getContainerDOM();
|
||||||
|
if (!container) return;
|
||||||
|
const width = container.clientWidth || 300;
|
||||||
|
const height = container.clientHeight || 300;
|
||||||
|
|
||||||
const width = container.clientWidth || 300;
|
if (this.app) return;
|
||||||
const height = container.clientHeight || 300;
|
|
||||||
|
|
||||||
if (app) return;
|
this.app = new PIXI.Application({
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
backgroundAlpha: 0,
|
||||||
|
backgroundColor: 0xf5f7fa,
|
||||||
|
antialias: true,
|
||||||
|
resolution: window.devicePixelRatio || 1,
|
||||||
|
autoDensity: true,
|
||||||
|
});
|
||||||
|
this.app.view.style.touchAction = 'auto';
|
||||||
|
|
||||||
app = new PIXI.Application({
|
container.appendChild(this.app.view);
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
backgroundAlpha: 0,
|
|
||||||
backgroundColor: 0xf5f7fa,
|
|
||||||
antialias: true,
|
|
||||||
resolution: window.devicePixelRatio || 1,
|
|
||||||
autoDensity: true,
|
|
||||||
});
|
|
||||||
app.view.style.touchAction = 'auto';
|
|
||||||
|
|
||||||
container.appendChild(app.view);
|
this.tagsContainer = new PIXI.Container();
|
||||||
|
this.app.stage.addChild(this.tagsContainer);
|
||||||
|
|
||||||
tagsContainer = new PIXI.Container();
|
this.renderScene(width, height);
|
||||||
app.stage.addChild(tagsContainer);
|
},
|
||||||
|
|
||||||
renderScene(width, height);
|
renderScene(sw, sh){
|
||||||
};
|
let ratio = window.innerWidth / 400;
|
||||||
|
if (ratio < 1) ratio = 1;
|
||||||
|
|
||||||
const renderScene = (sw, sh) => {
|
// 先清理旧的标签事件监听器
|
||||||
let ratio = window.innerWidth / 400;
|
if (this.activeTagInstances && this.activeTagInstances.length > 0) {
|
||||||
if (ratio < 1) ratio = 1;
|
this.activeTagInstances.forEach(tag => {
|
||||||
tagsContainer.removeChildren();
|
if (tag) {
|
||||||
activeTagInstances = [];
|
tag.eventMode = 'none';
|
||||||
|
tag.removeAllListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.activeTagInstances = [];
|
||||||
|
}
|
||||||
|
// 确保容器清空
|
||||||
|
if (this.tagsContainer) {
|
||||||
|
this.tagsContainer.removeChildren();
|
||||||
|
}
|
||||||
|
|
||||||
tagsConfig.value.forEach((data, index) => {
|
|
||||||
|
this.finallyTags.forEach((data, index) => {
|
||||||
const scaledRadius = data.radius * ratio;
|
const scaledRadius = data.radius * ratio;
|
||||||
|
|
||||||
let x = sw / 2 + scaledRadius * Math.cos(data.angle);
|
let x = sw / 2 + scaledRadius * Math.cos(data.angle);
|
||||||
let y = sh / 2 + scaledRadius * Math.sin(data.angle);
|
let y = sh / 2 + scaledRadius * Math.sin(data.angle);
|
||||||
|
|
||||||
const tag = createTag(data, index, ratio);
|
const tag = this.createTag(data, index, ratio);
|
||||||
|
|
||||||
tagsContainer.addChild(tag);
|
this.tagsContainer.addChild(tag);
|
||||||
|
|
||||||
const safeW = tag.width / 2 + 10;
|
const safeW = tag.width / 2 + 10;
|
||||||
const safeH = tag.height / 2 + 10;
|
const safeH = tag.height / 2 + 10;
|
||||||
@@ -219,12 +241,12 @@ const renderScene = (sw, sh) => {
|
|||||||
radius: scaledRadius,
|
radius: scaledRadius,
|
||||||
floatOffset: Math.random() * Math.PI * 2,
|
floatOffset: Math.random() * Math.PI * 2,
|
||||||
floatSpeed: 0.01 + Math.random() * 0.02,
|
floatSpeed: 0.01 + Math.random() * 0.02,
|
||||||
floatRange: 2 + Math.random() * 2,
|
floatRange: 2 * ratio + Math.random() * 2,
|
||||||
safeH: safeH,
|
safeH: safeH,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.radius > 0) {
|
if (data.radius > 0) {
|
||||||
const tail = createCometTail(
|
const tail = this.createCometTail(
|
||||||
data.tailColor || data.bgColor,
|
data.tailColor || data.bgColor,
|
||||||
data.tailRotation,
|
data.tailRotation,
|
||||||
tag.width,
|
tag.width,
|
||||||
@@ -235,13 +257,13 @@ const renderScene = (sw, sh) => {
|
|||||||
tag.updateTail = () => tail.updateAnim();
|
tag.updateTail = () => tail.updateAnim();
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTagInstances.push(tag);
|
this.activeTagInstances.push(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 动画循环
|
// 动画循环
|
||||||
app.ticker.add(() => {
|
this.app.ticker.add(() => {
|
||||||
const screenH = app.screen.height;
|
const screenH = this.app.screen.height;
|
||||||
activeTagInstances.forEach((tag) => {
|
this.activeTagInstances.forEach((tag) => {
|
||||||
const meta = tag.userData;
|
const meta = tag.userData;
|
||||||
if (meta) {
|
if (meta) {
|
||||||
// 计算新的浮动位置
|
// 计算新的浮动位置
|
||||||
@@ -258,138 +280,143 @@ const renderScene = (sw, sh) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
|
||||||
const createTag = (tagData, index, ratio) => {
|
createTag(tagData, index, ratio){
|
||||||
if (ratio > 1) ratio = ratio * 0.9;
|
if (ratio > 1) ratio = ratio * 0.9;
|
||||||
const tagGroup = new PIXI.Container();
|
const tagGroup = new PIXI.Container();
|
||||||
tagGroup.eventMode = 'static';
|
tagGroup.cursor = 'pointer';
|
||||||
tagGroup.cursor = 'pointer';
|
tagGroup.eventMode = 'static';
|
||||||
|
|
||||||
tagGroup.on('pointertap', () => emit('tag-click', tagData));
|
tagGroup.on('pointertap', () =>{
|
||||||
|
this.$ownerInstance.callMethod('tagClick', tagData);
|
||||||
|
});
|
||||||
|
|
||||||
const text = new PIXI.Text(tagData.name, {
|
const text = new PIXI.Text(tagData.name, {
|
||||||
fontFamily: ['PingFang SC', 'Microsoft YaHei', 'Arial'],
|
fontFamily: ['PingFang SC', 'Microsoft YaHei', 'Arial'],
|
||||||
fontSize: tagData.size * ratio,
|
fontSize: tagData.size * ratio,
|
||||||
fill: tagData.fontColor,
|
fill: tagData.fontColor,
|
||||||
padding: 4 * ratio,
|
padding: 4 * ratio,
|
||||||
resolution: 2,
|
resolution: 2,
|
||||||
});
|
});
|
||||||
text.anchor.set(0.5);
|
text.anchor.set(0.5);
|
||||||
|
|
||||||
const paddingH = 26 * ratio;
|
const paddingH = 26 * ratio;
|
||||||
const paddingV = 10 * ratio;
|
const paddingV = 10 * ratio;
|
||||||
let bgWidth = text.width + paddingH;
|
let bgWidth = text.width + paddingH;
|
||||||
let bgHeight = text.height + paddingV;
|
let bgHeight = text.height + paddingV;
|
||||||
|
|
||||||
if (index === 0) bgWidth = Math.max(bgWidth, tagData.size * 4.5);
|
if (index === 0) bgWidth = Math.max(bgWidth, tagData.size * 4.5);
|
||||||
|
|
||||||
const bg = new PIXI.Graphics();
|
const bg = new PIXI.Graphics();
|
||||||
bg.beginFill(tagData.bgColor, tagData.opacity ?? 1);
|
bg.beginFill(tagData.bgColor, tagData.opacity ?? 1);
|
||||||
bg.drawRoundedRect(-bgWidth / 2, -bgHeight / 2, bgWidth, bgHeight, bgHeight / 2);
|
bg.drawRoundedRect(-bgWidth / 2, -bgHeight / 2, bgWidth, bgHeight, bgHeight / 2);
|
||||||
bg.endFill();
|
bg.endFill();
|
||||||
|
|
||||||
tagGroup.addChild(bg);
|
tagGroup.addChild(bg);
|
||||||
tagGroup.addChild(text);
|
tagGroup.addChild(text);
|
||||||
|
|
||||||
return tagGroup;
|
return tagGroup;
|
||||||
};
|
},
|
||||||
|
|
||||||
const createCometTail = (bgColor, tailRotation, parentWidth, parentHeight, ratio) => {
|
createCometTail(bgColor, tailRotation, parentWidth, parentHeight, ratio){
|
||||||
if (ratio > 1) ratio = ratio;
|
if (ratio > 1) ratio = ratio;
|
||||||
const tailGroup = new PIXI.Container();
|
const tailGroup = new PIXI.Container();
|
||||||
const graphics = new PIXI.Graphics();
|
const graphics = new PIXI.Graphics();
|
||||||
tailGroup.addChild(graphics);
|
tailGroup.addChild(graphics);
|
||||||
|
|
||||||
const baseLength = 45 * ratio;
|
const baseLength = 45 * ratio;
|
||||||
const startWidth = parentHeight + 20 * ratio;
|
const startWidth = parentHeight + 20 * ratio;
|
||||||
const endWidth = parentHeight * 0.4;
|
const endWidth = parentHeight * 0.4;
|
||||||
|
|
||||||
let breathPhase = Math.random() * Math.PI * 2;
|
let breathPhase = Math.random() * Math.PI * 2;
|
||||||
const breathSpeed = 0.04;
|
const breathSpeed = 0.04;
|
||||||
|
|
||||||
tailGroup.updateAnim = () => {
|
tailGroup.updateAnim = () => {
|
||||||
breathPhase += breathSpeed;
|
breathPhase += breathSpeed;
|
||||||
const breathScale = 0.85 + 0.15 * Math.sin(breathPhase);
|
const breathScale = 0.85 + 0.15 * Math.sin(breathPhase);
|
||||||
graphics.clear();
|
graphics.clear();
|
||||||
const currentLength = baseLength * breathScale;
|
const currentLength = baseLength * breathScale;
|
||||||
|
|
||||||
const cos = Math.cos(tailRotation);
|
const cos = Math.cos(tailRotation);
|
||||||
const sin = Math.sin(tailRotation);
|
const sin = Math.sin(tailRotation);
|
||||||
const perpX = -sin;
|
const perpX = -sin;
|
||||||
const perpY = cos;
|
const perpY = cos;
|
||||||
|
|
||||||
const p1 = { x: perpX * (startWidth / 2), y: perpY * (startWidth / 2) };
|
const p1 = { x: perpX * (startWidth / 2), y: perpY * (startWidth / 2) };
|
||||||
const p2 = { x: -perpX * (startWidth / 2), y: -perpY * (startWidth / 2) };
|
const p2 = { x: -perpX * (startWidth / 2), y: -perpY * (startWidth / 2) };
|
||||||
const endCX = cos * currentLength;
|
const endCX = cos * currentLength;
|
||||||
const endCY = sin * currentLength;
|
const endCY = sin * currentLength;
|
||||||
const p3 = { x: endCX - perpX * (endWidth / 2), y: endCY - perpY * (endWidth / 2) };
|
const p3 = { x: endCX - perpX * (endWidth / 2), y: endCY - perpY * (endWidth / 2) };
|
||||||
const p4 = { x: endCX + perpX * (endWidth / 2), y: endCY + perpY * (endWidth / 2) };
|
const p4 = { x: endCX + perpX * (endWidth / 2), y: endCY + perpY * (endWidth / 2) };
|
||||||
|
|
||||||
const segments = 8;
|
const segments = 8;
|
||||||
for (let i = 0; i < segments; i++) {
|
for (let i = 0; i < segments; i++) {
|
||||||
const t1 = i / segments;
|
const t1 = i / segments;
|
||||||
const t2 = (i + 1) / segments;
|
const t2 = (i + 1) / segments;
|
||||||
const alpha = 0.4 * (1 - t1);
|
const alpha = 0.4 * (1 - t1);
|
||||||
const sp1 = { x: p1.x + (p4.x - p1.x) * t1, y: p1.y + (p4.y - p1.y) * t1 };
|
const sp1 = { x: p1.x + (p4.x - p1.x) * t1, y: p1.y + (p4.y - p1.y) * t1 };
|
||||||
const sp2 = { x: p2.x + (p3.x - p2.x) * t1, y: p2.y + (p3.y - p2.y) * t1 };
|
const sp2 = { x: p2.x + (p3.x - p2.x) * t1, y: p2.y + (p3.y - p2.y) * t1 };
|
||||||
const ep1 = { x: p1.x + (p4.x - p1.x) * t2, y: p1.y + (p4.y - p1.y) * t2 };
|
const ep1 = { x: p1.x + (p4.x - p1.x) * t2, y: p1.y + (p4.y - p1.y) * t2 };
|
||||||
const ep2 = { x: p2.x + (p3.x - p2.x) * t2, y: p2.y + (p3.y - p2.y) * t2 };
|
const ep2 = { x: p2.x + (p3.x - p2.x) * t2, y: p2.y + (p3.y - p2.y) * t2 };
|
||||||
graphics.beginFill(bgColor, alpha);
|
graphics.beginFill(bgColor, alpha);
|
||||||
graphics.moveTo(sp1.x, sp1.y);
|
graphics.moveTo(sp1.x, sp1.y);
|
||||||
graphics.lineTo(sp2.x, sp2.y);
|
graphics.lineTo(sp2.x, sp2.y);
|
||||||
graphics.lineTo(ep2.x, ep2.y);
|
graphics.lineTo(ep2.x, ep2.y);
|
||||||
graphics.lineTo(ep1.x, ep1.y);
|
graphics.lineTo(ep1.x, ep1.y);
|
||||||
graphics.endFill();
|
graphics.endFill();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tailGroup.updateAnim();
|
tailGroup.updateAnim();
|
||||||
return tailGroup;
|
return tailGroup;
|
||||||
};
|
},
|
||||||
|
|
||||||
const handleResize = () => {
|
handleResize(){
|
||||||
const container = getContainerDOM();
|
const container = this.getContainerDOM();
|
||||||
if (!app || !container) return;
|
if (!this.app || !container) return;
|
||||||
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
this.app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||||
app = null;
|
this.app = null;
|
||||||
initPixi();
|
this.initPixi(1);
|
||||||
};
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.out {
|
.out {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.loading {
|
.loading {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.semicircle-loader {
|
.semicircle-loader {
|
||||||
width: 150rpx;
|
width: 150rpx;
|
||||||
height: 150rpx;
|
height: 150rpx;
|
||||||
border: 12rpx solid #f3f3f3;
|
border: 12rpx solid #f3f3f3;
|
||||||
border-top: 12rpx solid #3498db;
|
border-top: 12rpx solid #3498db;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1.2s linear infinite;
|
animation: spin 1.2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #b9d3ff;
|
color: #b9d3ff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -534,6 +534,75 @@ function findJob(job) {
|
|||||||
conditionSearch.value[nameAttr] = val;
|
conditionSearch.value[nameAttr] = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.tabIndex === 'all') {
|
||||||
|
// 调用推荐接口
|
||||||
|
getJobRecommendWithNewSearch(job.index);
|
||||||
|
} else {
|
||||||
|
// 调用普通列表接口
|
||||||
|
getJobListWithNewSearch(job.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 立即查询新的数据替换现有数据
|
||||||
|
function getJobRecommendWithNewSearch(index) {
|
||||||
|
const params = {
|
||||||
|
pageSize: pageState.pageSize,
|
||||||
|
sessionId: useUserStore().seesionId,
|
||||||
|
...pageState.search,
|
||||||
|
...conditionSearch.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
$api.createRequest('/app/job/recommend', params).then((resData) => {
|
||||||
|
const currentHeight = waterfallsFlowRef.value.$el.offsetHeight; // 获取当前高度
|
||||||
|
waterfallsFlowRef.value.$el.style.minHeight = `${currentHeight}px`;
|
||||||
|
const { data } = resData;
|
||||||
|
const newData = dataToImg(data);
|
||||||
|
const sliceIndex = index + 1;
|
||||||
|
list.value.splice(sliceIndex, list.value.length - sliceIndex, ...newData);
|
||||||
|
nextTick(() => {
|
||||||
|
waterfallsFlowRef.value.refresh();
|
||||||
|
setTimeout(() => {
|
||||||
|
waterfallsFlowRef.value.$el.style.minHeight = '';
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
// 更新加载状态
|
||||||
|
updateLoadmoreStatus(newData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即查询新的数据替换现有数据
|
||||||
|
function getJobListWithNewSearch(index) {
|
||||||
|
// 重置分页参数
|
||||||
|
pageState.page = 1;
|
||||||
|
pageState.maxPage = 2;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
current: pageState.page,
|
||||||
|
pageSize: pageState.pageSize,
|
||||||
|
...pageState.search,
|
||||||
|
...conditionSearch.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
$api.createRequest('/app/job/list', params).then((resData) => {
|
||||||
|
const { rows, total } = resData;
|
||||||
|
// 将新数据转换为图片格式
|
||||||
|
const newData = dataToImg(rows);
|
||||||
|
//替换点击项以后的数据
|
||||||
|
list.value.splice(index, list.value.length - index, ...newData);
|
||||||
|
// 更新分页和加载状态
|
||||||
|
pageState.total = total;
|
||||||
|
pageState.maxPage = Math.ceil(total / pageState.pageSize);
|
||||||
|
updateLoadmoreStatus(newData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoadmoreStatus(newData) {
|
||||||
|
if (loadmoreRef.value && typeof loadmoreRef.value.change === 'function') {
|
||||||
|
if (newData.length < pageState.pageSize) {
|
||||||
|
loadmoreRef.value.change('noMore');
|
||||||
|
} else {
|
||||||
|
loadmoreRef.value.change('more');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,13 @@
|
|||||||
v-for="(_, index) in 2"
|
v-for="(_, index) in 2"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<component
|
<IndexRefactor
|
||||||
:is="components[index]"
|
v-if="index === 0"
|
||||||
|
@onShowTabbar="changeShowTabbar"
|
||||||
|
:ref="(el) => handelComponentsRef(el, index)"
|
||||||
|
/>
|
||||||
|
<IndexTwo
|
||||||
|
v-if="index === 1"
|
||||||
@onShowTabbar="changeShowTabbar"
|
@onShowTabbar="changeShowTabbar"
|
||||||
:ref="(el) => handelComponentsRef(el, index)"
|
:ref="(el) => handelComponentsRef(el, index)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="content-sex">
|
<view class="content-sex">
|
||||||
<view class="sex-titile">求职区域</view>
|
<view class="sex-titile">求职者性别</view>
|
||||||
<view class="sext-ri">
|
<view class="sext-ri">
|
||||||
<view
|
<view
|
||||||
class="sext-box"
|
class="sext-box"
|
||||||
@@ -196,11 +196,13 @@ import { reactive, inject, watch, ref, onMounted, onUnmounted } from 'vue';
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
import useDictStore from '@/stores/useDictStore';
|
import useDictStore from '@/stores/useDictStore';
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
const { $api, navTo } = inject('globalFunction');
|
const { $api, navTo } = inject('globalFunction');
|
||||||
const { loginSetToken, getUserResume } = useUserStore();
|
const { loginSetToken, getUserResume } = useUserStore();
|
||||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||||
const { getDictSelectOption, oneDictData } = useDictStore();
|
const { getDictSelectOption, oneDictData } = useDictStore();
|
||||||
const openSelectPopup = inject('openSelectPopup');
|
const openSelectPopup = inject('openSelectPopup');
|
||||||
|
|
||||||
// status
|
// status
|
||||||
const selectJobsModel = ref();
|
const selectJobsModel = ref();
|
||||||
const tabCurrent = ref(1);
|
const tabCurrent = ref(1);
|
||||||
@@ -242,6 +244,7 @@ onMounted(() => {
|
|||||||
if (isMachineEnv) {
|
if (isMachineEnv) {
|
||||||
startCountdown();
|
startCountdown();
|
||||||
startScanAnimation();
|
startScanAnimation();
|
||||||
|
playTextDirectly('请进行用户登录');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -331,8 +334,9 @@ function changeEducation() {
|
|||||||
maskClick: true,
|
maskClick: true,
|
||||||
data: [oneDictData('education')],
|
data: [oneDictData('education')],
|
||||||
success: (_, [value]) => {
|
success: (_, [value]) => {
|
||||||
fromValue.area = value.value;
|
fromValue.education = value.value;
|
||||||
state.educationText = value.label;
|
state.educationText = value.label;
|
||||||
|
console.log()
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -385,6 +389,7 @@ function changeJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nextStep() {
|
function nextStep() {
|
||||||
|
if(!state.experienceText) return $api.msg('请选择工作经验');
|
||||||
tabCurrent.value += 1;
|
tabCurrent.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +458,9 @@ function loginTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function complete() {
|
function complete() {
|
||||||
|
if(!state.areaText) return $api.msg('请选择求职区域');
|
||||||
|
if(!state.jobsText.length) return $api.msg('请选择求职岗位');
|
||||||
|
if(!state.salayText) return $api.msg('请选择期望薪资');
|
||||||
$api.createRequest('/app/user/resume', fromValue, 'post').then((resData) => {
|
$api.createRequest('/app/user/resume', fromValue, 'post').then((resData) => {
|
||||||
$api.msg('完成');
|
$api.msg('完成');
|
||||||
getUserResume();
|
getUserResume();
|
||||||
@@ -928,6 +936,7 @@ function complete() {
|
|||||||
margin-bottom: 52rpx
|
margin-bottom: 52rpx
|
||||||
.sex-titile
|
.sex-titile
|
||||||
line-height: 80rpx;
|
line-height: 80rpx;
|
||||||
|
color: #6A6A6A;
|
||||||
.sext-ri
|
.sext-ri
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ import img from '@/static/icon/filter.png';
|
|||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||||
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
|
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
const historyList = ref([]);
|
const historyList = ref([]);
|
||||||
const listCom = ref([]);
|
const listCom = ref([]);
|
||||||
@@ -188,6 +191,7 @@ function searchBtn() {
|
|||||||
if (!searchValue.value) {
|
if (!searchValue.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
playTextDirectly('正在为您查找岗位')
|
||||||
historyList.value.unshift(searchValue.value);
|
historyList.value.unshift(searchValue.value);
|
||||||
historyList.value = unique(historyList.value);
|
historyList.value = unique(historyList.value);
|
||||||
uni.setStorageSync('searchList', historyList.value);
|
uni.setStorageSync('searchList', historyList.value);
|
||||||
|
|||||||
BIN
static/icon/audio-fetching.png
Normal file
BIN
static/icon/audio-fetching.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -10,14 +10,14 @@ import {
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
const defalutLongLat = {
|
const defalutLongLat = {
|
||||||
longitude: 120.382665,
|
longitude: 120.366085,
|
||||||
latitude: 36.066938,
|
latitude: 36.086656,
|
||||||
}
|
}
|
||||||
|
|
||||||
const useLocationStore = defineStore("location", () => {
|
const useLocationStore = defineStore("location", () => {
|
||||||
// 定义状态
|
// 定义状态
|
||||||
const longitudeVal = ref(null) // 经度
|
const longitudeVal = ref(120.366085) // 经度
|
||||||
const latitudeVal = ref(null) //纬度
|
const latitudeVal = ref(36.086656) //纬度
|
||||||
const timer = ref(null)
|
const timer = ref(null)
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const useLocationStore = defineStore("location", () => {
|
|||||||
fail: function(data) {
|
fail: function(data) {
|
||||||
longitudeVal.value = defalutLongLat.longitude
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = defalutLongLat.latitude
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole(defalutLongLat)
|
reject()
|
||||||
msg('用户位置获取失败')
|
msg('用户位置获取失败')
|
||||||
console.log('失败3', data)
|
console.log('失败3', data)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ const useLocationStore = defineStore("location", () => {
|
|||||||
fail: function(data) {
|
fail: function(data) {
|
||||||
longitudeVal.value = defalutLongLat.longitude
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = defalutLongLat.latitude
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole(defalutLongLat)
|
reject()
|
||||||
msg('用户位置获取失败')
|
msg('用户位置获取失败')
|
||||||
console.log('失败2', data)
|
console.log('失败2', data)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ const useLocationStore = defineStore("location", () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
longitudeVal.value = defalutLongLat.longitude
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = defalutLongLat.latitude
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole(defalutLongLat)
|
reject()
|
||||||
msg('测试环境,使用模拟定位')
|
msg('测试环境,使用模拟定位')
|
||||||
console.log('失败1', e)
|
console.log('失败1', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
ref,
|
ref,
|
||||||
computed
|
computed
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
import wideScreenStyles from '../common/wide-screen.css?inline';
|
import wideScreenStyles from '../common/wide-screen.css?inline';
|
||||||
|
// #endif
|
||||||
|
|
||||||
// 屏幕检测管理器类
|
// 屏幕检测管理器类
|
||||||
class ScreenDetectionManager {
|
class ScreenDetectionManager {
|
||||||
|
|||||||
Reference in New Issue
Block a user