Compare commits
25 Commits
ab3201b015
...
shihezi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49af03f4bb | ||
| 4ae11e31f4 | |||
| bca0d997c6 | |||
|
|
b43eb98a1c | ||
|
|
44c297aac2 | ||
| f64c9e5dae | |||
| 975835baa5 | |||
| 1ac524e1f1 | |||
| 7e8bef0cb9 | |||
|
|
ce597b182d | ||
|
|
fdd5577c85 | ||
|
|
4dfc7bdfd8 | ||
|
|
4befbb05cc | ||
| 74dc6debcd | |||
| 534dfd8126 | |||
| 4c29882f36 | |||
| 4cf75922da | |||
| bb67e9ba12 | |||
|
|
7479176eff | ||
|
|
3fe0a1b9e1 | ||
| 284d696b25 | |||
| 85a24d3346 | |||
| 854bc9c197 | |||
|
|
606b6104bb | ||
|
|
a10fd29440 |
51
App.vue
51
App.vue
@@ -10,6 +10,7 @@ const { $api, navTo, appendScriptTagElement, aes_Decrypt, sm2_Decrypt, safeReLau
|
|||||||
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';
|
||||||
usePageAnimation();
|
usePageAnimation();
|
||||||
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
|
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
|
||||||
let uQRListen = null;
|
let uQRListen = null;
|
||||||
@@ -23,14 +24,27 @@ onLaunch((options) => {
|
|||||||
getUserInfo();
|
getUserInfo();
|
||||||
useUserStore().changMiniProgramAppStatus(false);
|
useUserStore().changMiniProgramAppStatus(false);
|
||||||
useUserStore().changMachineEnv(false);
|
useUserStore().changMachineEnv(false);
|
||||||
|
useLocationStore().getLocationLoop()//循环获取定位
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isY9MachineType()) {
|
if (isY9MachineType()) {
|
||||||
console.warn('求职一体机环境');
|
console.warn('求职一体机环境');
|
||||||
baseDB.resetAndReinit(); // 清空indexdb
|
baseDB.resetAndReinit(); // 清空indexdb
|
||||||
useUserStore().logOutApp();
|
useUserStore().logOutApp();
|
||||||
useUserStore().changMiniProgramAppStatus(true);
|
useUserStore().changMiniProgramAppStatus(true);
|
||||||
useUserStore().changMachineEnv(true);
|
useUserStore().changMachineEnv(true);
|
||||||
|
(function loop() {
|
||||||
|
console.log('📍一体机尝试获取定位')
|
||||||
|
useLocationStore().getLocation().then(({longitude,latitude})=>{
|
||||||
|
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`)
|
||||||
|
})
|
||||||
|
.catch(err=>{
|
||||||
|
console.log('❌一体机获取定位失败,30s后尝试重新获取')
|
||||||
|
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();
|
||||||
@@ -38,6 +52,7 @@ onLaunch((options) => {
|
|||||||
}
|
}
|
||||||
// 正式上线去除此方法
|
// 正式上线去除此方法
|
||||||
console.warn('浏览器环境');
|
console.warn('浏览器环境');
|
||||||
|
useLocationStore().getLocationLoop()//循环获取定位
|
||||||
useUserStore().changMiniProgramAppStatus(true);
|
useUserStore().changMiniProgramAppStatus(true);
|
||||||
useUserStore().changMachineEnv(false);
|
useUserStore().changMachineEnv(false);
|
||||||
useUserStore().initSeesionId(); //更新
|
useUserStore().initSeesionId(); //更新
|
||||||
@@ -55,6 +70,7 @@ onLaunch((options) => {
|
|||||||
|
|
||||||
onMounted(() => {});
|
onMounted(() => {});
|
||||||
|
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
console.log('App Show');
|
console.log('App Show');
|
||||||
});
|
});
|
||||||
@@ -63,15 +79,16 @@ onHide(() => {
|
|||||||
console.log('App Hide');
|
console.log('App Hide');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleInactivity() {
|
function handleInactivity() {
|
||||||
console.log('【全局】60秒无操作,执行安全逻辑');
|
console.log('【全局】60秒无操作,执行安全逻辑');
|
||||||
if (inactivityModalTimer) {
|
if (inactivityModalTimer) {
|
||||||
clearTimeout(inactivityModalTimer);
|
clearTimeout(inactivityModalTimer);
|
||||||
inactivityModalTimer = null;
|
inactivityModalTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useUserStore().hasLogin) {
|
if (useUserStore().hasLogin) {
|
||||||
// 示例:弹窗确认
|
// 1. 正常弹出确认框
|
||||||
await $confirm({
|
$confirm({
|
||||||
title: '会话即将过期',
|
title: '会话即将过期',
|
||||||
content: '长时间无操作,是否继续使用?',
|
content: '长时间无操作,是否继续使用?',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
@@ -80,25 +97,33 @@ async function handleInactivity() {
|
|||||||
inactivityModalTimer = null;
|
inactivityModalTimer = null;
|
||||||
}
|
}
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
inactivityManager?.resume(); // 恢复监听
|
inactivityManager?.resume();
|
||||||
} else {
|
} else {
|
||||||
performLogout();
|
performLogout();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
if (inactivityModalTimer) clearTimeout(inactivityModalTimer);
|
if (inactivityModalTimer) {
|
||||||
|
clearTimeout(inactivityModalTimer);
|
||||||
|
inactivityModalTimer = null;
|
||||||
|
}
|
||||||
performLogout();
|
performLogout();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. 启动 10 秒倒计时
|
||||||
|
inactivityModalTimer = setTimeout(() => {
|
||||||
|
inactivityModalTimer = null;
|
||||||
|
console.log('【自动登出】10秒无响应,强制清理状态');
|
||||||
|
|
||||||
|
// 【关键改进】:通知全局组件强制关闭弹窗,防止用户点击陈旧弹窗
|
||||||
|
uni.$emit('hide-global-popup');
|
||||||
|
|
||||||
|
performLogout();
|
||||||
|
}, 10000);
|
||||||
} else {
|
} else {
|
||||||
inactivityManager?.resume(); // 恢复监听
|
inactivityManager?.resume();
|
||||||
}
|
}
|
||||||
// 启动 10 秒自动登出定时器
|
|
||||||
inactivityModalTimer = setTimeout(() => {
|
|
||||||
inactivityModalTimer = null;
|
|
||||||
console.log('【自动登出】用户10秒未操作');
|
|
||||||
performLogout();
|
|
||||||
}, 10000); // 10秒
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function performLogout() {
|
function performLogout() {
|
||||||
|
|||||||
@@ -630,6 +630,10 @@ export function sm4Encrypt(key, value, mode = "hex") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reloadBrowser() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const $api = {
|
export const $api = {
|
||||||
msg,
|
msg,
|
||||||
@@ -679,5 +683,6 @@ export default {
|
|||||||
aes_Decrypt,
|
aes_Decrypt,
|
||||||
sm2_Decrypt,
|
sm2_Decrypt,
|
||||||
sm2_Encrypt,
|
sm2_Encrypt,
|
||||||
safeReLaunch
|
safeReLaunch,
|
||||||
|
reloadBrowser
|
||||||
}
|
}
|
||||||
@@ -30,3 +30,15 @@ uni-toast .uni-toast__content {
|
|||||||
font-size: 30rpx !important;
|
font-size: 30rpx !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uni-modal .uni-modal{
|
||||||
|
max-width: 450rpx !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni-modal .uni-modal__bd{
|
||||||
|
font-size: 34rpx !important;
|
||||||
|
min-height: 100rpx !important;
|
||||||
|
}
|
||||||
|
uni-modal .uni-modal__ft{
|
||||||
|
font-size: 36rpx !important;
|
||||||
|
line-height: 80rpx !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useReadMsg } from '@/stores/useReadMsg';
|
import { useReadMsg } from '@/stores/useReadMsg';
|
||||||
|
|
||||||
import useScreenStore from '@/stores/useScreenStore'
|
import useScreenStore from '@/stores/useScreenStore'
|
||||||
|
|
||||||
|
import useUserStore from '@/stores/useUserStore';
|
||||||
|
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
const screenStore = useScreenStore()
|
const screenStore = useScreenStore()
|
||||||
const {isWideScreen} = screenStore
|
const {isWideScreen} = screenStore
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ const tabbarList = computed(() => [
|
|||||||
selectedIconPath: '../../static/tabbar/chat4ed.png',
|
selectedIconPath: '../../static/tabbar/chat4ed.png',
|
||||||
centerItem: false,
|
centerItem: false,
|
||||||
badge: readMsg.badges[3].count,
|
badge: readMsg.badges[3].count,
|
||||||
|
needLogin:true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
@@ -81,6 +85,7 @@ const tabbarList = computed(() => [
|
|||||||
selectedIconPath: '../../static/tabbar/mined.png',
|
selectedIconPath: '../../static/tabbar/mined.png',
|
||||||
centerItem: false,
|
centerItem: false,
|
||||||
badge: readMsg.badges[4].count,
|
badge: readMsg.badges[4].count,
|
||||||
|
needLogin:true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -90,9 +95,14 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const changeItem = (item) => {
|
const changeItem = (item) => {
|
||||||
uni.switchTab({
|
if(isMachineEnv.value && item.needLogin){
|
||||||
url: item.path,
|
useUserStore().logOut()
|
||||||
});
|
}else{
|
||||||
|
uni.switchTab({
|
||||||
|
url: item.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
10
config.js
10
config.js
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
// baseUrl: 'https://fw.rc.qingdao.gov.cn/rgpp-api/api', // 内网
|
baseUrl: 'http://36.105.163.21:30081/rgpp/api', // 内网
|
||||||
baseUrl: 'https://qd.zhaopinzao8dian.com/api', // 测试
|
// baseUrl: 'https://qd.zhaopinzao8dian.com/api', // 测试
|
||||||
// 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',
|
||||||
// 语音转文字
|
// 语音转文字
|
||||||
@@ -8,8 +8,8 @@ export default {
|
|||||||
// 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/', //直接替换即可
|
// speechSynthesis2: 'wss://resource.zhuoson.com/synthesis/', //直接替换即可
|
||||||
// speechSynthesis2: 'http://39.98.44.136:19527', //直接替换即可
|
speechSynthesis2: 'http://39.98.44.136:19527', //直接替换即可
|
||||||
// indexedDB
|
// indexedDB
|
||||||
DBversion: 3,
|
DBversion: 3,
|
||||||
// 只使用本地缓寸的数据
|
// 只使用本地缓寸的数据
|
||||||
@@ -59,7 +59,7 @@ export default {
|
|||||||
allowedFileTypes: [
|
allowedFileTypes: [
|
||||||
"text/plain", // .txt
|
"text/plain", // .txt
|
||||||
"text/markdown", // .md
|
"text/markdown", // .md
|
||||||
"text/html", // .html
|
// "text/html", // .html
|
||||||
"application/msword", // .doc
|
"application/msword", // .doc
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||||
"application/pdf", // .pdf
|
"application/pdf", // .pdf
|
||||||
|
|||||||
459
hook/piper-bundle.js
Normal file
459
hook/piper-bundle.js
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
/**
|
||||||
|
* PiperTTS Bundle (SDK + Worker + PCMPlayer)
|
||||||
|
* Fix: Smart End Detection that supports Pause/Resume
|
||||||
|
*/
|
||||||
|
class PCMPlayer {
|
||||||
|
constructor(options) {
|
||||||
|
this.init(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
init(options) {
|
||||||
|
this.option = Object.assign({}, {
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: 16000,
|
||||||
|
flushTime: 50,
|
||||||
|
fftSize: 2048,
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
this.samples = new Float32Array();
|
||||||
|
this.interval = setInterval(this.flush.bind(this), this.option.flushTime);
|
||||||
|
this.convertValue = this.getConvertValue();
|
||||||
|
this.typedArray = this.getTypedArray();
|
||||||
|
|
||||||
|
this.initAudioContext();
|
||||||
|
this.bindAudioContextEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConvertValue() {
|
||||||
|
const map = {
|
||||||
|
Int8: 128,
|
||||||
|
Int16: 32768,
|
||||||
|
Int32: 2147483648,
|
||||||
|
Float32: 1
|
||||||
|
};
|
||||||
|
if (!map[this.option.inputCodec]) throw new Error('Codec Error');
|
||||||
|
return map[this.option.inputCodec];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypedArray() {
|
||||||
|
const map = {
|
||||||
|
Int8: Int8Array,
|
||||||
|
Int16: Int16Array,
|
||||||
|
Int32: Int32Array,
|
||||||
|
Float32: Float32Array
|
||||||
|
};
|
||||||
|
if (!map[this.option.inputCodec]) throw new Error('Codec Error');
|
||||||
|
return map[this.option.inputCodec];
|
||||||
|
}
|
||||||
|
|
||||||
|
initAudioContext() {
|
||||||
|
this.audioCtx = new(window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.gainNode = this.audioCtx.createGain();
|
||||||
|
this.gainNode.gain.value = 1.0;
|
||||||
|
this.gainNode.connect(this.audioCtx.destination);
|
||||||
|
this.startTime = this.audioCtx.currentTime;
|
||||||
|
this.analyserNode = this.audioCtx.createAnalyser();
|
||||||
|
this.analyserNode.fftSize = this.option.fftSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isTypedArray(data) {
|
||||||
|
return (data.byteLength && data.buffer && data.buffer.constructor == ArrayBuffer) || data.constructor ==
|
||||||
|
ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSupported(data) {
|
||||||
|
if (!PCMPlayer.isTypedArray(data)) throw new Error('Data must be ArrayBuffer or TypedArray');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
feed(data) {
|
||||||
|
this.isSupported(data);
|
||||||
|
data = this.getFormattedValue(data);
|
||||||
|
const tmp = new Float32Array(this.samples.length + data.length);
|
||||||
|
tmp.set(this.samples, 0);
|
||||||
|
tmp.set(data, this.samples.length);
|
||||||
|
this.samples = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormattedValue(data) {
|
||||||
|
data = data.constructor == ArrayBuffer ? new this.typedArray(data) : new this.typedArray(data.buffer);
|
||||||
|
let float32 = new Float32Array(data.length);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
float32[i] = data[i] / this.convertValue;
|
||||||
|
}
|
||||||
|
return float32;
|
||||||
|
}
|
||||||
|
|
||||||
|
volume(val) {
|
||||||
|
this.gainNode.gain.value = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.interval) clearInterval(this.interval);
|
||||||
|
this.samples = null;
|
||||||
|
if (this.audioCtx) {
|
||||||
|
this.audioCtx.close();
|
||||||
|
this.audioCtx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (!this.samples.length) return;
|
||||||
|
|
||||||
|
const bufferSource = this.audioCtx.createBufferSource();
|
||||||
|
if (typeof this.option.onended === 'function') {
|
||||||
|
bufferSource.onended = (e) => this.option.onended(this, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = this.samples.length / this.option.channels;
|
||||||
|
const audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate);
|
||||||
|
|
||||||
|
for (let channel = 0; channel < this.option.channels; channel++) {
|
||||||
|
const audioData = audioBuffer.getChannelData(channel);
|
||||||
|
let offset = channel;
|
||||||
|
let decrement = 50;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
audioData[i] = this.samples[offset];
|
||||||
|
if (i < 50) audioData[i] = (audioData[i] * i) / 50;
|
||||||
|
if (i >= length - 51) audioData[i] = (audioData[i] * decrement--) / 50;
|
||||||
|
offset += this.option.channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.startTime < this.audioCtx.currentTime) {
|
||||||
|
this.startTime = this.audioCtx.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferSource.buffer = audioBuffer;
|
||||||
|
bufferSource.connect(this.gainNode);
|
||||||
|
bufferSource.connect(this.analyserNode);
|
||||||
|
bufferSource.start(this.startTime);
|
||||||
|
|
||||||
|
this.startTime += audioBuffer.duration;
|
||||||
|
this.samples = new Float32Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause() {
|
||||||
|
await this.audioCtx.suspend();
|
||||||
|
}
|
||||||
|
async continue () {
|
||||||
|
await this.audioCtx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindAudioContextEvent() {
|
||||||
|
if (typeof this.option.onstatechange === 'function') {
|
||||||
|
this.audioCtx.onstatechange = (e) => {
|
||||||
|
this.option.onstatechange(this, e, this.audioCtx.state);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Worker 源码
|
||||||
|
// ==========================================
|
||||||
|
const WORKER_SOURCE = `
|
||||||
|
let globalWs = null;
|
||||||
|
|
||||||
|
self.onmessage = function (e) {
|
||||||
|
const { type, data } = e.data;
|
||||||
|
switch (type) {
|
||||||
|
case 'connect': connectWebSocket(data); break;
|
||||||
|
case 'stop': closeWs(); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function closeWs() {
|
||||||
|
if (globalWs) {
|
||||||
|
globalWs.onerror = null;
|
||||||
|
globalWs.onclose = null;
|
||||||
|
globalWs.onmessage = null;
|
||||||
|
try { globalWs.close(1000, 'User stopped'); } catch (e) {}
|
||||||
|
globalWs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket(config) {
|
||||||
|
closeWs();
|
||||||
|
|
||||||
|
const { url, text, options } = config;
|
||||||
|
self.postMessage({ type: 'status', data: 'ws_connecting' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentWs = new WebSocket(url);
|
||||||
|
currentWs.binaryType = 'arraybuffer';
|
||||||
|
globalWs = currentWs;
|
||||||
|
|
||||||
|
currentWs.onopen = () => {
|
||||||
|
if (globalWs !== currentWs) return;
|
||||||
|
self.postMessage({ type: 'status', data: 'ws_connected' });
|
||||||
|
currentWs.send(JSON.stringify({
|
||||||
|
text: text,
|
||||||
|
speaker_id: options.speakerId || 0,
|
||||||
|
length_scale: options.lengthScale || 1.0,
|
||||||
|
noise_scale: options.noiseScale || 0.667,
|
||||||
|
}));
|
||||||
|
self.postMessage({ type: 'status', data: 'generating' });
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWs.onmessage = (event) => {
|
||||||
|
if (globalWs !== currentWs) return;
|
||||||
|
|
||||||
|
if (typeof event.data === 'string' && event.data === 'END') {
|
||||||
|
const wsToClose = currentWs;
|
||||||
|
globalWs = null;
|
||||||
|
|
||||||
|
wsToClose.onmessage = null;
|
||||||
|
wsToClose.onerror = null;
|
||||||
|
wsToClose.onclose = null;
|
||||||
|
|
||||||
|
try { wsToClose.close(1000, 'Done'); } catch(e) {}
|
||||||
|
|
||||||
|
self.postMessage({ type: 'end' });
|
||||||
|
} else {
|
||||||
|
self.postMessage({ type: 'audio-data', buffer: event.data }, [event.data]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWs.onclose = (e) => {
|
||||||
|
if (globalWs === currentWs) {
|
||||||
|
self.postMessage({ type: 'end' });
|
||||||
|
globalWs = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWs.onerror = () => {
|
||||||
|
if (globalWs === currentWs) {
|
||||||
|
self.postMessage({ type: 'error', data: 'WebSocket error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
self.postMessage({ type: 'error', data: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PiperTTS SDK
|
||||||
|
// ==========================================
|
||||||
|
class PiperTTS {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.baseUrl = config.baseUrl || 'http://localhost:5001';
|
||||||
|
this.onStatus = config.onStatus || console.log;
|
||||||
|
this.onStart = config.onStart || (() => {});
|
||||||
|
this.onEnd = config.onEnd || (() => {});
|
||||||
|
this.sampleRate = config.sampleRate || 16000;
|
||||||
|
|
||||||
|
this.player = null;
|
||||||
|
this.worker = null;
|
||||||
|
this.recordedChunks = [];
|
||||||
|
this.isRecording = false;
|
||||||
|
|
||||||
|
// 新增:检测音频结束的定时器 ID
|
||||||
|
this.endCheckInterval = null;
|
||||||
|
|
||||||
|
this._initWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initWorker() {
|
||||||
|
const blob = new Blob([WORKER_SOURCE], {
|
||||||
|
type: 'application/javascript'
|
||||||
|
});
|
||||||
|
this.worker = new Worker(URL.createObjectURL(blob));
|
||||||
|
|
||||||
|
this.worker.onmessage = (e) => {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
buffer
|
||||||
|
} = e.data;
|
||||||
|
switch (type) {
|
||||||
|
case 'status':
|
||||||
|
const map = {
|
||||||
|
ws_connecting: '正在连接...',
|
||||||
|
ws_connected: '已连接',
|
||||||
|
generating: '流式接收中...'
|
||||||
|
};
|
||||||
|
this.onStatus(map[data] || data, 'processing');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (this.recordedChunks.length > 0) {
|
||||||
|
this.onStatus('数据接收完毕', 'success');
|
||||||
|
this._triggerEndWithDelay();
|
||||||
|
} else {
|
||||||
|
this.onStatus(`错误: ${data}`, 'error');
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'audio-data':
|
||||||
|
this._handleAudio(buffer);
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
this.onStatus('数据接收完毕', 'success');
|
||||||
|
this._triggerEndWithDelay();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 【核心修改】智能轮询检测
|
||||||
|
* 只有当 AudioContext 处于 running 状态且时间走完时,才触发 onEnd
|
||||||
|
*/
|
||||||
|
_triggerEndWithDelay() {
|
||||||
|
// 先清除可能存在的旧定时器
|
||||||
|
if (this.endCheckInterval) clearInterval(this.endCheckInterval);
|
||||||
|
|
||||||
|
// 每 200ms 检查一次
|
||||||
|
this.endCheckInterval = setInterval(() => {
|
||||||
|
// 1. 如果播放器没了,直接结束
|
||||||
|
if (!this.player || !this.player.audioCtx) {
|
||||||
|
this._finishEndCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果处于暂停状态 (suspended),什么都不做,继续等
|
||||||
|
if (this.player.audioCtx.state === 'suspended') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 计算剩余时间
|
||||||
|
// startTime 是缓冲区结束的绝对时间,currentTime 是当前时间
|
||||||
|
const remainingTime = this.player.startTime - this.player.audioCtx.currentTime;
|
||||||
|
|
||||||
|
// 4. 如果剩余时间小于 0.1秒(留点冗余),说明播完了
|
||||||
|
if (remainingTime <= 0.1) {
|
||||||
|
this._finishEndCheck();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
_finishEndCheck() {
|
||||||
|
if (this.endCheckInterval) {
|
||||||
|
clearInterval(this.endCheckInterval);
|
||||||
|
this.endCheckInterval = null;
|
||||||
|
}
|
||||||
|
this.onEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initPlayer() {
|
||||||
|
if (this.player) {
|
||||||
|
this.player.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player = new PCMPlayer({
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: this.sampleRate,
|
||||||
|
flushTime: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async speak(text, options = {}) {
|
||||||
|
if (!text) return;
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
this._initPlayer();
|
||||||
|
if (this.player) {
|
||||||
|
await this.player.continue();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordedChunks = [];
|
||||||
|
this.isRecording = true;
|
||||||
|
this.onStart();
|
||||||
|
|
||||||
|
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/synthesize';
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: 'connect',
|
||||||
|
data: {
|
||||||
|
url: wsUrl,
|
||||||
|
text,
|
||||||
|
options
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
// 停止时必须清除轮询检测
|
||||||
|
if (this.endCheckInterval) {
|
||||||
|
clearInterval(this.endCheckInterval);
|
||||||
|
this.endCheckInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: 'stop'
|
||||||
|
});
|
||||||
|
if (this.player) {
|
||||||
|
this.player.destroy();
|
||||||
|
this.player = null;
|
||||||
|
}
|
||||||
|
this.onStatus('已停止', 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleAudio(arrayBuffer) {
|
||||||
|
if (this.isRecording) {
|
||||||
|
this.recordedChunks.push(arrayBuffer);
|
||||||
|
}
|
||||||
|
if (this.player) {
|
||||||
|
this.player.feed(arrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnalyserNode() {
|
||||||
|
return this.player ? this.player.analyserNode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAudio(filename = 'tts_output.wav') {
|
||||||
|
if (this.recordedChunks.length === 0) return;
|
||||||
|
let totalLen = 0;
|
||||||
|
for (let chunk of this.recordedChunks) totalLen += chunk.byteLength;
|
||||||
|
const tmp = new Uint8Array(totalLen);
|
||||||
|
let offset = 0;
|
||||||
|
for (let chunk of this.recordedChunks) {
|
||||||
|
tmp.set(new Uint8Array(chunk), offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
const wavBuffer = this._encodeWAV(new Int16Array(tmp.buffer), this.sampleRate);
|
||||||
|
const blob = new Blob([wavBuffer], {
|
||||||
|
type: 'audio/wav'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style = 'display: none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
_encodeWAV(samples, sampleRate) {
|
||||||
|
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const writeString = (view, offset, string) => {
|
||||||
|
for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
};
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + samples.length * 2, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 1, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * 2, true);
|
||||||
|
view.setUint16(32, 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, samples.length * 2, true);
|
||||||
|
let offset = 44;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
view.setInt16(offset, samples[i], true);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default PiperTTS;
|
||||||
@@ -3,255 +3,344 @@ import {
|
|||||||
onUnmounted
|
onUnmounted
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
$api,
|
$api
|
||||||
|
} from '../common/globalFunction'; // 你的请求封装
|
||||||
} from '../common/globalFunction';
|
|
||||||
|
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
|
|
||||||
// Alibaba Cloud
|
// 开源
|
||||||
export function useAudioRecorder() {
|
export function useAudioRecorder() {
|
||||||
|
// --- 状态定义 ---
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false)
|
||||||
const isStopping = ref(false)
|
|
||||||
const isSocketConnected = ref(false)
|
const isSocketConnected = ref(false)
|
||||||
const recordingDuration = ref(0)
|
const recordingDuration = ref(0)
|
||||||
|
const volumeLevel = ref(0) // 0-100
|
||||||
const audioDataForDisplay = ref(new Array(16).fill(0))
|
|
||||||
const volumeLevel = ref(0)
|
|
||||||
|
|
||||||
const recognizedText = ref('')
|
const recognizedText = ref('')
|
||||||
const lastFinalText = ref('')
|
|
||||||
|
|
||||||
let audioStream = null
|
// --- 内部变量 ---
|
||||||
let audioContext = null
|
let socketTask = null
|
||||||
let audioInput = null
|
|
||||||
let scriptProcessor = null
|
|
||||||
let websocket = null
|
|
||||||
let durationTimer = null
|
let durationTimer = null
|
||||||
|
|
||||||
const generateUUID = () => {
|
// --- APP/小程序 变量 ---
|
||||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11)
|
let recorderManager = null;
|
||||||
.replace(/[018]/g, c =>
|
|
||||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
// --- H5 变量 ---
|
||||||
).replace(/-/g, '')
|
let audioContext = null;
|
||||||
|
let scriptProcessor = null;
|
||||||
|
let mediaStreamSource = null;
|
||||||
|
let h5Stream = null;
|
||||||
|
|
||||||
|
// --- 配置项 ---
|
||||||
|
const RECORD_CONFIG = {
|
||||||
|
duration: 600000,
|
||||||
|
sampleRate: 16000,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
format: 'pcm',
|
||||||
|
frameSize: 4096
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchWsUrl = async () => {
|
/**
|
||||||
const res = await $api.createRequest('/app/speech/getToken')
|
* 获取 WebSocket 地址 (含 Token)
|
||||||
if (res.code !== 200) throw new Error('无法获取语音识别 wsUrl')
|
*/
|
||||||
const wsUrl = res.msg
|
const getWsUrl = async () => {
|
||||||
return wsUrl
|
let wsUrl = config.vioceBaseURl
|
||||||
|
|
||||||
|
// 拼接 Token
|
||||||
|
const token = uni.getStorageSync('token') || '';
|
||||||
|
if (token) {
|
||||||
|
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||||
|
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
return wsUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractWsParams(wsUrl) {
|
/**
|
||||||
const url = new URL(wsUrl)
|
* 开始录音 (入口)
|
||||||
const appkey = url.searchParams.get('appkey')
|
*/
|
||||||
const token = url.searchParams.get('token')
|
const startRecording = async () => {
|
||||||
return {
|
if (isRecording.value) return
|
||||||
appkey,
|
|
||||||
token
|
try {
|
||||||
|
recognizedText.value = ''
|
||||||
|
volumeLevel.value = 0
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||||
|
uni.showToast({
|
||||||
|
title: 'H5录音需要HTTPS环境',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const url = await getWsUrl()
|
||||||
|
console.log('正在连接 ASR:', url)
|
||||||
|
|
||||||
|
await connectSocket(url);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('启动失败:', err);
|
||||||
|
uni.showToast({
|
||||||
|
title: '启动失败: ' + (err.message || ''),
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
const connectWebSocket = async () => {
|
* 连接 WebSocket
|
||||||
const wsUrl = await fetchWsUrl()
|
*/
|
||||||
const {
|
const connectSocket = (url) => {
|
||||||
appkey,
|
|
||||||
token
|
|
||||||
} = extractWsParams(wsUrl)
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
websocket = new WebSocket(wsUrl)
|
socketTask = uni.connectSocket({
|
||||||
websocket.binaryType = 'arraybuffer'
|
url: url,
|
||||||
|
success: () => console.log('Socket 连接请求发送'),
|
||||||
|
fail: (err) => reject(err)
|
||||||
|
});
|
||||||
|
|
||||||
websocket.onopen = () => {
|
socketTask.onOpen((res) => {
|
||||||
isSocketConnected.value = true
|
console.log('WebSocket 已连接');
|
||||||
|
isSocketConnected.value = true;
|
||||||
|
|
||||||
// 发送 StartTranscription 消息(参考 demo.html)
|
// #ifdef H5
|
||||||
const startTranscriptionMessage = {
|
startH5Recording().then(() => resolve()).catch(err => {
|
||||||
header: {
|
socketTask.close();
|
||||||
appkey: appkey, // 不影响使用,可留空或由 wsUrl 带入
|
reject(err);
|
||||||
namespace: 'SpeechTranscriber',
|
});
|
||||||
name: 'StartTranscription',
|
// #endif
|
||||||
task_id: generateUUID(),
|
|
||||||
message_id: generateUUID()
|
// #ifndef H5
|
||||||
},
|
startAppRecording();
|
||||||
payload: {
|
resolve();
|
||||||
format: 'pcm',
|
// #endif
|
||||||
sample_rate: 16000,
|
});
|
||||||
enable_intermediate_result: true,
|
|
||||||
enable_punctuation_prediction: true,
|
socketTask.onMessage((res) => {
|
||||||
enable_inverse_text_normalization: true
|
// 接收文本结果
|
||||||
}
|
if (res.data) {
|
||||||
|
recognizedText.value = res.data;
|
||||||
}
|
}
|
||||||
websocket.send(JSON.stringify(startTranscriptionMessage))
|
});
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
websocket.onerror = (e) => {
|
socketTask.onError((err) => {
|
||||||
isSocketConnected.value = false
|
console.error('Socket 错误:', err);
|
||||||
reject(e)
|
isSocketConnected.value = false;
|
||||||
}
|
stopRecording();
|
||||||
|
});
|
||||||
|
|
||||||
websocket.onclose = () => {
|
socketTask.onClose(() => {
|
||||||
isSocketConnected.value = false
|
isSocketConnected.value = false;
|
||||||
}
|
console.log('Socket 已关闭');
|
||||||
|
});
|
||||||
websocket.onmessage = (e) => {
|
|
||||||
const msg = JSON.parse(e.data)
|
|
||||||
const name = msg?.header?.name
|
|
||||||
const payload = msg?.payload
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case 'TranscriptionResultChanged': {
|
|
||||||
// 中间识别文本(可选:使用 stash_result.unfixedText 更精确)
|
|
||||||
const text = payload?.unfixed_result || payload?.result || ''
|
|
||||||
lastFinalText.value = text
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'SentenceBegin': {
|
|
||||||
// 可选:开始新的一句,重置状态
|
|
||||||
// console.log('开始新的句子识别')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'SentenceEnd': {
|
|
||||||
const text = payload?.result || ''
|
|
||||||
const confidence = payload?.confidence || 0
|
|
||||||
if (text && confidence > 0.5) {
|
|
||||||
recognizedText.value += text
|
|
||||||
lastFinalText.value = ''
|
|
||||||
// console.log('识别完成:', {
|
|
||||||
// text,
|
|
||||||
// confidence
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'TranscriptionStarted': {
|
|
||||||
// console.log('识别任务已开始')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'TranscriptionCompleted': {
|
|
||||||
lastFinalText.value = ''
|
|
||||||
// console.log('识别全部完成')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'TaskFailed': {
|
|
||||||
console.error('识别失败:', msg?.header?.status_text)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
console.log('未知消息类型:', name, msg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startH5Recording = async () => {
|
||||||
if (isRecording.value) return
|
|
||||||
try {
|
try {
|
||||||
recognizedText.value = ''
|
// 1. 获取麦克风流
|
||||||
lastFinalText.value = ''
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
await connectWebSocket()
|
|
||||||
|
|
||||||
audioStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true
|
audio: true
|
||||||
})
|
});
|
||||||
audioContext = new(window.AudioContext || window.webkitAudioContext)({
|
h5Stream = stream;
|
||||||
|
|
||||||
|
// 2. 创建 AudioContext
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioContext = new AudioContext({
|
||||||
sampleRate: 16000
|
sampleRate: 16000
|
||||||
})
|
});
|
||||||
audioInput = audioContext.createMediaStreamSource(audioStream)
|
|
||||||
scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1)
|
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||||||
|
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
|
||||||
scriptProcessor.onaudioprocess = (event) => {
|
scriptProcessor.onaudioprocess = (event) => {
|
||||||
const input = event.inputBuffer.getChannelData(0)
|
if (!isSocketConnected.value || !socketTask) return;
|
||||||
const pcm = new Int16Array(input.length)
|
|
||||||
let sum = 0
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
for (let i = 0; i < input.length; ++i) {
|
|
||||||
const s = Math.max(-1, Math.min(1, input[i]))
|
calculateVolume(inputData, true);
|
||||||
pcm[i] = s * 0x7FFF
|
|
||||||
sum += s * s
|
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
let s = Math.max(-1, Math.min(1, inputData[i]));
|
||||||
|
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
volumeLevel.value = Math.sqrt(sum / input.length)
|
socketTask.send({
|
||||||
audioDataForDisplay.value = Array(16).fill(volumeLevel.value)
|
data: buffer,
|
||||||
|
fail: (e) => console.error('发送音频失败', e)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (websocket?.readyState === WebSocket.OPEN) {
|
mediaStreamSource.connect(scriptProcessor);
|
||||||
websocket.send(pcm.buffer)
|
scriptProcessor.connect(audioContext.destination);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioInput.connect(scriptProcessor)
|
isRecording.value = true;
|
||||||
scriptProcessor.connect(audioContext.destination)
|
recordingDuration.value = 0;
|
||||||
|
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||||
|
|
||||||
|
console.log('H5 录音已启动');
|
||||||
|
|
||||||
isRecording.value = true
|
|
||||||
recordingDuration.value = 0
|
|
||||||
durationTimer = setInterval(() => recordingDuration.value++, 1000)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('启动失败:', err)
|
console.error('H5 录音启动失败:', err);
|
||||||
cleanup()
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopRecording = () => {
|
const stopH5Resources = () => {
|
||||||
if (!isRecording.value || isStopping.value) return
|
if (scriptProcessor) scriptProcessor.disconnect();
|
||||||
isStopping.value = true
|
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||||
|
if (audioContext) audioContext.close();
|
||||||
|
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
if (websocket?.readyState === WebSocket.OPEN) {
|
scriptProcessor = null;
|
||||||
websocket.send(JSON.stringify({
|
mediaStreamSource = null;
|
||||||
header: {
|
audioContext = null;
|
||||||
namespace: 'SpeechTranscriber',
|
h5Stream = null;
|
||||||
name: 'StopTranscription',
|
}
|
||||||
message_id: generateUUID()
|
|
||||||
}
|
const startAppRecording = () => {
|
||||||
}))
|
recorderManager = uni.getRecorderManager();
|
||||||
websocket.close()
|
|
||||||
|
recorderManager.onFrameRecorded((res) => {
|
||||||
|
const {
|
||||||
|
frameBuffer
|
||||||
|
} = res;
|
||||||
|
|
||||||
|
calculateVolume(frameBuffer, false);
|
||||||
|
|
||||||
|
if (isSocketConnected.value && socketTask) {
|
||||||
|
socketTask.send({
|
||||||
|
data: frameBuffer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderManager.onStart(() => {
|
||||||
|
console.log('APP 录音已开始');
|
||||||
|
isRecording.value = true;
|
||||||
|
recordingDuration.value = 0;
|
||||||
|
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderManager.onError((err) => {
|
||||||
|
console.error('APP 录音报错:', err);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderManager.start(RECORD_CONFIG);
|
||||||
|
}
|
||||||
|
const stopHardwareResource = () => {
|
||||||
|
// APP/小程序停止
|
||||||
|
if (recorderManager) {
|
||||||
|
recorderManager.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup()
|
// H5停止
|
||||||
isStopping.value = false
|
// #ifdef H5
|
||||||
|
if (scriptProcessor) scriptProcessor.disconnect();
|
||||||
|
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||||
|
if (audioContext) audioContext.close();
|
||||||
|
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
scriptProcessor = null;
|
||||||
|
mediaStreamSource = null;
|
||||||
|
audioContext = null;
|
||||||
|
h5Stream = null;
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止录音 (通用)
|
||||||
|
*/
|
||||||
|
const stopRecording = () => {
|
||||||
|
// 停止 APP 录音
|
||||||
|
if (recorderManager) {
|
||||||
|
recorderManager.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止 H5 录音资源
|
||||||
|
// #ifdef H5
|
||||||
|
stopH5Resources();
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 关闭 Socket
|
||||||
|
if (socketTask) {
|
||||||
|
socketTask.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelRecording = () => {
|
const cancelRecording = () => {
|
||||||
if (!isRecording.value || isStopping.value) return
|
if (!isRecording.value) return;
|
||||||
isStopping.value = true
|
|
||||||
websocket?.close()
|
console.log('取消录音 - 丢弃结果');
|
||||||
cleanup()
|
|
||||||
isStopping.value = false
|
// 1. 停止硬件录音
|
||||||
|
stopHardwareResource();
|
||||||
|
|
||||||
|
// 2. 强制关闭 Socket
|
||||||
|
if (socketTask) {
|
||||||
|
socketTask.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 关键:清空已识别的文本
|
||||||
|
recognizedText.value = '';
|
||||||
|
|
||||||
|
// 4. 清理资源
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理状态
|
||||||
|
*/
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
clearInterval(durationTimer)
|
clearInterval(durationTimer);
|
||||||
|
isRecording.value = false;
|
||||||
|
isSocketConnected.value = false;
|
||||||
|
socketTask = null;
|
||||||
|
recorderManager = null;
|
||||||
|
volumeLevel.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
scriptProcessor?.disconnect()
|
/**
|
||||||
audioInput?.disconnect()
|
* 计算音量 (兼容 Float32 和 Int16/ArrayBuffer)
|
||||||
audioStream?.getTracks().forEach(track => track.stop())
|
*/
|
||||||
audioContext?.close()
|
const calculateVolume = (data, isFloat32) => {
|
||||||
|
let sum = 0;
|
||||||
|
let length = 0;
|
||||||
|
|
||||||
audioStream = null
|
if (isFloat32) {
|
||||||
audioContext = null
|
length = data.length;
|
||||||
audioInput = null
|
for (let i = 0; i < length; i += 10) {
|
||||||
scriptProcessor = null
|
sum += Math.abs(data[i]);
|
||||||
websocket = null
|
}
|
||||||
|
volumeLevel.value = Math.min(100, Math.floor((sum / (length / 10)) * 100 * 3));
|
||||||
isRecording.value = false
|
} else {
|
||||||
isSocketConnected.value = false
|
const int16Data = new Int16Array(data);
|
||||||
|
length = int16Data.length;
|
||||||
|
for (let i = 0; i < length; i += 10) {
|
||||||
|
sum += Math.abs(int16Data[i]);
|
||||||
|
}
|
||||||
|
const avg = sum / (length / 10);
|
||||||
|
volumeLevel.value = Math.min(100, Math.floor((avg / 10000) * 100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (isRecording.value) stopRecording()
|
if (isRecording.value) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRecording,
|
isRecording,
|
||||||
isStopping,
|
|
||||||
isSocketConnected,
|
isSocketConnected,
|
||||||
recordingDuration,
|
recordingDuration,
|
||||||
audioDataForDisplay,
|
|
||||||
volumeLevel,
|
volumeLevel,
|
||||||
recognizedText,
|
recognizedText,
|
||||||
lastFinalText,
|
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording
|
cancelRecording
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import {
|
|
||||||
ref,
|
|
||||||
onUnmounted
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
|
||||||
$api
|
|
||||||
} from '../common/globalFunction'; // 你的请求封装
|
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
// 开源
|
|
||||||
export function useAudioRecorder() {
|
|
||||||
// --- 状态定义 ---
|
|
||||||
const isRecording = ref(false)
|
|
||||||
const isSocketConnected = ref(false)
|
|
||||||
const recordingDuration = ref(0)
|
|
||||||
const volumeLevel = ref(0) // 0-100
|
|
||||||
const recognizedText = ref('')
|
|
||||||
|
|
||||||
// --- 内部变量 ---
|
|
||||||
let socketTask = null
|
|
||||||
let durationTimer = null
|
|
||||||
|
|
||||||
// --- APP/小程序 变量 ---
|
|
||||||
let recorderManager = null;
|
|
||||||
|
|
||||||
// --- H5 变量 ---
|
|
||||||
let audioContext = null;
|
|
||||||
let scriptProcessor = null;
|
|
||||||
let mediaStreamSource = null;
|
|
||||||
let h5Stream = null;
|
|
||||||
|
|
||||||
// --- 配置项 ---
|
|
||||||
const RECORD_CONFIG = {
|
|
||||||
duration: 600000,
|
|
||||||
sampleRate: 16000,
|
|
||||||
numberOfChannels: 1,
|
|
||||||
format: 'pcm',
|
|
||||||
frameSize: 4096
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 WebSocket 地址 (含 Token)
|
|
||||||
*/
|
|
||||||
const getWsUrl = async () => {
|
|
||||||
let wsUrl = config.vioceBaseURl
|
|
||||||
|
|
||||||
// 拼接 Token
|
|
||||||
const token = uni.getStorageSync('token') || '';
|
|
||||||
if (token) {
|
|
||||||
const separator = wsUrl.includes('?') ? '&' : '?';
|
|
||||||
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
return wsUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始录音 (入口)
|
|
||||||
*/
|
|
||||||
const startRecording = async () => {
|
|
||||||
if (isRecording.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
recognizedText.value = ''
|
|
||||||
volumeLevel.value = 0
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
|
||||||
uni.showToast({
|
|
||||||
title: 'H5录音需要HTTPS环境',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
const url = await getWsUrl()
|
|
||||||
console.log('正在连接 ASR:', url)
|
|
||||||
|
|
||||||
await connectSocket(url);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('启动失败:', err);
|
|
||||||
uni.showToast({
|
|
||||||
title: '启动失败: ' + (err.message || ''),
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 连接 WebSocket
|
|
||||||
*/
|
|
||||||
const connectSocket = (url) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socketTask = uni.connectSocket({
|
|
||||||
url: url,
|
|
||||||
success: () => console.log('Socket 连接请求发送'),
|
|
||||||
fail: (err) => reject(err)
|
|
||||||
});
|
|
||||||
|
|
||||||
socketTask.onOpen((res) => {
|
|
||||||
console.log('WebSocket 已连接');
|
|
||||||
isSocketConnected.value = true;
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
startH5Recording().then(() => resolve()).catch(err => {
|
|
||||||
socketTask.close();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifndef H5
|
|
||||||
startAppRecording();
|
|
||||||
resolve();
|
|
||||||
// #endif
|
|
||||||
});
|
|
||||||
|
|
||||||
socketTask.onMessage((res) => {
|
|
||||||
// 接收文本结果
|
|
||||||
if (res.data) {
|
|
||||||
recognizedText.value = res.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socketTask.onError((err) => {
|
|
||||||
console.error('Socket 错误:', err);
|
|
||||||
isSocketConnected.value = false;
|
|
||||||
stopRecording();
|
|
||||||
});
|
|
||||||
|
|
||||||
socketTask.onClose(() => {
|
|
||||||
isSocketConnected.value = false;
|
|
||||||
console.log('Socket 已关闭');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const startH5Recording = async () => {
|
|
||||||
try {
|
|
||||||
// 1. 获取麦克风流
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true
|
|
||||||
});
|
|
||||||
h5Stream = stream;
|
|
||||||
|
|
||||||
// 2. 创建 AudioContext
|
|
||||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
||||||
audioContext = new AudioContext({
|
|
||||||
sampleRate: 16000
|
|
||||||
});
|
|
||||||
|
|
||||||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
|
||||||
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
|
||||||
|
|
||||||
scriptProcessor.onaudioprocess = (event) => {
|
|
||||||
if (!isSocketConnected.value || !socketTask) return;
|
|
||||||
|
|
||||||
const inputData = event.inputBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
calculateVolume(inputData, true);
|
|
||||||
|
|
||||||
const buffer = new ArrayBuffer(inputData.length * 2);
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
for (let i = 0; i < inputData.length; i++) {
|
|
||||||
let s = Math.max(-1, Math.min(1, inputData[i]));
|
|
||||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
socketTask.send({
|
|
||||||
data: buffer,
|
|
||||||
fail: (e) => console.error('发送音频失败', e)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaStreamSource.connect(scriptProcessor);
|
|
||||||
scriptProcessor.connect(audioContext.destination);
|
|
||||||
|
|
||||||
isRecording.value = true;
|
|
||||||
recordingDuration.value = 0;
|
|
||||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
|
||||||
|
|
||||||
console.log('H5 录音已启动');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('H5 录音启动失败:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopH5Resources = () => {
|
|
||||||
if (scriptProcessor) scriptProcessor.disconnect();
|
|
||||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
|
||||||
if (audioContext) audioContext.close();
|
|
||||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
|
||||||
|
|
||||||
scriptProcessor = null;
|
|
||||||
mediaStreamSource = null;
|
|
||||||
audioContext = null;
|
|
||||||
h5Stream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAppRecording = () => {
|
|
||||||
recorderManager = uni.getRecorderManager();
|
|
||||||
|
|
||||||
recorderManager.onFrameRecorded((res) => {
|
|
||||||
const {
|
|
||||||
frameBuffer
|
|
||||||
} = res;
|
|
||||||
|
|
||||||
calculateVolume(frameBuffer, false);
|
|
||||||
|
|
||||||
if (isSocketConnected.value && socketTask) {
|
|
||||||
socketTask.send({
|
|
||||||
data: frameBuffer
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
recorderManager.onStart(() => {
|
|
||||||
console.log('APP 录音已开始');
|
|
||||||
isRecording.value = true;
|
|
||||||
recordingDuration.value = 0;
|
|
||||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
recorderManager.onError((err) => {
|
|
||||||
console.error('APP 录音报错:', err);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
recorderManager.start(RECORD_CONFIG);
|
|
||||||
}
|
|
||||||
const stopHardwareResource = () => {
|
|
||||||
// APP/小程序停止
|
|
||||||
if (recorderManager) {
|
|
||||||
recorderManager.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// H5停止
|
|
||||||
// #ifdef H5
|
|
||||||
if (scriptProcessor) scriptProcessor.disconnect();
|
|
||||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
|
||||||
if (audioContext) audioContext.close();
|
|
||||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
|
||||||
|
|
||||||
scriptProcessor = null;
|
|
||||||
mediaStreamSource = null;
|
|
||||||
audioContext = null;
|
|
||||||
h5Stream = null;
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止录音 (通用)
|
|
||||||
*/
|
|
||||||
const stopRecording = () => {
|
|
||||||
// 停止 APP 录音
|
|
||||||
if (recorderManager) {
|
|
||||||
recorderManager.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止 H5 录音资源
|
|
||||||
// #ifdef H5
|
|
||||||
stopH5Resources();
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 关闭 Socket
|
|
||||||
if (socketTask) {
|
|
||||||
socketTask.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelRecording = () => {
|
|
||||||
if (!isRecording.value) return;
|
|
||||||
|
|
||||||
console.log('取消录音 - 丢弃结果');
|
|
||||||
|
|
||||||
// 1. 停止硬件录音
|
|
||||||
stopHardwareResource();
|
|
||||||
|
|
||||||
// 2. 强制关闭 Socket
|
|
||||||
if (socketTask) {
|
|
||||||
socketTask.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 关键:清空已识别的文本
|
|
||||||
recognizedText.value = '';
|
|
||||||
|
|
||||||
// 4. 清理资源
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理状态
|
|
||||||
*/
|
|
||||||
const cleanup = () => {
|
|
||||||
clearInterval(durationTimer);
|
|
||||||
isRecording.value = false;
|
|
||||||
isSocketConnected.value = false;
|
|
||||||
socketTask = null;
|
|
||||||
recorderManager = null;
|
|
||||||
volumeLevel.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算音量 (兼容 Float32 和 Int16/ArrayBuffer)
|
|
||||||
*/
|
|
||||||
const calculateVolume = (data, isFloat32) => {
|
|
||||||
let sum = 0;
|
|
||||||
let length = 0;
|
|
||||||
|
|
||||||
if (isFloat32) {
|
|
||||||
length = data.length;
|
|
||||||
for (let i = 0; i < length; i += 10) {
|
|
||||||
sum += Math.abs(data[i]);
|
|
||||||
}
|
|
||||||
volumeLevel.value = Math.min(100, Math.floor((sum / (length / 10)) * 100 * 3));
|
|
||||||
} else {
|
|
||||||
const int16Data = new Int16Array(data);
|
|
||||||
length = int16Data.length;
|
|
||||||
for (let i = 0; i < length; i += 10) {
|
|
||||||
sum += Math.abs(int16Data[i]);
|
|
||||||
}
|
|
||||||
const avg = sum / (length / 10);
|
|
||||||
volumeLevel.value = Math.min(100, Math.floor((avg / 10000) * 100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (isRecording.value) {
|
|
||||||
stopRecording();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRecording,
|
|
||||||
isSocketConnected,
|
|
||||||
recordingDuration,
|
|
||||||
volumeLevel,
|
|
||||||
recognizedText,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
cancelRecording
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import {
|
|
||||||
ref,
|
|
||||||
onUnmounted,
|
|
||||||
readonly
|
|
||||||
} from 'vue';
|
|
||||||
|
|
||||||
const defaultExtractSpeechText = (text) => text;
|
|
||||||
|
|
||||||
|
|
||||||
export function useTTSPlayer() {
|
|
||||||
const synth = window.speechSynthesis;
|
|
||||||
const isSpeaking = ref(false);
|
|
||||||
const isPaused = ref(false);
|
|
||||||
const utteranceRef = ref(null);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
isSpeaking.value = false;
|
|
||||||
isPaused.value = false;
|
|
||||||
utteranceRef.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} text - The text to be spoken.
|
|
||||||
* @param {object} [options] - Optional settings for the speech.
|
|
||||||
* @param {string} [options.lang] - Language (e.g., 'en-US', 'es-ES').
|
|
||||||
* @param {number} [options.rate] - Speed (0.1 to 10, default 1).
|
|
||||||
* @param {number} [options.pitch] - Pitch (0 to 2, default 1).
|
|
||||||
* @param {SpeechSynthesisVoice} [options.voice] - A specific voice object.
|
|
||||||
* @param {function(string): string} [options.extractSpeechText] - A function to filter/clean the text before speaking.
|
|
||||||
*/
|
|
||||||
const speak = (text, options = {}) => {
|
|
||||||
if (!synth) {
|
|
||||||
console.error('SpeechSynthesis API is not supported in this browser.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSpeaking.value) {
|
|
||||||
synth.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredText = extractSpeechText(text);
|
|
||||||
|
|
||||||
if (!filteredText || typeof filteredText !== 'string' || filteredText.trim() === '') {
|
|
||||||
console.warn('Text to speak is empty after filtering.');
|
|
||||||
cleanup(); // Ensure state is clean
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUtterance = new SpeechSynthesisUtterance(filteredText); // Use filtered text
|
|
||||||
utteranceRef.value = newUtterance;
|
|
||||||
|
|
||||||
newUtterance.lang = 'zh-CN';
|
|
||||||
newUtterance.rate = options.rate || 1;
|
|
||||||
newUtterance.pitch = options.pitch || 1;
|
|
||||||
if (options.voice) {
|
|
||||||
newUtterance.voice = options.voice;
|
|
||||||
}
|
|
||||||
|
|
||||||
newUtterance.onstart = () => {
|
|
||||||
isSpeaking.value = true;
|
|
||||||
isPaused.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
newUtterance.onpause = () => {
|
|
||||||
isPaused.value = true;
|
|
||||||
};
|
|
||||||
newUtterance.onresume = () => {
|
|
||||||
isPaused.value = false;
|
|
||||||
};
|
|
||||||
newUtterance.onend = () => {
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
newUtterance.onerror = (event) => {
|
|
||||||
console.error('SpeechSynthesis Error:', event.error);
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
synth.speak(newUtterance);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pause = () => {
|
|
||||||
if (synth && isSpeaking.value && !isPaused.value) {
|
|
||||||
synth.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resume = () => {
|
|
||||||
if (synth && isPaused.value) {
|
|
||||||
synth.resume();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelAudio = () => {
|
|
||||||
if (synth) {
|
|
||||||
synth.cancel();
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
cancelAudio();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
speak,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
cancelAudio,
|
|
||||||
isSpeaking: readonly(isSpeaking),
|
|
||||||
isPaused: readonly(isPaused),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSpeechText(markdown) {
|
|
||||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
|
||||||
const jobs = [];
|
|
||||||
let match;
|
|
||||||
let lastJobEndIndex = 0;
|
|
||||||
let firstJobStartIndex = -1;
|
|
||||||
|
|
||||||
// 提取岗位 json 数据及前后位置
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取引导语(第一个 job-json 之前的文字)
|
|
||||||
const guideText = firstJobStartIndex > 0 ?
|
|
||||||
markdown.slice(0, firstJobStartIndex).trim() :
|
|
||||||
'';
|
|
||||||
|
|
||||||
// 提取结束语(最后一个 job-json 之后的文字)
|
|
||||||
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,203 +0,0 @@
|
|||||||
import {
|
|
||||||
ref,
|
|
||||||
readonly,
|
|
||||||
onUnmounted
|
|
||||||
} from 'vue';
|
|
||||||
|
|
||||||
// 检查 API 兼容性
|
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
||||||
const isApiSupported = !!SpeechRecognition && !!navigator.mediaDevices && !!window.AudioContext;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {string} [options.lang] - Language code (e.g., 'zh-CN', 'en-US')
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
export function useAudioRecorder(options = {}) {
|
|
||||||
const lang = options.lang || 'zh-CN'; // 默认使用中文
|
|
||||||
|
|
||||||
const isRecording = ref(false);
|
|
||||||
const recognizedText = ref(''); // 完整的识别文本(包含临时的)
|
|
||||||
const lastFinalText = ref(''); // 最后一段已确定的文本
|
|
||||||
const volumeLevel = ref(0); // 音量 (0-100)
|
|
||||||
const audioDataForDisplay = ref(new Uint8Array()); // 波形数据
|
|
||||||
|
|
||||||
let recognition = null;
|
|
||||||
let audioContext = null;
|
|
||||||
let analyser = null;
|
|
||||||
let mediaStreamSource = null;
|
|
||||||
let mediaStream = null;
|
|
||||||
let dataArray = null; // 用于音量和波形
|
|
||||||
let animationFrameId = null;
|
|
||||||
|
|
||||||
if (!isApiSupported) {
|
|
||||||
console.warn(
|
|
||||||
'此浏览器不支持Web语音API或Web音频API。钩子无法正常工作。'
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
isRecording: readonly(isRecording),
|
|
||||||
startRecording: () => console.error('Audio recording not supported.'),
|
|
||||||
stopRecording: () => {},
|
|
||||||
cancelRecording: () => {},
|
|
||||||
audioDataForDisplay: readonly(audioDataForDisplay),
|
|
||||||
volumeLevel: readonly(volumeLevel),
|
|
||||||
recognizedText: readonly(recognizedText),
|
|
||||||
lastFinalText: readonly(lastFinalText),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupRecognition = () => {
|
|
||||||
recognition = new SpeechRecognition();
|
|
||||||
recognition.lang = lang;
|
|
||||||
recognition.continuous = true; // 持续识别
|
|
||||||
recognition.interimResults = true; // 返回临时结果
|
|
||||||
|
|
||||||
recognition.onstart = () => {
|
|
||||||
isRecording.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onend = () => {
|
|
||||||
isRecording.value = false;
|
|
||||||
stopAudioAnalysis(); // 语音识别停止时,也停止音频分析
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onerror = (event) => {
|
|
||||||
console.error('SpeechRecognition Error:', event.error);
|
|
||||||
isRecording.value = false;
|
|
||||||
stopAudioAnalysis();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onresult = (event) => {
|
|
||||||
let interim = '';
|
|
||||||
let final = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < event.results.length; i++) {
|
|
||||||
const transcript = event.results[i][0].transcript;
|
|
||||||
if (event.results[i].isFinal) {
|
|
||||||
final += transcript;
|
|
||||||
lastFinalText.value = transcript; // 存储最后一段确定的文本
|
|
||||||
} else {
|
|
||||||
interim += transcript;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recognizedText.value = final + interim; // 组合为完整文本
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAudioAnalysis = async () => {
|
|
||||||
try {
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true
|
|
||||||
});
|
|
||||||
audioContext = new AudioContext();
|
|
||||||
analyser = audioContext.createAnalyser();
|
|
||||||
mediaStreamSource = audioContext.createMediaStreamSource(mediaStream);
|
|
||||||
|
|
||||||
// 设置 Analyser
|
|
||||||
analyser.fftSize = 512; // 必须是 2 的幂
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
dataArray = new Uint8Array(bufferLength); // 用于波形
|
|
||||||
|
|
||||||
// 连接节点
|
|
||||||
mediaStreamSource.connect(analyser);
|
|
||||||
|
|
||||||
// 开始循环分析
|
|
||||||
updateAudioData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get media stream or setup AudioContext:', err);
|
|
||||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
|
||||||
alert('麦克风权限被拒绝。请在浏览器设置中允许访问麦克风。');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAudioData = () => {
|
|
||||||
if (!isRecording.value) return; // 如果停止了就退出循环
|
|
||||||
|
|
||||||
// 获取时域数据 (波形)
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
|
||||||
audioDataForDisplay.value = new Uint8Array(dataArray); // 复制数组以触发响应式
|
|
||||||
|
|
||||||
// 计算音量 (RMS)
|
|
||||||
let sumSquares = 0.0;
|
|
||||||
for (const amplitude of dataArray) {
|
|
||||||
const normalized = (amplitude / 128.0) - 1.0; // 转换为 -1.0 到 1.0
|
|
||||||
sumSquares += normalized * normalized;
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
|
||||||
volumeLevel.value = Math.min(100, Math.floor(rms * 250)); // 放大 RMS 值到 0-100 范围
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(updateAudioData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopAudioAnalysis = () => {
|
|
||||||
if (animationFrameId) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
animationFrameId = null;
|
|
||||||
}
|
|
||||||
// 停止麦克风轨道
|
|
||||||
mediaStream?.getTracks().forEach((track) => track.stop());
|
|
||||||
// 关闭 AudioContext
|
|
||||||
audioContext?.close().catch((e) => console.error('Error closing AudioContext', e));
|
|
||||||
|
|
||||||
mediaStream = null;
|
|
||||||
audioContext = null;
|
|
||||||
analyser = null;
|
|
||||||
mediaStreamSource = null;
|
|
||||||
volumeLevel.value = 0;
|
|
||||||
audioDataForDisplay.value = new Uint8Array();
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRecording = async () => {
|
|
||||||
if (isRecording.value) return;
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
recognizedText.value = '';
|
|
||||||
lastFinalText.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 必须先启动音频分析以获取麦克风权限
|
|
||||||
await startAudioAnalysis();
|
|
||||||
|
|
||||||
// 如果音频启动成功 (mediaStream 存在),则启动语音识别
|
|
||||||
if (mediaStream) {
|
|
||||||
setupRecognition();
|
|
||||||
recognition.start();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error starting recording:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRecording = () => {
|
|
||||||
if (!isRecording.value || !recognition) return;
|
|
||||||
recognition.stop(); // 这将触发 onend 事件,自动停止音频分析
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelRecording = () => {
|
|
||||||
if (!recognition) return;
|
|
||||||
isRecording.value = false; // 立即设置状态
|
|
||||||
recognition.abort(); // 这也会触发 onend
|
|
||||||
recognizedText.value = '';
|
|
||||||
lastFinalText.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (recognition) {
|
|
||||||
recognition.abort();
|
|
||||||
}
|
|
||||||
stopAudioAnalysis();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRecording: readonly(isRecording),
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
cancelRecording,
|
|
||||||
audioDataForDisplay: readonly(audioDataForDisplay),
|
|
||||||
volumeLevel: readonly(volumeLevel),
|
|
||||||
recognizedText: readonly(recognizedText),
|
|
||||||
lastFinalText: readonly(lastFinalText),
|
|
||||||
isApiSupported, // 导出支持状态
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,217 +1,205 @@
|
|||||||
import {
|
import {
|
||||||
ref,
|
ref,
|
||||||
onUnmounted,
|
onUnmounted
|
||||||
onBeforeUnmount,
|
|
||||||
onMounted
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
onHide,
|
onHide,
|
||||||
onUnload
|
onUnload
|
||||||
} from '@dcloudio/uni-app'
|
} from '@dcloudio/uni-app'
|
||||||
import WavDecoder from '@/lib/wav-decoder@1.3.0.js'
|
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
|
import PiperTTS from './piper-bundle.js'
|
||||||
|
|
||||||
export function useTTSPlayer() {
|
export function useTTSPlayer() {
|
||||||
const isSpeaking = ref(false)
|
// UI 状态
|
||||||
const isPaused = ref(false)
|
const isSpeaking = ref(false) // 是否正在交互(含播放、暂停、加载)
|
||||||
const isComplete = ref(false)
|
const isPaused = ref(false) // 是否处于暂停状态
|
||||||
|
const isLoading = ref(false) // 是否正在加载/连接
|
||||||
|
|
||||||
const audioContext = new(window.AudioContext || window.webkitAudioContext)()
|
// 单例 Piper 实例
|
||||||
let playTime = audioContext.currentTime
|
let piper = null
|
||||||
let sourceNodes = []
|
|
||||||
let socket = null
|
|
||||||
let sampleRate = 16000
|
|
||||||
let numChannels = 1
|
|
||||||
let isHeaderDecoded = false
|
|
||||||
let pendingText = null
|
|
||||||
|
|
||||||
let currentPlayId = 0
|
/**
|
||||||
let activePlayId = 0
|
* 获取或创建 SDK 实例
|
||||||
|
*/
|
||||||
|
const getPiperInstance = () => {
|
||||||
|
if (!piper) {
|
||||||
|
let baseUrl = config.speechSynthesis2 || ''
|
||||||
|
baseUrl = baseUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
const speak = (text) => {
|
piper = new PiperTTS({
|
||||||
currentPlayId++
|
baseUrl: baseUrl,
|
||||||
const myPlayId = currentPlayId
|
sampleRate: 16000,
|
||||||
reset()
|
onStatus: (msg, type) => {
|
||||||
pendingText = text
|
if (type === 'error') {
|
||||||
activePlayId = myPlayId
|
console.error('[TTS Error]', msg)
|
||||||
}
|
resetState()
|
||||||
|
|
||||||
const pause = () => {
|
|
||||||
if (audioContext.state === 'running') {
|
|
||||||
audioContext.suspend()
|
|
||||||
isPaused.value = true
|
|
||||||
isSpeaking.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resume = () => {
|
|
||||||
if (audioContext.state === 'suspended') {
|
|
||||||
audioContext.resume()
|
|
||||||
isPaused.value = false
|
|
||||||
isSpeaking.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelAudio = () => {
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
isSpeaking.value = false
|
|
||||||
isPaused.value = false
|
|
||||||
isComplete.value = false
|
|
||||||
playTime = audioContext.currentTime
|
|
||||||
|
|
||||||
sourceNodes.forEach(node => {
|
|
||||||
try {
|
|
||||||
node.stop()
|
|
||||||
node.disconnect()
|
|
||||||
} catch (e) {}
|
|
||||||
})
|
|
||||||
sourceNodes = []
|
|
||||||
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
isHeaderDecoded = false
|
|
||||||
pendingText = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
stop()
|
|
||||||
isSpeaking.value = false
|
|
||||||
isPaused.value = false
|
|
||||||
isComplete.value = false
|
|
||||||
playTime = audioContext.currentTime
|
|
||||||
initWebSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
const initWebSocket = () => {
|
|
||||||
const thisPlayId = currentPlayId
|
|
||||||
socket = new WebSocket(config.speechSynthesis)
|
|
||||||
socket.binaryType = 'arraybuffer'
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
if (pendingText && thisPlayId === activePlayId) {
|
|
||||||
const seepdText = extractSpeechText(pendingText)
|
|
||||||
console.log(seepdText)
|
|
||||||
socket.send(seepdText)
|
|
||||||
pendingText = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = async (e) => {
|
|
||||||
if (thisPlayId !== activePlayId) return // 忽略旧播放的消息
|
|
||||||
|
|
||||||
if (typeof e.data === 'string') {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data)
|
|
||||||
if (msg.status === 'complete') {
|
|
||||||
isComplete.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
if (thisPlayId === activePlayId) {
|
|
||||||
isSpeaking.value = false
|
|
||||||
}
|
|
||||||
}, (playTime - audioContext.currentTime) * 1000)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
console.log('[TTSPlayer] 文本消息:', e.data)
|
onStart: () => {
|
||||||
|
isLoading.value = false
|
||||||
|
isSpeaking.value = true
|
||||||
|
isPaused.value = false
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
// 只有非暂停状态下的结束,才重置所有状态
|
||||||
|
// 如果是用户手动暂停导致的中断,不应视为自然播放结束
|
||||||
|
isSpeaking.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
isPaused.value = false
|
||||||
}
|
}
|
||||||
} else if (e.data instanceof ArrayBuffer) {
|
})
|
||||||
if (!isHeaderDecoded) {
|
|
||||||
try {
|
|
||||||
const decoded = await WavDecoder.decode(e.data)
|
|
||||||
sampleRate = decoded.sampleRate
|
|
||||||
numChannels = decoded.channelData.length
|
|
||||||
decoded.channelData.forEach((channel, i) => {
|
|
||||||
const audioBuffer = audioContext.createBuffer(1, channel.length,
|
|
||||||
sampleRate)
|
|
||||||
audioBuffer.copyToChannel(channel, 0)
|
|
||||||
playBuffer(audioBuffer)
|
|
||||||
})
|
|
||||||
isHeaderDecoded = true
|
|
||||||
} catch (err) {
|
|
||||||
console.error('WAV 解码失败:', err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const pcm = new Int16Array(e.data)
|
|
||||||
const audioBuffer = pcmToAudioBuffer(pcm, sampleRate, numChannels)
|
|
||||||
playBuffer(audioBuffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return piper
|
||||||
}
|
}
|
||||||
|
|
||||||
const pcmToAudioBuffer = (pcm, sampleRate, numChannels) => {
|
/**
|
||||||
const length = pcm.length / numChannels
|
* 核心朗读方法
|
||||||
const audioBuffer = audioContext.createBuffer(numChannels, length, sampleRate)
|
*/
|
||||||
for (let ch = 0; ch < numChannels; ch++) {
|
const speak = async (text) => {
|
||||||
const channelData = audioBuffer.getChannelData(ch)
|
if (!text) return
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const sample = pcm[i * numChannels + ch]
|
|
||||||
channelData[i] = sample / 32768
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return audioBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
const playBuffer = (audioBuffer) => {
|
const processedText = extractSpeechText(text)
|
||||||
if (!isSpeaking.value) {
|
if (!processedText) return
|
||||||
playTime = audioContext.currentTime
|
|
||||||
}
|
const instance = getPiperInstance()
|
||||||
const source = audioContext.createBufferSource()
|
|
||||||
source.buffer = audioBuffer
|
// 重置状态
|
||||||
source.connect(audioContext.destination)
|
isLoading.value = true
|
||||||
source.start(playTime)
|
isPaused.value = false
|
||||||
sourceNodes.push(source)
|
|
||||||
playTime += audioBuffer.duration
|
|
||||||
isSpeaking.value = true
|
isSpeaking.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 直接调用 speak,SDK 内部会自动处理 init 和 stop
|
||||||
|
await instance.speak(processedText, {
|
||||||
|
speakerId: 0,
|
||||||
|
noiseScale: 0.667,
|
||||||
|
lengthScale: 1.0
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('TTS Speak Error:', e)
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
/**
|
||||||
stop()
|
* 暂停
|
||||||
})
|
*/
|
||||||
|
const pause = async () => {
|
||||||
|
// 1. 只有正在播放且未暂停时,才执行暂停
|
||||||
|
if (!isSpeaking.value || isPaused.value) return
|
||||||
|
|
||||||
// 页面刷新/关闭时
|
// 2. 检查播放器实例是否存在
|
||||||
onMounted(() => {
|
if (piper && piper.player) {
|
||||||
if (typeof window !== 'undefined') {
|
try {
|
||||||
window.addEventListener('beforeunload', cancelAudio)
|
// 执行音频挂起
|
||||||
|
await piper.player.pause()
|
||||||
|
// 3. 成功后更新 UI
|
||||||
|
isPaused.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Pause failed:", e)
|
||||||
|
// 即使报错,如果不是致命错误,也可以尝试强制更新 UI
|
||||||
|
// isPaused.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
/**
|
||||||
cancelAudio()
|
* 恢复 (继续播放)
|
||||||
if (typeof window !== 'undefined') {
|
*/
|
||||||
window.removeEventListener('beforeunload', cancelAudio)
|
const resume = async () => {
|
||||||
|
// 1. 只有处于暂停状态时,才执行恢复
|
||||||
|
if (!isPaused.value) return
|
||||||
|
|
||||||
|
if (piper && piper.player) {
|
||||||
|
try {
|
||||||
|
await piper.player.continue()
|
||||||
|
// 2. 成功后更新 UI
|
||||||
|
isPaused.value = false
|
||||||
|
isSpeaking.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Resume failed:", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
onHide(cancelAudio)
|
/**
|
||||||
onUnload(cancelAudio)
|
* 切换 播放/暂停 (方便按钮绑定)
|
||||||
|
*/
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isPaused.value) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initWebSocket()
|
/**
|
||||||
|
* 停止 (中断)
|
||||||
|
*/
|
||||||
|
const stop = () => {
|
||||||
|
if (piper) {
|
||||||
|
piper.stop()
|
||||||
|
}
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 彻底销毁
|
||||||
|
*/
|
||||||
|
const destroy = () => {
|
||||||
|
if (piper) {
|
||||||
|
piper.stop()
|
||||||
|
piper = null
|
||||||
|
}
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
isSpeaking.value = false
|
||||||
|
isPaused.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 生命周期管理 ===
|
||||||
|
|
||||||
|
onUnmounted(destroy)
|
||||||
|
|
||||||
|
if (typeof onHide === 'function') {
|
||||||
|
onHide(() => {
|
||||||
|
togglePlay()
|
||||||
|
// stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onUnload === 'function') {
|
||||||
|
onUnload(destroy)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
speak,
|
speak,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
cancelAudio,
|
togglePlay, // 新增:单按钮切换功能
|
||||||
|
stop,
|
||||||
|
cancelAudio: stop,
|
||||||
isSpeaking,
|
isSpeaking,
|
||||||
isPaused,
|
isPaused,
|
||||||
isComplete
|
isLoading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本提取工具函数 (保持原样)
|
||||||
|
*/
|
||||||
function extractSpeechText(markdown) {
|
function extractSpeechText(markdown) {
|
||||||
|
if (!markdown || markdown.indexOf('job-json') === -1) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||||
const jobs = [];
|
const jobs = [];
|
||||||
let match;
|
let match;
|
||||||
let lastJobEndIndex = 0;
|
let lastJobEndIndex = 0;
|
||||||
let firstJobStartIndex = -1;
|
let firstJobStartIndex = -1;
|
||||||
|
|
||||||
// 提取岗位 json 数据及前后位置
|
|
||||||
while ((match = jobRegex.exec(markdown)) !== null) {
|
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||||
const jobStr = match[1];
|
const jobStr = match[1];
|
||||||
try {
|
try {
|
||||||
@@ -225,27 +213,16 @@ function extractSpeechText(markdown) {
|
|||||||
console.warn('JSON 解析失败', e);
|
console.warn('JSON 解析失败', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取引导语(第一个 job-json 之前的文字)
|
|
||||||
const guideText = firstJobStartIndex > 0 ?
|
const guideText = firstJobStartIndex > 0 ?
|
||||||
markdown.slice(0, firstJobStartIndex).trim() :
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
||||||
'';
|
|
||||||
|
|
||||||
// 提取结束语(最后一个 job-json 之后的文字)
|
|
||||||
const endingText = lastJobEndIndex < markdown.length ?
|
const endingText = lastJobEndIndex < markdown.length ?
|
||||||
markdown.slice(lastJobEndIndex).trim() :
|
markdown.slice(lastJobEndIndex).trim() : '';
|
||||||
'';
|
|
||||||
|
|
||||||
// 岗位信息格式化为语音文本
|
|
||||||
const jobTexts = jobs.map((job, index) => {
|
const jobTexts = jobs.map((job, index) => {
|
||||||
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拼接总语音内容
|
|
||||||
const finalTextParts = [];
|
const finalTextParts = [];
|
||||||
if (guideText) finalTextParts.push(guideText);
|
if (guideText) finalTextParts.push(guideText);
|
||||||
finalTextParts.push(...jobTexts);
|
finalTextParts.push(...jobTexts);
|
||||||
if (endingText) finalTextParts.push(endingText);
|
if (endingText) finalTextParts.push(endingText);
|
||||||
|
|
||||||
return finalTextParts.join('\n');
|
return finalTextParts.join('\n');
|
||||||
}
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import {
|
|
||||||
ref,
|
|
||||||
onUnmounted,
|
|
||||||
onMounted,
|
|
||||||
watch
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
|
||||||
onHide,
|
|
||||||
onUnload
|
|
||||||
} from '@dcloudio/uni-app'
|
|
||||||
import config from '@/config'
|
|
||||||
// 请确保 piper-sdk.js 已经正确 export class PiperTTS
|
|
||||||
import {
|
|
||||||
PiperTTS
|
|
||||||
} from './piper-sdk.js'
|
|
||||||
|
|
||||||
export function useTTSPlayer() {
|
|
||||||
// UI 状态
|
|
||||||
const isSpeaking = ref(false)
|
|
||||||
const isPaused = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
|
|
||||||
// SDK 实例
|
|
||||||
let piper = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 SDK 实例
|
|
||||||
* 每次 stop 后 piper 会被置空,这里会重新创建
|
|
||||||
*/
|
|
||||||
const initPiper = () => {
|
|
||||||
if (piper) return
|
|
||||||
|
|
||||||
let baseUrl = config.speechSynthesis2 || ''
|
|
||||||
baseUrl = baseUrl.replace(/\/$/, '')
|
|
||||||
|
|
||||||
piper = new PiperTTS({
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
onStatus: (msg, type) => {
|
|
||||||
if (type === 'error') {
|
|
||||||
console.error('[TTS Error]', msg)
|
|
||||||
// 出错时不重置状态,交给用户手动处理或结束事件处理
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStart: () => {
|
|
||||||
isLoading.value = false
|
|
||||||
isSpeaking.value = true
|
|
||||||
isPaused.value = false
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 核心朗读方法
|
|
||||||
*/
|
|
||||||
const speak = async (text) => {
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
const processedText = extractSpeechText(text)
|
|
||||||
if (!processedText) return
|
|
||||||
|
|
||||||
// 1. 【关键修改】先彻底停止并销毁旧实例
|
|
||||||
// 这会断开 socket 并且 close AudioContext,确保上一个声音立即消失
|
|
||||||
await stop()
|
|
||||||
|
|
||||||
// 2. 初始化新实例 (因为 stop() 把 piper 设为了 null)
|
|
||||||
initPiper()
|
|
||||||
|
|
||||||
// 3. 更新 UI 为加载中
|
|
||||||
isLoading.value = true
|
|
||||||
isPaused.value = false
|
|
||||||
isSpeaking.value = true // 预先设为 true,防止按钮闪烁
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 4. 激活音频引擎 (移动端防静音关键)
|
|
||||||
await piper.init()
|
|
||||||
|
|
||||||
// 5. 发送请求
|
|
||||||
piper.speak(processedText, {
|
|
||||||
speakerId: 0,
|
|
||||||
noiseScale: 0.667,
|
|
||||||
lengthScale: 1.0
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('TTS Speak Error:', e)
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 暂停
|
|
||||||
*/
|
|
||||||
const pause = async () => {
|
|
||||||
if (piper && piper.audioCtx && piper.audioCtx.state === 'running') {
|
|
||||||
await piper.audioCtx.suspend()
|
|
||||||
isPaused.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 恢复
|
|
||||||
*/
|
|
||||||
const resume = async () => {
|
|
||||||
if (piper && piper.audioCtx && piper.audioCtx.state === 'suspended') {
|
|
||||||
await piper.audioCtx.resume()
|
|
||||||
isPaused.value = false
|
|
||||||
isSpeaking.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止并重置 (核打击模式)
|
|
||||||
*/
|
|
||||||
const stop = async () => {
|
|
||||||
if (piper) {
|
|
||||||
// 1. 断开 WebSocket
|
|
||||||
piper.stop()
|
|
||||||
|
|
||||||
// 2. 【关键】关闭 AudioContext
|
|
||||||
// Web Audio API 中,已经 schedule 的 buffer 很难单独取消
|
|
||||||
// 最直接的方法是关闭整个 Context
|
|
||||||
if (piper.audioCtx && piper.audioCtx.state !== 'closed') {
|
|
||||||
try {
|
|
||||||
await piper.audioCtx.close()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('AudioContext close failed', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 销毁实例引用
|
|
||||||
piper = null
|
|
||||||
}
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI 状态重置
|
|
||||||
const resetState = () => {
|
|
||||||
isSpeaking.value = false
|
|
||||||
isPaused.value = false
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 生命周期 ===
|
|
||||||
onMounted(() => {
|
|
||||||
// 预初始化可以不做,等到点击时再做,避免空闲占用 AudioContext 资源
|
|
||||||
// initPiper()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Uniapp 生命周期
|
|
||||||
if (typeof onHide === 'function') onHide(stop)
|
|
||||||
if (typeof onUnload === 'function') onUnload(stop)
|
|
||||||
|
|
||||||
return {
|
|
||||||
speak,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
stop,
|
|
||||||
cancelAudio: stop,
|
|
||||||
isSpeaking,
|
|
||||||
isPaused,
|
|
||||||
isLoading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取文本逻辑 (保持不变)
|
|
||||||
*/
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
210
manifest.json
210
manifest.json
@@ -1,105 +1,105 @@
|
|||||||
{
|
{
|
||||||
"name": "qingdao-employment-service",
|
"name" : "qingdao-employment-service",
|
||||||
"appid": "__UNI__C939371",
|
"appid" : "__UNI__C939371",
|
||||||
"description": "招聘",
|
"description" : "招聘",
|
||||||
"versionName": "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
"versionCode": "100",
|
"versionCode" : "100",
|
||||||
"transformPx": false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus": {
|
"app-plus" : {
|
||||||
"usingComponents": true,
|
"usingComponents" : true,
|
||||||
"nvueStyleCompiler": "uni-app",
|
"nvueStyleCompiler" : "uni-app",
|
||||||
"compilerVersion": 3,
|
"compilerVersion" : 3,
|
||||||
"splashscreen": {
|
"splashscreen" : {
|
||||||
"alwaysShowBeforeRender": true,
|
"alwaysShowBeforeRender" : true,
|
||||||
"waiting": true,
|
"waiting" : true,
|
||||||
"autoclose": true,
|
"autoclose" : true,
|
||||||
"delay": 0
|
"delay" : 0
|
||||||
},
|
},
|
||||||
/* 模块配置 */
|
/* 模块配置 */
|
||||||
"modules": {},
|
"modules" : {},
|
||||||
/* 应用发布信息 */
|
/* 应用发布信息 */
|
||||||
"distribute": {
|
"distribute" : {
|
||||||
/* android打包配置 */
|
/* android打包配置 */
|
||||||
"android": {
|
"android" : {
|
||||||
"permissions": [
|
"permissions" : [
|
||||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
/* ios打包配置 */
|
/* ios打包配置 */
|
||||||
"ios": {},
|
"ios" : {},
|
||||||
/* SDK配置 */
|
/* SDK配置 */
|
||||||
"sdkConfigs": {}
|
"sdkConfigs" : {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/* 快应用特有相关 */
|
/* 快应用特有相关 */
|
||||||
"quickapp": {},
|
"quickapp" : {},
|
||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin": {
|
"mp-weixin" : {
|
||||||
"appid": "",
|
"appid" : "",
|
||||||
"setting": {
|
"setting" : {
|
||||||
"urlCheck": false,
|
"urlCheck" : false,
|
||||||
"es6": true,
|
"es6" : true,
|
||||||
"postcss": true,
|
"postcss" : true,
|
||||||
"minified": true
|
"minified" : true
|
||||||
},
|
},
|
||||||
"usingComponents": true,
|
"usingComponents" : true,
|
||||||
"permission": {
|
"permission" : {
|
||||||
"scope.userLocation": {
|
"scope.userLocation" : {
|
||||||
"desc": "用于用户选择地图查看位置"
|
"desc" : "用于用户选择地图查看位置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mp-alipay": {
|
"mp-alipay" : {
|
||||||
"usingComponents": true
|
"usingComponents" : true
|
||||||
},
|
},
|
||||||
"mp-baidu": {
|
"mp-baidu" : {
|
||||||
"usingComponents": true
|
"usingComponents" : true
|
||||||
},
|
},
|
||||||
"mp-toutiao": {
|
"mp-toutiao" : {
|
||||||
"usingComponents": true
|
"usingComponents" : true
|
||||||
},
|
},
|
||||||
"uniStatistics": {
|
"uniStatistics" : {
|
||||||
"enable": false
|
"enable" : false
|
||||||
},
|
},
|
||||||
"vueVersion": "3",
|
"vueVersion" : "3",
|
||||||
"locale": "zh-Hans",
|
"locale" : "zh-Hans",
|
||||||
"h5": {
|
"h5" : {
|
||||||
"router": {
|
"router" : {
|
||||||
"base": "./",
|
"base" : "./",
|
||||||
"mode": "hash"
|
"mode" : "hash"
|
||||||
},
|
},
|
||||||
"title": "青岛智慧就业服务",
|
"title" : "青岛智慧就业服务",
|
||||||
"optimization": {
|
"optimization" : {
|
||||||
"treeShaking": {
|
"treeShaking" : {
|
||||||
"enable": true
|
"enable" : true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sdkConfigs": {
|
"sdkConfigs" : {
|
||||||
"maps": {
|
"maps" : {
|
||||||
"amap": {
|
"amap" : {
|
||||||
"key": "9cfc9370bd8a941951da1cea0308e9e3",
|
"key" : "9cfc9370bd8a941951da1cea0308e9e3",
|
||||||
"securityJsCode": "7b16386c7f744c3ca05595965f2b037f",
|
"securityJsCode" : "7b16386c7f744c3ca05595965f2b037f",
|
||||||
"serviceHost": ""
|
"serviceHost" : ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devServer": {
|
"devServer" : {
|
||||||
"https": false
|
"https" : false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<image src="@/static/icon/back.png" @click="navBack"></image>
|
<image src="@/static/icon/back.png" @click="navBack"></image>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
<view v-show="searchFocus" class="search-mask" ></view>
|
||||||
<view class="main">
|
<view class="main">
|
||||||
<view class="content-title">
|
<view class="content-title">
|
||||||
<view class="title-lf">
|
<view class="title-lf">
|
||||||
@@ -74,7 +75,7 @@ const { $api, navBack } = inject('globalFunction');
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { getUserResume } = useUserStore();
|
const { getUserResume } = useUserStore();
|
||||||
const { userInfo, isMiniProgram } = storeToRefs(useUserStore());
|
const { userInfo, isMiniProgram ,hasLogin,isMachineEnv} = storeToRefs(useUserStore());
|
||||||
const popup = ref(null);
|
const popup = ref(null);
|
||||||
const selectJobsModel = ref(null);
|
const selectJobsModel = ref(null);
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ const dataSource = ref([]);
|
|||||||
const filterList = ref([]);
|
const filterList = ref([]);
|
||||||
const dataItem = ref(null);
|
const dataItem = ref(null);
|
||||||
const inputVal = ref('');
|
const inputVal = ref('');
|
||||||
|
const searchFocus = ref(false);
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
getTree();
|
getTree();
|
||||||
@@ -93,12 +95,14 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleBlur() {
|
function handleBlur() {
|
||||||
|
searchFocus.value = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
filterList.value = [];
|
filterList.value = [];
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
|
searchFocus.value = true
|
||||||
const val = inputVal.value.toLowerCase();
|
const val = inputVal.value.toLowerCase();
|
||||||
if (val && dataSource.value) {
|
if (val && dataSource.value) {
|
||||||
filterList.value = dataSource.value.filter((item) => item.lable.toLowerCase().search(val) !== -1);
|
filterList.value = dataSource.value.filter((item) => item.lable.toLowerCase().search(val) !== -1);
|
||||||
@@ -141,6 +145,10 @@ function deleteItem(item, index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeJobs() {
|
function changeJobs() {
|
||||||
|
if(isMachineEnv.value && !hasLogin.value){
|
||||||
|
useUserStore().logOut()
|
||||||
|
return
|
||||||
|
}
|
||||||
selectJobsModel.value?.open({
|
selectJobsModel.value?.open({
|
||||||
title: '添加岗位',
|
title: '添加岗位',
|
||||||
maskClick: true,
|
maskClick: true,
|
||||||
@@ -217,6 +225,7 @@ function flattenTree(treeData, parentPath = '') {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index:10;
|
||||||
.search-input{
|
.search-input{
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
@@ -261,6 +270,15 @@ function flattenTree(treeData, parentPath = '') {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.search-mask{
|
||||||
|
position fixed;
|
||||||
|
width 100vw;
|
||||||
|
height:100vh;
|
||||||
|
top:0;
|
||||||
|
left:0
|
||||||
|
z-index:9;
|
||||||
|
background: #33333355;
|
||||||
|
}
|
||||||
.main{
|
.main{
|
||||||
padding: 54rpx 28rpx 28rpx 28rpx
|
padding: 54rpx 28rpx 28rpx 28rpx
|
||||||
.content-list{
|
.content-list{
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const { userInfo } = storeToRefs(useUserStore());
|
|||||||
const { getUserResume } = useUserStore();
|
const { getUserResume } = useUserStore();
|
||||||
const { dictLabel, oneDictData } = useDictStore();
|
const { dictLabel, oneDictData } = useDictStore();
|
||||||
const openSelectPopup = inject('openSelectPopup');
|
const openSelectPopup = inject('openSelectPopup');
|
||||||
|
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||||
|
|
||||||
const percent = ref('0%');
|
const percent = ref('0%');
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -278,15 +279,26 @@ function selectAvatar() {
|
|||||||
sizeType: ['original', 'compressed'],
|
sizeType: ['original', 'compressed'],
|
||||||
sourceType: ['album', 'camera'],
|
sourceType: ['album', 'camera'],
|
||||||
count: 1,
|
count: 1,
|
||||||
success: ({ tempFilePaths, tempFiles }) => {
|
success: async (res) => {
|
||||||
$api.uploadFile(tempFilePaths[0], true)
|
const tempFilePaths = res.tempFilePaths;
|
||||||
.then((res) => {
|
const file = res.tempFiles[0];
|
||||||
res = JSON.parse(res);
|
|
||||||
if (res.msg) fromValue.avatar = res.msg;
|
const imageValidator = new FileValidator();
|
||||||
})
|
|
||||||
.catch((err) => {
|
try {
|
||||||
$api.msg('上传失败');
|
await imageValidator.validate(file);
|
||||||
});
|
|
||||||
|
$api.uploadFile(tempFilePaths[0], true)
|
||||||
|
.then((res) => {
|
||||||
|
res = JSON.parse(res);
|
||||||
|
if (res.msg) fromValue.avatar = res.msg;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
$api.msg('上传失败');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$api.msg(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
fail: (error) => {},
|
fail: (error) => {},
|
||||||
});
|
});
|
||||||
@@ -392,4 +404,4 @@ function selectAvatar() {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 90rpx
|
line-height: 90rpx
|
||||||
</style>
|
</style>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
class="msg-files btn-light"
|
class="msg-files btn-light"
|
||||||
v-for="(file, vInex) in msg.files"
|
v-for="(file, vInex) in msg.files"
|
||||||
:key="vInex"
|
:key="vInex"
|
||||||
@click="jumpUrl(file)"
|
@click="handleMsgFileClick(file)"
|
||||||
>
|
>
|
||||||
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
|
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
|
||||||
<text class="msg-file-text">{{ file.name || '附件' }}</text>
|
<text class="msg-file-text">{{ file.name || '附件' }}</text>
|
||||||
@@ -210,15 +210,15 @@
|
|||||||
<view class="uploadfiles-list">
|
<view class="uploadfiles-list">
|
||||||
<view
|
<view
|
||||||
class="file-uploadsend"
|
class="file-uploadsend"
|
||||||
:class="{ 'file-border': isImage(file.type) }"
|
|
||||||
v-for="(file, index) in filesList"
|
v-for="(file, index) in filesList"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
class="file-iconImg"
|
class="file-iconImg"
|
||||||
@click="jumpUrl(file)"
|
@click="preViewImage(file)"
|
||||||
v-if="isImage(file.type)"
|
v-if="isImage(file.type)"
|
||||||
:src="file.url"
|
:src="file.url"
|
||||||
|
mode="heightFix"
|
||||||
></image>
|
></image>
|
||||||
<view class="file-doc" @click="jumpUrl(file)" v-else>
|
<view class="file-doc" @click="jumpUrl(file)" v-else>
|
||||||
<FileIcon class="doc-icon" :type="file.type"></FileIcon>
|
<FileIcon class="doc-icon" :type="file.type"></FileIcon>
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<PopupFeeBack ref="feeback" @onClose="colseFeeBack" @onSend="confirmFeeBack"></PopupFeeBack>
|
<PopupFeeBack ref="feeback" @onClose="colseFeeBack" @onSend="confirmFeeBack"></PopupFeeBack>
|
||||||
<UploadQrcode ref="qrcodeRef" @onSend="handleFileSend" :sessionId="chatSessionID"></UploadQrcode>
|
<UploadQrcode ref="qrcodeRef" @onSend="handleFileSend" :leaveFileCount="leaveFileCount" :sessionId="chatSessionID"></UploadQrcode>
|
||||||
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
|
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -272,10 +272,8 @@ import FileText from './fileText.vue';
|
|||||||
import useScreenStore from '@/stores/useScreenStore'
|
import useScreenStore from '@/stores/useScreenStore'
|
||||||
const screenStore = useScreenStore();
|
const screenStore = useScreenStore();
|
||||||
// 系统功能hook和阿里云hook
|
// 系统功能hook和阿里云hook
|
||||||
import { useAudioRecorder } from '@/hook/useRealtimeRecorder2.js';
|
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
|
||||||
// import { useAudioRecorder } from '@/hook/useSystemSpeechReader.js';
|
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
|
||||||
import { useTTSPlayer } from '@/hook/useTTSPlayer2.js';
|
|
||||||
// import { useTTSPlayer } from '@/hook/useSystemPlayer.js';
|
|
||||||
// 全局
|
// 全局
|
||||||
const { $api, navTo, throttle } = inject('globalFunction');
|
const { $api, navTo, throttle } = inject('globalFunction');
|
||||||
const emit = defineEmits(['onConfirm']);
|
const emit = defineEmits(['onConfirm']);
|
||||||
@@ -283,6 +281,8 @@ const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGrou
|
|||||||
import successIcon from '@/static/icon/success.png';
|
import successIcon from '@/static/icon/success.png';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
|
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||||
// hook
|
// hook
|
||||||
// 语音识别
|
// 语音识别
|
||||||
const {
|
const {
|
||||||
@@ -341,6 +341,10 @@ const audiowaveStyle = computed(() => {
|
|||||||
: '#f1f1f1';
|
: '#f1f1f1';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const leaveFileCount = computed(()=>{ //还剩多少文件可以上传 给扫码传参使用
|
||||||
|
return config.allowedFileNumber - filesList.value.length
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
changeQueries();
|
changeQueries();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -470,7 +474,8 @@ function isImage(type) {
|
|||||||
'apng', 'avif', 'heic', 'heif', 'jfif'
|
'apng', 'avif', 'heic', 'heif', 'jfif'
|
||||||
];
|
];
|
||||||
const lowerType = type.toLowerCase();
|
const lowerType = type.toLowerCase();
|
||||||
return imageTypes.includes(lowerType);
|
|
||||||
|
return imageTypes.some(item=>lowerType.includes(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFile(type) {
|
function isFile(type) {
|
||||||
@@ -483,11 +488,40 @@ function isFile(type) {
|
|||||||
|
|
||||||
function jumpUrl(file) {
|
function jumpUrl(file) {
|
||||||
if (file.url) {
|
if (file.url) {
|
||||||
window.open(file.url);
|
if(!isMachineEnv.value)window.open(file.url);
|
||||||
|
else $api.msg('该文件无法预览');
|
||||||
} else {
|
} else {
|
||||||
$api.msg('文件地址丢失');
|
$api.msg('文件地址丢失');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function preViewImage(file) {
|
||||||
|
if (file.url) {
|
||||||
|
uni.previewImage({
|
||||||
|
urls: [file.url]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
$api.msg('文件地址丢失');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleMsgFileClick(file) {
|
||||||
|
if(isImage(file.type)){
|
||||||
|
if (file.url) {
|
||||||
|
uni.previewImage({
|
||||||
|
urls: [file.url]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
$api.msg('文件地址丢失');
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
if (file.url) {
|
||||||
|
if(!isMachineEnv.value)window.open(file.url);
|
||||||
|
else $api.msg('该文件无法预览');
|
||||||
|
} else {
|
||||||
|
$api.msg('文件地址丢失');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function VerifyNumberFiles(num) {
|
function VerifyNumberFiles(num) {
|
||||||
if (filesList.value.length >= config.allowedFileNumber) {
|
if (filesList.value.length >= config.allowedFileNumber) {
|
||||||
@@ -504,21 +538,29 @@ function uploadCamera(type = 'camera') {
|
|||||||
count: 1, //默认9
|
count: 1, //默认9
|
||||||
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
|
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
|
||||||
sourceType: [type], //从相册选择
|
sourceType: [type], //从相册选择
|
||||||
success: function (res) {
|
success: async (res)=> {
|
||||||
const tempFilePaths = res.tempFilePaths;
|
const tempFilePaths = res.tempFilePaths;
|
||||||
const file = res.tempFiles[0];
|
const file = res.tempFiles[0];
|
||||||
// 继续上传
|
|
||||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
const imageValidator = new FileValidator()
|
||||||
resData = JSON.parse(resData);
|
try {
|
||||||
if (isImage(file.type)) {
|
await imageValidator.validate(file)
|
||||||
filesList.value.push({
|
|
||||||
url: resData.msg,
|
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||||
type: file.type,
|
resData = JSON.parse(resData);
|
||||||
name: file.name,
|
console.log(file.type,'++')
|
||||||
});
|
if (isImage(file.type)) {
|
||||||
textInput.value = state.uploadFileTips;
|
filesList.value.push({
|
||||||
}
|
url: resData.msg,
|
||||||
});
|
type: file.type,
|
||||||
|
name: file.name,
|
||||||
|
});
|
||||||
|
textInput.value = state.uploadFileTips;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$api.msg(error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -527,44 +569,48 @@ function getUploadFile(type = 'camera') {
|
|||||||
if (VerifyNumberFiles()) return;
|
if (VerifyNumberFiles()) return;
|
||||||
uni.chooseFile({
|
uni.chooseFile({
|
||||||
count: 1,
|
count: 1,
|
||||||
success: (res) => {
|
success: async(res) => {
|
||||||
const tempFilePaths = res.tempFilePaths;
|
const tempFilePaths = res.tempFilePaths;
|
||||||
const file = res.tempFiles[0];
|
const file = res.tempFiles[0];
|
||||||
const allowedTypes = config.allowedFileTypes || [];
|
const allowedTypes = config.allowedFileTypes || [];
|
||||||
const size = $api.formatFileSize(file.size);
|
const size = $api.formatFileSize(file.size);
|
||||||
if (!allowedTypes.includes(file.type)) {
|
|
||||||
return $api.msg('仅支持 txt md html word pdf ppt csv excel 格式类型');
|
const imageValidator = new FileValidator({allowedExtensions:config.allowedFileTypes})
|
||||||
}
|
|
||||||
// 继续上传
|
try{
|
||||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
await imageValidator.validate(file)
|
||||||
resData = JSON.parse(resData);
|
|
||||||
filesList.value.push({
|
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||||
url: resData.msg,
|
resData = JSON.parse(resData);
|
||||||
type: file.type,
|
filesList.value.push({
|
||||||
name: file.name,
|
url: resData.msg,
|
||||||
size: size,
|
type: file.type,
|
||||||
|
name: file.name,
|
||||||
|
size: size,
|
||||||
|
});
|
||||||
|
textInput.value = state.uploadFileTips;
|
||||||
});
|
});
|
||||||
textInput.value = state.uploadFileTips;
|
}catch(error){
|
||||||
});
|
$api.msg(error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileSend = (rows)=>{
|
const handleFileSend = (rows)=>{
|
||||||
filesList.value = []
|
|
||||||
try {
|
try {
|
||||||
rows.map(item=>{
|
rows.map(item=>{
|
||||||
if(isImage(item.fileSuffix)){
|
if(isImage(item.fileSuffix)){
|
||||||
filesList.value.push({
|
filesList.value.push({
|
||||||
url: item.fileUrl,
|
url: item.fileUrl,
|
||||||
type: item.fileSuffix,
|
type: item.fileSuffix,
|
||||||
name: item.fileName,
|
name: item.originalName,
|
||||||
});
|
});
|
||||||
}else{
|
}else{
|
||||||
filesList.value.push({
|
filesList.value.push({
|
||||||
url: item.fileUrl,
|
url: item.fileUrl,
|
||||||
type: item.fileSuffix,
|
type: item.fileSuffix,
|
||||||
name: item.fileName,
|
name: item.originalName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -637,8 +683,12 @@ function changeVoice() {
|
|||||||
|
|
||||||
function changeShowFile() {
|
function changeShowFile() {
|
||||||
if(isMachineEnv.value){
|
if(isMachineEnv.value){
|
||||||
qrcodeRef.value?.open()
|
if (filesList.value.length >= config.allowedFileNumber){
|
||||||
return
|
return $api.msg(`最大上传文件数量 ${config.allowedFileNumber} 个`);
|
||||||
|
}else{
|
||||||
|
qrcodeRef.value?.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
showfile.value = !showfile.value;
|
showfile.value = !showfile.value;
|
||||||
}
|
}
|
||||||
@@ -1068,22 +1118,23 @@ image-margin-top = 40rpx
|
|||||||
padding: 16rpx 20rpx 18rpx 20rpx
|
padding: 16rpx 20rpx 18rpx 20rpx
|
||||||
height: calc(100% - 40rpx)
|
height: calc(100% - 40rpx)
|
||||||
.doc-icon
|
.doc-icon
|
||||||
width: 60rpx
|
width: 60rpx;
|
||||||
height: 76rpx
|
height: 76rpx;
|
||||||
margin-right: 20rpx
|
margin-right: 20rpx;
|
||||||
.doc-con
|
.doc-con
|
||||||
flex: 1
|
max-width:320rpx;
|
||||||
width: 0
|
overflow :hidden;
|
||||||
|
padding-right:40rpx;
|
||||||
|
box-sizing:border-box;
|
||||||
.file-uploadsend
|
.file-uploadsend
|
||||||
margin: 10rpx 18rpx 0 0;
|
margin: 10rpx 18rpx 0 0;
|
||||||
height: 100%
|
height: 100%
|
||||||
font-size: 24rpx
|
font-size: 24rpx
|
||||||
position: relative
|
position: relative
|
||||||
min-width: 460rpx;
|
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
||||||
border: 2rpx solid #E2E2E2;
|
border: 2rpx solid #E2E2E2;
|
||||||
overflow: hidden
|
flex-shrink: 0;
|
||||||
.file-del
|
.file-del
|
||||||
position: absolute
|
position: absolute
|
||||||
right: 25rpx
|
right: 25rpx
|
||||||
@@ -1117,7 +1168,7 @@ image-margin-top = 40rpx
|
|||||||
max-width: 100%
|
max-width: 100%
|
||||||
.file-iconImg
|
.file-iconImg
|
||||||
height: 100%
|
height: 100%
|
||||||
width: 100%
|
// width: 100%
|
||||||
.filerow
|
.filerow
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
@@ -1127,8 +1178,6 @@ image-margin-top = 40rpx
|
|||||||
height: 20rpx
|
height: 20rpx
|
||||||
width: 2rpx
|
width: 2rpx
|
||||||
background: rgba(226, 226, 226, .9)
|
background: rgba(226, 226, 226, .9)
|
||||||
.file-border
|
|
||||||
width: 160rpx !important;
|
|
||||||
|
|
||||||
@keyframes ai-circle {
|
@keyframes ai-circle {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<image
|
<image
|
||||||
v-else-if="
|
v-else-if="
|
||||||
type === 'application/msword' ||
|
type === 'application/msword' ||
|
||||||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||||
|
type === 'doc' ||
|
||||||
|
type === 'docx'
|
||||||
"
|
"
|
||||||
:src="docIcon"
|
:src="docIcon"
|
||||||
class="file-icon"
|
class="file-icon"
|
||||||
@@ -18,9 +20,9 @@
|
|||||||
:src="pptIcon"
|
:src="pptIcon"
|
||||||
class="file-icon"
|
class="file-icon"
|
||||||
/>
|
/>
|
||||||
<image v-else-if="type === 'text/markdown'" :src="mdIcon" class="file-icon" />
|
<image v-else-if="type === 'text/markdown' || type === 'md'" :src="mdIcon" class="file-icon" />
|
||||||
<image v-else-if="type === 'text/plain'" :src="txtIcon" class="file-icon" />
|
<image v-else-if="type === 'text/plain' || type=== 'txt'" :src="txtIcon" class="file-icon" />
|
||||||
<image v-else-if="type === 'text/html'" :src="htmlIcon" class="file-icon" />
|
<image v-else-if="type === 'text/html' || type === 'html'" :src="htmlIcon" class="file-icon" />
|
||||||
<image
|
<image
|
||||||
v-else-if="
|
v-else-if="
|
||||||
type === 'application/vnd.ms-excel' ||
|
type === 'application/vnd.ms-excel' ||
|
||||||
|
|||||||
@@ -23,11 +23,25 @@ const fileAbbreviation = computed(() => {
|
|||||||
'text/markdown': 'MD',
|
'text/markdown': 'MD',
|
||||||
'text/plain': 'TXT',
|
'text/plain': 'TXT',
|
||||||
'text/html': 'HTML',
|
'text/html': 'HTML',
|
||||||
|
'pdf':'PDF',
|
||||||
|
'doc':'DOC',
|
||||||
|
'docx':'DOCX',
|
||||||
|
'ppt':'PPT',
|
||||||
|
'pptx':'PPTX',
|
||||||
'xls': 'XLS',
|
'xls': 'XLS',
|
||||||
'xlsx': 'XLSX',
|
'xlsx': 'XLSX',
|
||||||
'pdf': 'PDF',
|
'md':'MD',
|
||||||
'ppt': 'PPT',
|
'txt':'TXT',
|
||||||
'pptx': 'PPTX',
|
'html':'HTML',
|
||||||
|
'jpg':'JPG',
|
||||||
|
'img':'IMG',
|
||||||
|
'png':'PNG',
|
||||||
|
'jpeg':'JPEG',
|
||||||
|
'gif':'GIF',
|
||||||
|
'webp':'WEBP',
|
||||||
|
'svg':'SVG',
|
||||||
|
'tiff':'TIFF',
|
||||||
|
|
||||||
};
|
};
|
||||||
return typeMap[props.type] || 'OTHER';
|
return typeMap[props.type] || 'OTHER';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<uni-popup ref="popup" type="center" borderRadius="12px 12px 0 0" @change="changePopup">
|
<uni-popup ref="popup" type="center" borderRadius="12px 12px 0 0" @change="changePopup">
|
||||||
<view class="popup-inner">
|
<view class="popup-inner">
|
||||||
<view class="title">请扫码上传附件</view>
|
<view v-show="!fileCount" class="title">请扫码上传附件</view>
|
||||||
<view class="img-box">
|
<view v-show="!fileCount" class="img-box">
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<view v-if="loading" class="loading-container">
|
<view v-if="loading" class="loading-container">
|
||||||
<uni-load-more status="loading" :icon-size="24" :content-text="loadingText" />
|
<uni-load-more status="loading" :icon-size="24" :content-text="loadingText" />
|
||||||
</view>
|
</view>
|
||||||
<canvas canvas-id="qrcode" id="qrcode" />
|
<canvas canvas-id="qrcode" id="qrcode" />
|
||||||
</view>
|
</view>
|
||||||
<view class="tips" v-if="!loading"> 已上传 <span class="num">{{fileCount}}</span> 个文件 </view>
|
<view class="tips" v-if="!loading">
|
||||||
<view class="tips" v-if="!loading"> 请使用手机扫描二维码上传文件 </view>
|
已上传
|
||||||
|
<span class="num">{{ fileCount }}</span>
|
||||||
|
个文件
|
||||||
|
</view>
|
||||||
|
<view v-show="!fileCount" class="tips" v-if="!loading">请使用手机扫描二维码上传文件</view>
|
||||||
|
<view v-show="fileCount" class="file-list">
|
||||||
|
<view v-for="(file, index) in fileList" class="file-item">
|
||||||
|
<image
|
||||||
|
class="file-icon"
|
||||||
|
@click="preViewImage(file)"
|
||||||
|
v-if="isImage(file.fileSuffix)"
|
||||||
|
:src="file.fileUrl"
|
||||||
|
mode="scaleToFill"
|
||||||
|
></image>
|
||||||
|
<FileIcon v-else class="file-icon" :type="file.fileSuffix"></FileIcon>
|
||||||
|
<view class="right">
|
||||||
|
<view class="file-name">{{ file.originalName }}</view>
|
||||||
|
<FileText :type="file.fileSuffix"></FileText>
|
||||||
|
</view>
|
||||||
|
<view class="remove-btn" @click="delFile(file, index)">×</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-show="fileCount" class="confirm-btn btn-feel" @click="handleConfirm">确认</view>
|
||||||
<view class="close-btn" @click="close"></view>
|
<view class="close-btn" @click="close"></view>
|
||||||
</view>
|
</view>
|
||||||
</uni-popup>
|
</uni-popup>
|
||||||
@@ -18,15 +40,21 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, inject, onUnmounted, watch, computed } from 'vue';
|
import { ref, inject, onUnmounted, watch, computed } from 'vue';
|
||||||
|
import FileIcon from './fileIcon.vue';
|
||||||
|
import FileText from './fileText.vue';
|
||||||
import uQRCode from '@/static/js/qrcode';
|
import uQRCode from '@/static/js/qrcode';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { onShow, onHide } from '@dcloudio/uni-app';
|
import { onShow, onHide } from '@dcloudio/uni-app';
|
||||||
import { UUID } from '../../../lib/uuid-min';
|
import { UUID } from '../../../lib/uuid-min';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sessionId: {
|
sessionId: {
|
||||||
type: [Number ,String],
|
type: [Number, String],
|
||||||
default:'',
|
default: '',
|
||||||
},
|
},
|
||||||
|
leaveFileCount: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['onSend', 'onClose']);
|
const emit = defineEmits(['onSend', 'onClose']);
|
||||||
@@ -39,6 +67,8 @@ 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 fileCount = ref(0);
|
||||||
|
const fileList = ref([]);
|
||||||
|
const delFiles = ref([]); //本地记录删除的文件
|
||||||
|
|
||||||
// 计算加载文本
|
// 计算加载文本
|
||||||
const loadingText = computed(() => ({
|
const loadingText = computed(() => ({
|
||||||
@@ -46,14 +76,51 @@ const loadingText = computed(() => ({
|
|||||||
contentrefresh: '二维码生成中',
|
contentrefresh: '二维码生成中',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
clearResources();
|
clearResources();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isImage(type) {
|
||||||
|
if (!type || typeof type !== 'string') return false;
|
||||||
|
const imageTypes = [
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'bmp',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'tiff',
|
||||||
|
'tif',
|
||||||
|
'ico',
|
||||||
|
'apng',
|
||||||
|
'avif',
|
||||||
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'jfif',
|
||||||
|
];
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
|
||||||
|
return imageTypes.some((item) => lowerType.includes(item));
|
||||||
|
}
|
||||||
|
function preViewImage(file) {
|
||||||
|
if (file.fileUrl) {
|
||||||
|
uni.previewImage({
|
||||||
|
urls: [file.fileUrl],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$api.msg('文件地址丢失');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function delFile(file, idx) {
|
||||||
|
fileList.value.splice(idx, 1);
|
||||||
|
fileCount.value = fileList.value.length
|
||||||
|
delFiles.value.push(file.fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
uuid.value = UUID.generate()
|
uuid.value = UUID.generate();
|
||||||
resetState();
|
resetState();
|
||||||
isVisible.value = true;
|
isVisible.value = true;
|
||||||
popup.value.open();
|
popup.value.open();
|
||||||
@@ -75,8 +142,16 @@ function changePopup(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('onSend', fileList.value);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
// 重置所有状态
|
// 重置所有状态
|
||||||
function resetState() {
|
function resetState() {
|
||||||
|
delFiles.value = []
|
||||||
|
fileList.value = [];
|
||||||
|
fileCount.value = 0;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
@@ -104,25 +179,44 @@ async function initQrCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeQrcode() {
|
function makeQrcode() {
|
||||||
const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
|
const protocol = window.location.protocol;
|
||||||
config.baseUrl + '/app/kiosk/upload'
|
const host = window.location.host;
|
||||||
}`;
|
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||||
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${props.sessionId}&uploadApi=${
|
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
||||||
// config.baseUrl + '/app/kiosk/upload'
|
let pathPrefix = '';
|
||||||
// }`;
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||||
|
pathPrefix = '';
|
||||||
|
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
||||||
|
// 外网测试环境
|
||||||
|
pathPrefix = '/app';
|
||||||
|
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
||||||
|
// 青岛政务网环境
|
||||||
|
pathPrefix = '/rgpp-api/all-in-one';
|
||||||
|
} else {
|
||||||
|
pathPrefix = '';
|
||||||
|
}
|
||||||
|
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=${
|
||||||
|
// config.baseUrl + '/app/kiosk/upload'
|
||||||
|
// }`;
|
||||||
|
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${props.sessionId}&uploadApi=${
|
||||||
|
// config.baseUrl + '/app/kiosk/upload'
|
||||||
|
// }`;
|
||||||
console.log(htmlPath);
|
console.log(htmlPath);
|
||||||
|
console.log('剩余可上传文件数量:',props.leaveFileCount)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uQRCode.make({
|
uQRCode.make({
|
||||||
canvasId: 'qrcode',
|
canvasId: 'qrcode',
|
||||||
text: htmlPath,
|
text: htmlPath,
|
||||||
size: uni.upx2px(300),
|
size: uni.upx2px(300),
|
||||||
margin: 0,
|
margin: 0,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
foregroundColor: '#1677ff',
|
foregroundColor: '#1677ff',
|
||||||
fileType: 'png',
|
fileType: 'png',
|
||||||
correctLevel: uQRCode.defaults.correctLevel,
|
correctLevel: uQRCode.defaults.correctLevel,
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
@@ -144,12 +238,13 @@ function startPolling() {
|
|||||||
// 轮询检查上传状态
|
// 轮询检查上传状态
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (!isPolling.value || !isVisible.value) return;
|
if (!isPolling.value || !isVisible.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) {
|
||||||
// 上传完成,触发事件
|
// 上传完成,触发事件
|
||||||
fileCount.value = data.length
|
fileList.value = data.filter((item) => !delFiles.value.includes(item.fileUrl))
|
||||||
emit('onSend', data);
|
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秒轮询一次
|
||||||
@@ -178,40 +273,216 @@ defineExpose({ open, close });
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.popup-inner {
|
.popup-inner {
|
||||||
padding: 40rpx 30rpx;
|
padding: 40rpx 30rpx 50rpx;
|
||||||
padding-bottom: 50rpx;
|
width: 520rpx;
|
||||||
width: 440rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: #fafafa;
|
background: linear-gradient(135deg, #fafafa 0%, #f0f7ff 100%);
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
color: #333;
|
color: #1a1a1a;
|
||||||
margin-bottom: 30rpx;
|
margin-bottom: 30rpx;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background: linear-gradient(90deg, #4191fe 0%, #256bfa 100%);
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-box {
|
.img-box {
|
||||||
margin: 0 auto 30rpx;
|
margin: 0 auto 30rpx;
|
||||||
width: 300rpx;
|
width: 300rpx;
|
||||||
height: 300rpx;
|
height: 300rpx;
|
||||||
background-color: #ffffff;
|
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
|
||||||
border-radius: 10rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8rpx 32rpx rgba(65, 145, 254, 0.2);
|
||||||
padding: 20rpx;
|
padding: 20rpx;
|
||||||
|
border: 2rpx solid rgba(65, 145, 254, 0.1);
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: opacity 0.3s ease;
|
border-radius: 8rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: canvasFadeIn 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes canvasFadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
width: 470rpx;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10rpx;
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 25rpx;
|
||||||
|
padding-right: 15rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #bbc5d1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
animation: fadeIn 0.5s ease;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f0f8ff 100%);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #4191fe 0%, #256bfa 100%);
|
||||||
|
border-radius: 3rpx 0 0 3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2rpx);
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(65, 145, 254, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #838383;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 52rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-top: -15rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: rgba(255, 107, 107, 0.1);
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
width: 350rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
background: linear-gradient(135deg, #4191fe 0%, #256bfa 100%);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(37, 107, 250, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(37, 107, 250, 0.4);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,18 +492,25 @@ defineExpose({ open, close });
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #ffffff;
|
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
|
||||||
|
border-radius: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: #666;
|
color: #666;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
.num{
|
margin-top: 10rpx;
|
||||||
color: #4191FE;
|
animation: fadeIn 0.5s ease;
|
||||||
|
|
||||||
|
.num {
|
||||||
|
color: #4191fe;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 8rpx;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,39 +521,40 @@ defineExpose({ open, close });
|
|||||||
width: 56rpx;
|
width: 56rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
transform: scale(0.9) rotate(90deg);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24rpx;
|
||||||
|
height: 2rpx;
|
||||||
|
background: #5a5a68;
|
||||||
|
border-radius: 1rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
position: absolute;
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
content: '';
|
|
||||||
width: 4rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
border-radius: 2rpx;
|
|
||||||
background: #5a5a68;
|
|
||||||
transform: translate(50%, -50%) rotate(-45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
position: absolute;
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
content: '';
|
|
||||||
width: 4rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
border-radius: 2rpx;
|
|
||||||
background: #5a5a68;
|
|
||||||
transform: translate(50%, -50%) rotate(45deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
<image class="bg-text" mode="widthFix" src="@/static/icon/index-text-bg.png"></image>
|
<image class="bg-text" mode="widthFix" src="@/static/icon/index-text-bg.png"></image>
|
||||||
<view class="search-inner">
|
<view class="search-inner">
|
||||||
<view class="inner-left">
|
<view class="inner-left">
|
||||||
<image class="bg-text2" mode="widthFix" src="@/static/icon/index-text-bg2.png"></image>
|
<image
|
||||||
|
class="bg-text2"
|
||||||
|
mode="widthFix"
|
||||||
|
@click="reloadBrowser()"
|
||||||
|
src="@/static/icon/index-text-bg2.png"
|
||||||
|
></image>
|
||||||
<view class="search-input button-click" @click="navTo('/pages/search/search')">
|
<view class="search-input button-click" @click="navTo('/pages/search/search')">
|
||||||
<image class="icon" src="@/static/icon/index-search.png"></image>
|
<image class="icon" src="@/static/icon/index-search.png"></image>
|
||||||
<text class="inpute">请告诉我想找什么工作</text>
|
<text class="inpute">请告诉我想找什么工作</text>
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
<image class="bg-robot button-click" mode="widthFix" src="@/static/icon/index-robot.png"></image>
|
<image class="bg-robot button-click" mode="widthFix" src="@/static/icon/index-robot.png"></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!isMachineEnv" class="ai-card-out" >
|
<view v-if="!isMachineEnv" class="ai-card-out">
|
||||||
<view class="ai-card">
|
<view class="ai-card">
|
||||||
<image class="ai-card-bg" src="@/static/icon/ai-card-bg.png" />
|
<image class="ai-card-bg" src="@/static/icon/ai-card-bg.png" />
|
||||||
<view class="ai-card-inner">
|
<view class="ai-card-inner">
|
||||||
@@ -56,7 +61,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="hasLogin" :class="{'match-move-top':isMachineEnv}" class="match-card-out">
|
<view v-if="hasLogin" :class="{ 'match-move-top': isMachineEnv }" class="match-card-out">
|
||||||
<view class="match-card">
|
<view class="match-card">
|
||||||
<image class="match-card-bg" src="@/static/icon/match-card-bg.png" />
|
<image class="match-card-bg" src="@/static/icon/match-card-bg.png" />
|
||||||
<view class="title">简历匹配职位</view>
|
<view class="title">简历匹配职位</view>
|
||||||
@@ -65,7 +70,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view :class="{'cards-move-top':isMachineEnv && !hasLogin}" class="cards">
|
<view :class="{ 'cards-move-top': isMachineEnv && !hasLogin }" class="cards">
|
||||||
<view class="card card1 press-button" @click="navTo('/pages/nearby/nearby')">
|
<view class="card card1 press-button" @click="navTo('/pages/nearby/nearby')">
|
||||||
<view class="card-title">附近工作</view>
|
<view class="card-title">附近工作</view>
|
||||||
<view class="card-text">好岗职等你来</view>
|
<view class="card-text">好岗职等你来</view>
|
||||||
@@ -254,11 +259,11 @@
|
|||||||
import { reactive, inject, watch, ref, onMounted, watchEffect, nextTick, getCurrentInstance } from 'vue';
|
import { reactive, inject, watch, ref, onMounted, watchEffect, nextTick, getCurrentInstance } from 'vue';
|
||||||
import img from '@/static/icon/filter.png';
|
import img from '@/static/icon/filter.png';
|
||||||
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
||||||
const { $api, navTo, vacanciesTo, formatTotal, throttle } = inject('globalFunction');
|
const { $api, navTo, vacanciesTo, formatTotal, throttle, reloadBrowser } = inject('globalFunction');
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { userInfo, hasLogin ,isMachineEnv} = storeToRefs(useUserStore());
|
const { userInfo, hasLogin, isMachineEnv } = storeToRefs(useUserStore());
|
||||||
import useDictStore from '@/stores/useDictStore';
|
import useDictStore from '@/stores/useDictStore';
|
||||||
const { getTransformChildren, oneDictData } = useDictStore();
|
const { getTransformChildren, oneDictData } = useDictStore();
|
||||||
import useLocationStore from '@/stores/useLocationStore';
|
import useLocationStore from '@/stores/useLocationStore';
|
||||||
@@ -271,7 +276,6 @@ const recommedIndexDb = useRecommedIndexedDBStore();
|
|||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import AIMatch from './AIMatch.vue';
|
import AIMatch from './AIMatch.vue';
|
||||||
|
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
|
|
||||||
const maskFirstEntry = ref(true);
|
const maskFirstEntry = ref(true);
|
||||||
@@ -363,7 +367,7 @@ onMounted(() => {
|
|||||||
let firstEntry = uni.getStorageSync('firstEntry') === false ? false : true; // 默认未读
|
let firstEntry = uni.getStorageSync('firstEntry') === false ? false : true; // 默认未读
|
||||||
maskFirstEntry.value = firstEntry;
|
maskFirstEntry.value = firstEntry;
|
||||||
getMatchTags();
|
getMatchTags();
|
||||||
console.log(isMachineEnv.value,'+++++++++')
|
// console.log(isMachineEnv.value, '+++++++++');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getMatchTags() {
|
async function getMatchTags() {
|
||||||
@@ -466,7 +470,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
|||||||
getJobRecommend('refresh');
|
getJobRecommend('refresh');
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
waterfallsFlowRef.value?.refresh?.();
|
waterfallsFlowRef.value?.refresh?.();
|
||||||
useLocationStore().getLocation();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
|||||||
pageSize.value = 10 * (columnCount.value - 1);
|
pageSize.value = 10 * (columnCount.value - 1);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
waterfallsFlowRef.value?.refresh?.();
|
waterfallsFlowRef.value?.refresh?.();
|
||||||
useLocationStore().getLocation();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
</tabcontrolVue>
|
</tabcontrolVue>
|
||||||
<SelectJobs ref="selectJobsModel"></SelectJobs>
|
<SelectJobs ref="selectJobsModel"></SelectJobs>
|
||||||
<!-- 后门 -->
|
<!-- 后门 -->
|
||||||
<view class="backdoor" @click="loginbackdoor">
|
<view class="backdoor" v-if="!isMachineEnv" @click="loginbackdoor">
|
||||||
<my-icons type="gift-filled" size="60"></my-icons>
|
<my-icons type="gift-filled" size="60"></my-icons>
|
||||||
</view>
|
</view>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -276,9 +276,7 @@ const resetCountdown = () => {
|
|||||||
const returnToHome = () => {
|
const returnToHome = () => {
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
stopScanAnimation();
|
stopScanAnimation();
|
||||||
uni.switchTab({
|
useUserStore().logOutApp();
|
||||||
url: '/pages/index/index',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消登录
|
// 取消登录
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
|
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
|
||||||
<view class="nearby-map" @touchmove.stop.prevent>
|
<view class="nearby-map" @touchmove.stop.prevent>
|
||||||
<map
|
<map
|
||||||
style="width: 100%; height:410rpx "
|
style="width: 100%; height: 690rpx"
|
||||||
:latitude="latitudeVal"
|
:latitude="latitudeVal"
|
||||||
:longitude="longitudeVal"
|
:longitude="longitudeVal"
|
||||||
:markers="mapCovers"
|
:markers="mapCovers"
|
||||||
:circles="mapCircles"
|
:circles="mapCircles"
|
||||||
:controls="mapControls"
|
:controls="mapControls"
|
||||||
@controltap="handleControl"
|
@controltap="handleControl"
|
||||||
|
:scale="mapScale"
|
||||||
></map>
|
></map>
|
||||||
<view class="nearby-select">
|
<view class="nearby-select">
|
||||||
<view class="select-view" @click="changeRangeShow">
|
<view class="select-view" @click="changeRangeShow">
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
<image class="view-sx" :class="{ active: rangeShow }" src="@/static/icon/shaixun.png"></image>
|
<image class="view-sx" :class="{ active: rangeShow }" src="@/static/icon/shaixun.png"></image>
|
||||||
</view>
|
</view>
|
||||||
<transition name="fade-slide">
|
<transition name="fade-slide">
|
||||||
<view class="select-list" v-if="rangeShow">
|
<view class="select-list" v-show="rangeShow">
|
||||||
<view class="list-item button-click" v-for="(item, index) in range" @click="changeRadius(item)">
|
<view class="list-item button-click" v-for="(item, index) in range" @click="changeRadius(item)">
|
||||||
{{ item }}km
|
{{ item }}km
|
||||||
</view>
|
</view>
|
||||||
@@ -106,16 +107,18 @@ const tMap = ref();
|
|||||||
const progress = ref();
|
const progress = ref();
|
||||||
const mapCovers = ref([]);
|
const mapCovers = ref([]);
|
||||||
const mapCircles = ref([]);
|
const mapCircles = ref([]);
|
||||||
|
const mapScale = ref(14.5)
|
||||||
const mapControls = ref([
|
const mapControls = ref([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
position: {
|
position: {
|
||||||
// 控件位置
|
// 控件位置
|
||||||
left: customSystem.systemInfo.screenWidth - 48 - 14,
|
left: customSystem.systemInfo.screenWidth - uni.upx2px(75 + 30),
|
||||||
top: 320,
|
top: uni.upx2px(655 - 75 - 30),
|
||||||
width: 48,
|
width: uni.upx2px(75),
|
||||||
height: 48,
|
height: uni.upx2px(75),
|
||||||
},
|
},
|
||||||
|
width:100,
|
||||||
iconPath: LocationPng, // 控件图标
|
iconPath: LocationPng, // 控件图标
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -147,6 +150,9 @@ function changeRangeShow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeRadius(item) {
|
function changeRadius(item) {
|
||||||
|
console.log(item);
|
||||||
|
if(item > 1) mapScale.value = 14.5 - item * 0.3
|
||||||
|
else mapScale.value = 14.5
|
||||||
pageState.search.radius = item;
|
pageState.search.radius = item;
|
||||||
rangeShow.value = false;
|
rangeShow.value = false;
|
||||||
progressChange(item);
|
progressChange(item);
|
||||||
@@ -220,31 +226,27 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function getInit() {
|
function getInit() {
|
||||||
useLocationStore()
|
mapCovers.value = [
|
||||||
.getLocation()
|
{
|
||||||
.then((res) => {
|
latitude: latitudeVal.value,
|
||||||
mapCovers.value = [
|
longitude: longitudeVal.value,
|
||||||
{
|
iconPath: point2,
|
||||||
latitude: res.latitude,
|
},
|
||||||
longitude: res.longitude,
|
];
|
||||||
iconPath: point2,
|
mapCircles.value = [
|
||||||
},
|
{
|
||||||
];
|
latitude: latitudeVal.value,
|
||||||
mapCircles.value = [
|
longitude:longitudeVal.value,
|
||||||
{
|
radius: 1000,
|
||||||
latitude: res.latitude,
|
fillColor: '#1c52fa25',
|
||||||
longitude: res.longitude,
|
color: '#256BFA',
|
||||||
radius: 1000,
|
},
|
||||||
fillColor: '#1c52fa25',
|
];
|
||||||
color: '#256BFA',
|
getJobList('refresh');
|
||||||
},
|
|
||||||
];
|
|
||||||
getJobList('refresh');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressChange(value) {
|
function progressChange(value) {
|
||||||
const range = 1 + value;
|
const range = value < 1 ? 1 : value;
|
||||||
pageState.search.radius = range;
|
pageState.search.radius = range;
|
||||||
mapCircles.value = [
|
mapCircles.value = [
|
||||||
{
|
{
|
||||||
@@ -362,7 +364,7 @@ defineExpose({ loadData, handleFilterConfirm });
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: #f4f4f4;
|
background: #f4f4f4;
|
||||||
.nearby-map
|
.nearby-map
|
||||||
height: 400rpx;
|
height: 655rpx;
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
.nearby-list
|
.nearby-list
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
|||||||
pageSize.value = 10 * (columnCount.value - 1);
|
pageSize.value = 10 * (columnCount.value - 1);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
waterfallsFlowRef.value?.refresh?.();
|
waterfallsFlowRef.value?.refresh?.();
|
||||||
useLocationStore().getLocation();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
170
static/js/fileValidator.js
Normal file
170
static/js/fileValidator.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const KNOWN_SIGNATURES = {
|
||||||
|
png: '89504E470D0A1A0A',
|
||||||
|
jpg: 'FFD8FF',
|
||||||
|
jpeg: 'FFD8FF',
|
||||||
|
gif: '47494638',
|
||||||
|
webp: '52494646',
|
||||||
|
docx: '504B0304',
|
||||||
|
xlsx: '504B0304',
|
||||||
|
pptx: '504B0304',
|
||||||
|
doc: 'D0CF11E0',
|
||||||
|
xls: 'D0CF11E0',
|
||||||
|
ppt: 'D0CF11E0',
|
||||||
|
pdf: '25504446',
|
||||||
|
txt: 'TYPE_TEXT',
|
||||||
|
csv: 'TYPE_TEXT',
|
||||||
|
md: 'TYPE_TEXT',
|
||||||
|
json: 'TYPE_TEXT',
|
||||||
|
};
|
||||||
|
export class FileValidator {
|
||||||
|
version = '1.0.0';
|
||||||
|
signs = Object.keys(KNOWN_SIGNATURES);
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.maxSizeMB = options.maxSizeMB || 10;
|
||||||
|
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||||
|
this.allowedConfig = {};
|
||||||
|
options.allowedExtensions.forEach((ext) => {
|
||||||
|
const key = ext.toLowerCase();
|
||||||
|
if (KNOWN_SIGNATURES[key]) {
|
||||||
|
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||||
|
} else {
|
||||||
|
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.allowedConfig = {
|
||||||
|
...KNOWN_SIGNATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isValidUTF8(buffer) {
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
decoder.decode(buffer);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bufferToHex(buffer) {
|
||||||
|
return Array.prototype.map
|
||||||
|
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
_countCSVRows(buffer) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
const text = decoder.decode(buffer);
|
||||||
|
let rowCount = 0;
|
||||||
|
let inQuote = false;
|
||||||
|
let len = text.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
} else if (char === '\n' && !inQuote) {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (len > 0 && text[len - 1] !== '\n') {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
return rowCount;
|
||||||
|
}
|
||||||
|
_validateTextContent(buffer, extension) {
|
||||||
|
let contentStr = '';
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
contentStr = decoder.decode(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('UTF-8 解码失败', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (contentStr.includes('\0')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (extension === 'json') {
|
||||||
|
try {
|
||||||
|
JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('无效的 JSON 格式');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
validate(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file || !file.name) return reject('无效的文件对象');
|
||||||
|
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||||
|
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||||
|
}
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
const expectedMagic = this.allowedConfig[extension];
|
||||||
|
if (!expectedMagic) {
|
||||||
|
return reject(`不支持的文件格式: .${extension}`);
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const buffer = e.target.result;
|
||||||
|
let isSafe = false;
|
||||||
|
if (expectedMagic === 'TYPE_TEXT') {
|
||||||
|
if (this._validateTextContent(buffer, extension)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
if (extension === 'json') {
|
||||||
|
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||||
|
}
|
||||||
|
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||||
|
}
|
||||||
|
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||||
|
const rows = this._countCSVRows(buffer);
|
||||||
|
if (rows > this.csvMaxRows) {
|
||||||
|
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||||
|
if (fileHeader.startsWith(expectedMagic)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSafe) resolve(true);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||||
|
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 【demo】
|
||||||
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||||
|
// const imageValidator = new FileValidator({
|
||||||
|
// maxSizeMB: 5,
|
||||||
|
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// imageValidator
|
||||||
|
// .validate(file)
|
||||||
|
// .then(() => {
|
||||||
|
// statusDiv.textContent = `检测通过: ${file.name}`;
|
||||||
|
// statusDiv.style.color = 'green';
|
||||||
|
// console.log('图片校验通过,开始上传...');
|
||||||
|
// // upload(file)...
|
||||||
|
// })
|
||||||
|
// .catch((err) => {
|
||||||
|
// statusDiv.textContent = `检测失败: ${err}`;
|
||||||
|
// statusDiv.style.color = 'red';
|
||||||
|
// });
|
||||||
2423
static/upload.html
2423
static/upload.html
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,17 @@ import {
|
|||||||
} from '@/common/globalFunction.js'
|
} from '@/common/globalFunction.js'
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
|
const defalutLongLat = {
|
||||||
|
longitude: 120.382665,
|
||||||
|
latitude: 36.066938,
|
||||||
|
}
|
||||||
|
|
||||||
const useLocationStore = defineStore("location", () => {
|
const useLocationStore = defineStore("location", () => {
|
||||||
// 定义状态
|
// 定义状态
|
||||||
const longitudeVal = ref(null) // 经度
|
const longitudeVal = ref(null) // 经度
|
||||||
const latitudeVal = ref(null) //纬度
|
const latitudeVal = ref(null) //纬度
|
||||||
|
const timer = ref(null)
|
||||||
|
const count = ref(0)
|
||||||
|
|
||||||
function getLocation() { // 获取经纬度两个平台
|
function getLocation() { // 获取经纬度两个平台
|
||||||
return new Promise((resole, reject) => {
|
return new Promise((resole, reject) => {
|
||||||
@@ -25,48 +32,57 @@ const useLocationStore = defineStore("location", () => {
|
|||||||
resole(data)
|
resole(data)
|
||||||
},
|
},
|
||||||
fail: function(data) {
|
fail: function(data) {
|
||||||
longitudeVal.value = 120.382665
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = 36.066938
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole({
|
resole(defalutLongLat)
|
||||||
longitude: 120.382665,
|
|
||||||
latitude: 36.066938
|
|
||||||
})
|
|
||||||
msg('用户位置获取失败')
|
msg('用户位置获取失败')
|
||||||
|
console.log('失败3', data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
uni.getLocation({
|
uni.getLocation({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
highAccuracyExpireTime: 3000,
|
// highAccuracyExpireTime: 3000,
|
||||||
isHighAccuracy: true,
|
// isHighAccuracy: true,
|
||||||
|
// timeout: 2000,
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
longitudeVal.value = Number(data.longitude)
|
longitudeVal.value = Number(data.longitude)
|
||||||
latitudeVal.value = Number(data.latitude)
|
latitudeVal.value = Number(data.latitude)
|
||||||
resole(data)
|
resole(data)
|
||||||
},
|
},
|
||||||
fail: function(data) {
|
fail: function(data) {
|
||||||
longitudeVal.value = 120.382665
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = 36.066938
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole({
|
resole(defalutLongLat)
|
||||||
longitude: 120.382665,
|
|
||||||
latitude: 36.066938
|
|
||||||
})
|
|
||||||
msg('用户位置获取失败')
|
msg('用户位置获取失败')
|
||||||
|
console.log('失败2', data)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
longitudeVal.value = 120.382665
|
longitudeVal.value = defalutLongLat.longitude
|
||||||
latitudeVal.value = 36.066938
|
latitudeVal.value = defalutLongLat.latitude
|
||||||
resole({
|
resole(defalutLongLat)
|
||||||
longitude: 120.382665,
|
|
||||||
latitude: 36.066938
|
|
||||||
})
|
|
||||||
msg('测试环境,使用模拟定位')
|
msg('测试环境,使用模拟定位')
|
||||||
console.log('失败', data)
|
console.log('失败1', e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
function getLocationLoop(gap = 1000 * 60 * 2) {
|
||||||
|
console.log(`🔄开始循环获取定位,间隔:${Math.floor(gap/1000)}秒`)
|
||||||
|
const run = () => {
|
||||||
|
count.value++
|
||||||
|
console.log(`📍第${count.value}次获取定位`)
|
||||||
|
getLocation()
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
timer.value = setInterval(run,gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGetLocationLoop(params) {
|
||||||
|
clearInterval(timer.value)
|
||||||
|
timer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function longitude() {
|
function longitude() {
|
||||||
return longitudeVal.value
|
return longitudeVal.value
|
||||||
@@ -79,10 +95,13 @@ const useLocationStore = defineStore("location", () => {
|
|||||||
// 导入
|
// 导入
|
||||||
return {
|
return {
|
||||||
getLocation,
|
getLocation,
|
||||||
|
getLocationLoop,
|
||||||
|
clearGetLocationLoop,
|
||||||
longitudeVal,
|
longitudeVal,
|
||||||
latitudeVal
|
latitudeVal,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
unistorage: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export default useLocationStore;
|
export default useLocationStore;
|
||||||
@@ -1,283 +1,329 @@
|
|||||||
import { defineStore } from 'pinia';
|
import {
|
||||||
import { ref, computed } from 'vue';
|
defineStore
|
||||||
|
} from 'pinia';
|
||||||
// 屏幕检测管理器类
|
import {
|
||||||
class ScreenDetectionManager {
|
ref,
|
||||||
constructor() {
|
computed
|
||||||
this.WIDE_SCREEN_CSS_ID = 'wide-screen-css';
|
} from 'vue';
|
||||||
this.WIDE_SCREEN_CSS_PATH = '../common/wide-screen.css';
|
import wideScreenStyles from '../common/wide-screen.css?inline';
|
||||||
this.resizeTimer = null;
|
|
||||||
this.resizeListeners = [];
|
// 屏幕检测管理器类
|
||||||
this.cssLink = null;
|
class ScreenDetectionManager {
|
||||||
}
|
constructor() {
|
||||||
|
this.WIDE_SCREEN_CSS_ID = 'wide-screen-css';
|
||||||
// 获取屏幕宽度
|
this.WIDE_SCREEN_CSS_PATH = '../common/wide-screen.css';
|
||||||
getScreenWidth() {
|
this.resizeTimer = null;
|
||||||
if (typeof window === 'undefined') return 0;
|
this.resizeListeners = [];
|
||||||
|
this.cssLink = null;
|
||||||
// 优先使用 uni.getSystemInfo
|
this.STYLE_ID = 'wide-screen-style-tag';
|
||||||
if (typeof uni !== 'undefined' && uni.getSystemInfo) {
|
this.styleTag = null;
|
||||||
return new Promise((resolve) => {
|
}
|
||||||
uni.getSystemInfo({
|
|
||||||
success: (res) => {
|
// 获取屏幕宽度
|
||||||
const width = res.screenWidth || res.windowWidth || window.innerWidth;
|
getScreenWidth() {
|
||||||
resolve(width);
|
if (typeof window === 'undefined') return 0;
|
||||||
},
|
|
||||||
fail: () => {
|
// 优先使用 uni.getSystemInfo
|
||||||
resolve(this.getWindowWidth());
|
if (typeof uni !== 'undefined' && uni.getSystemInfo) {
|
||||||
}
|
return new Promise((resolve) => {
|
||||||
});
|
uni.getSystemInfo({
|
||||||
});
|
success: (res) => {
|
||||||
}
|
const width = res.screenWidth || res.windowWidth || window.innerWidth;
|
||||||
|
resolve(width);
|
||||||
return Promise.resolve(this.getWindowWidth());
|
},
|
||||||
}
|
fail: () => {
|
||||||
|
resolve(this.getWindowWidth());
|
||||||
// 备用方案:使用 window 对象
|
}
|
||||||
getWindowWidth() {
|
});
|
||||||
if (typeof window === 'undefined') return 0;
|
});
|
||||||
return window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
}
|
||||||
}
|
|
||||||
|
return Promise.resolve(this.getWindowWidth());
|
||||||
// 检测折叠屏
|
}
|
||||||
checkVisualViewport() {
|
|
||||||
if (window.visualViewport?.segments?.length > 1) {
|
// 备用方案:使用 window 对象
|
||||||
return {
|
getWindowWidth() {
|
||||||
foldable: true,
|
if (typeof window === 'undefined') return 0;
|
||||||
count: window.visualViewport.segments.length - 1
|
return window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else {
|
// 检测折叠屏
|
||||||
return {
|
checkVisualViewport() {
|
||||||
foldable: false,
|
if (window.visualViewport?.segments?.length > 1) {
|
||||||
count: 1
|
return {
|
||||||
}
|
foldable: true,
|
||||||
}
|
count: window.visualViewport.segments.length - 1
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// 动态加载 CSS
|
return {
|
||||||
loadWideScreenCSS() {
|
foldable: false,
|
||||||
if (typeof window === 'undefined' || this.cssLink) return null;
|
count: 1
|
||||||
|
}
|
||||||
try {
|
}
|
||||||
this.cssLink = document.createElement('link');
|
}
|
||||||
this.cssLink.id = this.WIDE_SCREEN_CSS_ID;
|
|
||||||
this.cssLink.rel = 'stylesheet';
|
// 动态加载 CSS
|
||||||
this.cssLink.href = this.WIDE_SCREEN_CSS_PATH;
|
loadWideScreenCSS() {
|
||||||
|
if (typeof window === 'undefined' || this.cssLink) return null;
|
||||||
// 添加加载成功/失败监听
|
|
||||||
this.cssLink.onload = () => {
|
try {
|
||||||
console.log('🎨 宽屏 CSS 文件加载成功');
|
this.cssLink = document.createElement('link');
|
||||||
};
|
this.cssLink.id = this.WIDE_SCREEN_CSS_ID;
|
||||||
|
this.cssLink.rel = 'stylesheet';
|
||||||
this.cssLink.onerror = () => {
|
this.cssLink.href = this.WIDE_SCREEN_CSS_PATH;
|
||||||
console.error('❌ 宽屏 CSS 文件加载失败');
|
|
||||||
this.cssLink = null;
|
// 添加加载成功/失败监听
|
||||||
};
|
this.cssLink.onload = () => {
|
||||||
|
console.log('🎨 宽屏 CSS 文件加载成功');
|
||||||
document.head.appendChild(this.cssLink);
|
};
|
||||||
return this.cssLink;
|
|
||||||
} catch (error) {
|
this.cssLink.onerror = () => {
|
||||||
console.error('加载 CSS 失败:', error);
|
console.error('❌ 宽屏 CSS 文件加载失败');
|
||||||
this.cssLink = null;
|
this.cssLink = null;
|
||||||
return null;
|
};
|
||||||
}
|
|
||||||
}
|
document.head.appendChild(this.cssLink);
|
||||||
|
return this.cssLink;
|
||||||
// 移除 CSS
|
} catch (error) {
|
||||||
removeWideScreenCSS() {
|
console.error('加载 CSS 失败:', error);
|
||||||
if (this.cssLink && this.cssLink.parentNode) {
|
this.cssLink = null;
|
||||||
try {
|
return null;
|
||||||
this.cssLink.parentNode.removeChild(this.cssLink);
|
}
|
||||||
this.cssLink = null;
|
}
|
||||||
console.log('🗑️ 宽屏 CSS 文件已移除');
|
|
||||||
return true;
|
// 移除 CSS
|
||||||
} catch (error) {
|
removeWideScreenCSS() {
|
||||||
console.error('移除 CSS 失败:', error);
|
if (this.cssLink && this.cssLink.parentNode) {
|
||||||
return false;
|
try {
|
||||||
}
|
this.cssLink.parentNode.removeChild(this.cssLink);
|
||||||
}
|
this.cssLink = null;
|
||||||
return false;
|
console.log('🗑️ 宽屏 CSS 文件已移除');
|
||||||
}
|
return true;
|
||||||
|
} catch (error) {
|
||||||
// 更新 CSS 状态
|
console.error('移除 CSS 失败:', error);
|
||||||
updateWideScreenCSS(isWideScreen) {
|
return false;
|
||||||
if (isWideScreen) {
|
}
|
||||||
return this.loadWideScreenCSS();
|
}
|
||||||
} else {
|
return false;
|
||||||
return this.removeWideScreenCSS();
|
}
|
||||||
}
|
|
||||||
}
|
// 更新 CSS 状态
|
||||||
//显示/隐藏默认tabbar
|
// updateWideScreenCSS(isWideScreen) {
|
||||||
updateTabbar(isWideScreen){
|
// if (isWideScreen) {
|
||||||
if(isWideScreen) uni.hideTabBar()
|
// return this.loadWideScreenCSS();
|
||||||
else uni.showTabBar()
|
// } else {
|
||||||
}
|
// return this.removeWideScreenCSS();
|
||||||
|
// }
|
||||||
// 添加 resize 监听器
|
// }
|
||||||
addResizeListener(callback, delay = 250) {
|
|
||||||
if (typeof window === 'undefined') return () => {};
|
updateWideScreenCSS(isWideScreen) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
const handler = () => {
|
|
||||||
clearTimeout(this.resizeTimer);
|
if (isWideScreen) {
|
||||||
this.resizeTimer = setTimeout(() => {
|
// 如果已经是宽屏且标签已存在,则不重复创建
|
||||||
callback();
|
if (document.getElementById(this.STYLE_ID)) return;
|
||||||
}, delay);
|
|
||||||
};
|
this.styleTag = document.createElement('style');
|
||||||
|
this.styleTag.id = this.STYLE_ID;
|
||||||
window.addEventListener('resize', handler);
|
this.styleTag.innerHTML = wideScreenStyles; // 将 CSS 文本注入
|
||||||
this.resizeListeners.push({ callback, handler });
|
document.head.appendChild(this.styleTag);
|
||||||
|
console.log('宽屏样式已通过内联方式注入');
|
||||||
// 返回清理函数
|
} else {
|
||||||
return () => this.removeResizeListener(callback);
|
// 非宽屏时移除标签
|
||||||
}
|
const existingTag = document.getElementById(this.STYLE_ID);
|
||||||
|
if (existingTag) {
|
||||||
// 移除 resize 监听器
|
existingTag.remove();
|
||||||
removeResizeListener(callback) {
|
this.styleTag = null;
|
||||||
const index = this.resizeListeners.findIndex(item => item.callback === callback);
|
console.log('宽屏样式已移除');
|
||||||
if (index > -1) {
|
}
|
||||||
const { handler } = this.resizeListeners[index];
|
}
|
||||||
window.removeEventListener('resize', handler);
|
}
|
||||||
this.resizeListeners.splice(index, 1);
|
|
||||||
}
|
//显示/隐藏默认tabbar
|
||||||
}
|
updateTabbar(isWideScreen) {
|
||||||
|
if (isWideScreen) uni.hideTabBar()
|
||||||
|
else uni.showTabBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建屏幕状态 store
|
// 添加 resize 监听器
|
||||||
const useScreenStore = defineStore('screen', () => {
|
addResizeListener(callback, delay = 250) {
|
||||||
// 状态
|
if (typeof window === 'undefined') return () => {};
|
||||||
const screenWidth = ref(0);
|
|
||||||
const isInitialized = ref(false);
|
const handler = () => {
|
||||||
const isLoading = ref(false);
|
clearTimeout(this.resizeTimer);
|
||||||
const foldFeature = ref(false);
|
this.resizeTimer = setTimeout(() => {
|
||||||
const foldCount = ref(0);
|
callback();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
const isWideScreen = computed(() => {
|
|
||||||
return screenWidth.value >= 900;
|
window.addEventListener('resize', handler);
|
||||||
});
|
this.resizeListeners.push({
|
||||||
|
callback,
|
||||||
|
handler
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回清理函数
|
||||||
// 管理器实例
|
return () => this.removeResizeListener(callback);
|
||||||
const manager = new ScreenDetectionManager();
|
}
|
||||||
|
|
||||||
// 初始化屏幕检测
|
// 移除 resize 监听器
|
||||||
const initScreenDetection = async () => {
|
removeResizeListener(callback) {
|
||||||
if (isLoading.value || isInitialized.value) return;
|
const index = this.resizeListeners.findIndex(item => item.callback === callback);
|
||||||
isLoading.value = true;
|
if (index > -1) {
|
||||||
try {
|
const {
|
||||||
// 检测屏幕状态
|
handler
|
||||||
const width = await manager.getScreenWidth();
|
} = this.resizeListeners[index];
|
||||||
const { foldable, count } = manager.checkVisualViewport();
|
window.removeEventListener('resize', handler);
|
||||||
|
this.resizeListeners.splice(index, 1);
|
||||||
foldFeature.value = foldable;
|
}
|
||||||
foldCount.value = count;
|
}
|
||||||
screenWidth.value = width;
|
|
||||||
isInitialized.value = true;
|
|
||||||
|
}
|
||||||
console.log(`📱 屏幕检测完成: ${width}px, 宽屏: ${isWideScreen.value}`);
|
|
||||||
console.log(`是否为折叠屏: ${foldFeature.value},折叠屏数量:${foldCount.value}`);
|
// 创建屏幕状态 store
|
||||||
|
const useScreenStore = defineStore('screen', () => {
|
||||||
// 根据状态加载或移除 CSS
|
// 状态
|
||||||
manager.updateWideScreenCSS(isWideScreen.value);
|
const screenWidth = ref(0);
|
||||||
manager.updateTabbar(isWideScreen.value);
|
const isInitialized = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
// 设置 resize 监听
|
const foldFeature = ref(false);
|
||||||
setupResizeListener();
|
const foldCount = ref(0);
|
||||||
return {
|
|
||||||
screenWidth: width,
|
|
||||||
isWideScreen: isWideScreen.value,
|
const isWideScreen = computed(() => {
|
||||||
foldable,
|
return screenWidth.value >= 900;
|
||||||
count
|
});
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('初始化屏幕检测失败:', error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
// 管理器实例
|
||||||
}
|
const manager = new ScreenDetectionManager();
|
||||||
};
|
|
||||||
|
// 初始化屏幕检测
|
||||||
// 手动更新屏幕状态
|
const initScreenDetection = async () => {
|
||||||
const updateScreenStatus = async () => {
|
if (isLoading.value || isInitialized.value) return;
|
||||||
try {
|
isLoading.value = true;
|
||||||
const width = await manager.getScreenWidth();
|
try {
|
||||||
const { foldable, count } = manager.checkVisualViewport();
|
// 检测屏幕状态
|
||||||
|
const width = await manager.getScreenWidth();
|
||||||
// 保存旧状态
|
const {
|
||||||
const oldWidth = screenWidth.value;
|
foldable,
|
||||||
const oldIsWideScreen = isWideScreen.value;
|
count
|
||||||
const oldFoldable = foldFeature.value;
|
} = manager.checkVisualViewport();
|
||||||
const oldFoldCount = foldCount.value;
|
|
||||||
|
foldFeature.value = foldable;
|
||||||
// 更新状态
|
foldCount.value = count;
|
||||||
screenWidth.value = width;
|
screenWidth.value = width;
|
||||||
foldFeature.value = foldable;
|
isInitialized.value = true;
|
||||||
foldCount.value = count;
|
|
||||||
|
console.log(`📱 屏幕检测完成: ${width}px, 宽屏: ${isWideScreen.value}`);
|
||||||
// 检查宽屏状态是否发生变化
|
console.log(`是否为折叠屏: ${foldFeature.value},折叠屏数量:${foldCount.value}`);
|
||||||
if (oldIsWideScreen !== isWideScreen.value) {
|
|
||||||
console.log(`🔄 屏幕状态变化: ${oldIsWideScreen ? '宽屏' : '非宽屏'} -> ${isWideScreen.value ? '宽屏' : '非宽屏'}`);
|
// 根据状态加载或移除 CSS
|
||||||
console.log(`屏幕宽度变化: ${oldWidth}px -> ${width}px`);
|
manager.updateWideScreenCSS(isWideScreen.value);
|
||||||
manager.updateWideScreenCSS(isWideScreen.value);
|
manager.updateTabbar(isWideScreen.value);
|
||||||
manager.updateTabbar(isWideScreen.value);
|
|
||||||
}
|
// 设置 resize 监听
|
||||||
|
setupResizeListener();
|
||||||
// 检查折叠屏状态是否发生变化
|
return {
|
||||||
if (oldFoldable !== foldable || oldFoldCount !== count) {
|
screenWidth: width,
|
||||||
console.log(`折叠屏状态变化: ${oldFoldable ? '是' : '否'}->${foldable ? '是' : '否'}, 折叠数: ${oldFoldCount}->${count}`);
|
isWideScreen: isWideScreen.value,
|
||||||
}
|
foldable,
|
||||||
|
count
|
||||||
return {
|
};
|
||||||
screenWidth: width,
|
} catch (error) {
|
||||||
isWideScreen: isWideScreen.value,
|
console.error('初始化屏幕检测失败:', error);
|
||||||
foldable,
|
return null;
|
||||||
count
|
} finally {
|
||||||
};
|
isLoading.value = false;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('更新屏幕状态失败:', error);
|
};
|
||||||
return null;
|
|
||||||
}
|
// 手动更新屏幕状态
|
||||||
};
|
const updateScreenStatus = async () => {
|
||||||
|
try {
|
||||||
// 设置 resize 监听
|
const width = await manager.getScreenWidth();
|
||||||
let cleanupResizeListener = () => {};
|
const {
|
||||||
const setupResizeListener = () => {
|
foldable,
|
||||||
if (typeof window === 'undefined') return;
|
count
|
||||||
|
} = manager.checkVisualViewport();
|
||||||
cleanupResizeListener = manager.addResizeListener(async () => {
|
|
||||||
await updateScreenStatus();
|
// 保存旧状态
|
||||||
}, 300);
|
const oldWidth = screenWidth.value;
|
||||||
};
|
const oldIsWideScreen = isWideScreen.value;
|
||||||
|
const oldFoldable = foldFeature.value;
|
||||||
// 手动触发屏幕检测
|
const oldFoldCount = foldCount.value;
|
||||||
const detectScreen = () => {
|
|
||||||
return updateScreenStatus();
|
// 更新状态
|
||||||
};
|
screenWidth.value = width;
|
||||||
|
foldFeature.value = foldable;
|
||||||
|
foldCount.value = count;
|
||||||
|
|
||||||
return {
|
// 检查宽屏状态是否发生变化
|
||||||
// 状态
|
if (oldIsWideScreen !== isWideScreen.value) {
|
||||||
foldFeature,
|
console.log(
|
||||||
foldCount,
|
`🔄 屏幕状态变化: ${oldIsWideScreen ? '宽屏' : '非宽屏'} -> ${isWideScreen.value ? '宽屏' : '非宽屏'}`
|
||||||
screenWidth,
|
);
|
||||||
isInitialized,
|
console.log(`屏幕宽度变化: ${oldWidth}px -> ${width}px`);
|
||||||
isLoading,
|
manager.updateWideScreenCSS(isWideScreen.value);
|
||||||
|
manager.updateTabbar(isWideScreen.value);
|
||||||
// 响应式计算属性
|
}
|
||||||
isWideScreen,
|
|
||||||
|
// 检查折叠屏状态是否发生变化
|
||||||
// 方法
|
if (oldFoldable !== foldable || oldFoldCount !== count) {
|
||||||
initScreenDetection,
|
console.log(
|
||||||
updateScreenStatus,
|
`折叠屏状态变化: ${oldFoldable ? '是' : '否'}->${foldable ? '是' : '否'}, 折叠数: ${oldFoldCount}->${count}`
|
||||||
detectScreen,
|
);
|
||||||
};
|
}
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
screenWidth: width,
|
||||||
|
isWideScreen: isWideScreen.value,
|
||||||
|
foldable,
|
||||||
|
count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新屏幕状态失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置 resize 监听
|
||||||
|
let cleanupResizeListener = () => {};
|
||||||
|
const setupResizeListener = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
cleanupResizeListener = manager.addResizeListener(async () => {
|
||||||
|
await updateScreenStatus();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动触发屏幕检测
|
||||||
|
const detectScreen = () => {
|
||||||
|
return updateScreenStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
foldFeature,
|
||||||
|
foldCount,
|
||||||
|
screenWidth,
|
||||||
|
isInitialized,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
// 响应式计算属性
|
||||||
|
isWideScreen,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
initScreenDetection,
|
||||||
|
updateScreenStatus,
|
||||||
|
detectScreen,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export default useScreenStore;
|
export default useScreenStore;
|
||||||
170
utils/fileValidator.js
Normal file
170
utils/fileValidator.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const KNOWN_SIGNATURES = {
|
||||||
|
png: '89504E470D0A1A0A',
|
||||||
|
jpg: 'FFD8FF',
|
||||||
|
jpeg: 'FFD8FF',
|
||||||
|
gif: '47494638',
|
||||||
|
webp: '52494646',
|
||||||
|
docx: '504B0304',
|
||||||
|
xlsx: '504B0304',
|
||||||
|
pptx: '504B0304',
|
||||||
|
doc: 'D0CF11E0',
|
||||||
|
xls: 'D0CF11E0',
|
||||||
|
ppt: 'D0CF11E0',
|
||||||
|
pdf: '25504446',
|
||||||
|
txt: 'TYPE_TEXT',
|
||||||
|
csv: 'TYPE_TEXT',
|
||||||
|
md: 'TYPE_TEXT',
|
||||||
|
json: 'TYPE_TEXT',
|
||||||
|
};
|
||||||
|
export class FileValidator {
|
||||||
|
version = '1.0.0';
|
||||||
|
signs = Object.keys(KNOWN_SIGNATURES);
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.maxSizeMB = options.maxSizeMB || 10;
|
||||||
|
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||||
|
this.allowedConfig = {};
|
||||||
|
options.allowedExtensions.forEach((ext) => {
|
||||||
|
const key = ext.toLowerCase();
|
||||||
|
if (KNOWN_SIGNATURES[key]) {
|
||||||
|
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||||
|
} else {
|
||||||
|
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.allowedConfig = {
|
||||||
|
...KNOWN_SIGNATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isValidUTF8(buffer) {
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
decoder.decode(buffer);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bufferToHex(buffer) {
|
||||||
|
return Array.prototype.map
|
||||||
|
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
_countCSVRows(buffer) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
const text = decoder.decode(buffer);
|
||||||
|
let rowCount = 0;
|
||||||
|
let inQuote = false;
|
||||||
|
let len = text.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
} else if (char === '\n' && !inQuote) {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (len > 0 && text[len - 1] !== '\n') {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
return rowCount;
|
||||||
|
}
|
||||||
|
_validateTextContent(buffer, extension) {
|
||||||
|
let contentStr = '';
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
contentStr = decoder.decode(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('UTF-8 解码失败', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (contentStr.includes('\0')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (extension === 'json') {
|
||||||
|
try {
|
||||||
|
JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('无效的 JSON 格式');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
validate(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file || !file.name) return reject('无效的文件对象');
|
||||||
|
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||||
|
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||||
|
}
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
const expectedMagic = this.allowedConfig[extension];
|
||||||
|
if (!expectedMagic) {
|
||||||
|
return reject(`不支持的文件格式: .${extension}`);
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const buffer = e.target.result;
|
||||||
|
let isSafe = false;
|
||||||
|
if (expectedMagic === 'TYPE_TEXT') {
|
||||||
|
if (this._validateTextContent(buffer, extension)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
if (extension === 'json') {
|
||||||
|
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||||
|
}
|
||||||
|
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||||
|
}
|
||||||
|
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||||
|
const rows = this._countCSVRows(buffer);
|
||||||
|
if (rows > this.csvMaxRows) {
|
||||||
|
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||||
|
if (fileHeader.startsWith(expectedMagic)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSafe) resolve(true);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||||
|
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 【demo】
|
||||||
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||||
|
// const imageValidator = new FileValidator({
|
||||||
|
// maxSizeMB: 5,
|
||||||
|
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// imageValidator
|
||||||
|
// .validate(file)
|
||||||
|
// .then(() => {
|
||||||
|
// statusDiv.textContent = `检测通过: ${file.name}`;
|
||||||
|
// statusDiv.style.color = 'green';
|
||||||
|
// console.log('图片校验通过,开始上传...');
|
||||||
|
// // upload(file)...
|
||||||
|
// })
|
||||||
|
// .catch((err) => {
|
||||||
|
// statusDiv.textContent = `检测失败: ${err}`;
|
||||||
|
// statusDiv.style.color = 'red';
|
||||||
|
// });
|
||||||
@@ -53,7 +53,7 @@ export default function viteInjectPopup() {
|
|||||||
'\n <global-popup />\n' +
|
'\n <global-popup />\n' +
|
||||||
code.slice(lastTemplateEndIndex);
|
code.slice(lastTemplateEndIndex);
|
||||||
|
|
||||||
console.log(`🎯 [精准注入]: ${relativePath}`);
|
console.log(`[精准注入]: ${relativePath}`);
|
||||||
return {
|
return {
|
||||||
code: newCode,
|
code: newCode,
|
||||||
map: null
|
map: null
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default defineConfig({
|
|||||||
// 只保留这一个自定义插件,它只负责改 template 字符串
|
// 只保留这一个自定义插件,它只负责改 template 字符串
|
||||||
viteInjectPopup(),
|
viteInjectPopup(),
|
||||||
uni(),
|
uni(),
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user