flat: 优化语音
This commit is contained in:
9
App.vue
9
App.vue
@@ -57,14 +57,17 @@ onHide(() => {
|
||||
margin-top: -1rpx;
|
||||
}
|
||||
.uni-tabbar-border {
|
||||
/* background-color: transparent !important; */
|
||||
background-color: #e4e4e4 !important;
|
||||
background-color: transparent !important;
|
||||
/* background-color: #e4e4e4 !important; */
|
||||
}
|
||||
.uni-popup {
|
||||
z-index: 1001 !important;
|
||||
}
|
||||
/* 提升toast层级 */
|
||||
uni-toast,
|
||||
uni-modal,
|
||||
.uni-modal,
|
||||
.uni-mask {
|
||||
z-index: 999;
|
||||
z-index: 998;
|
||||
}
|
||||
</style>
|
||||
|
@@ -186,19 +186,51 @@ class IndexedDBHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* @param {string} storeName
|
||||
* @param {Object} data
|
||||
* @returns {Promise}
|
||||
* 更新数据(支持指定 key 或自动使用 keyPath)
|
||||
* @param {string} storeName 存储对象名
|
||||
* @param {Object} data 待更新数据
|
||||
* @param {IDBValidKey|IDBKeyRange} [key] 可选参数,指定更新的 key
|
||||
* @returns {Promise<string>} 更新结果
|
||||
*/
|
||||
update(storeName, data) {
|
||||
update(storeName, data, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([storeName], "readwrite");
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(data);
|
||||
const keyPath = store.keyPath;
|
||||
|
||||
request.onsuccess = () => resolve("Data updated successfully");
|
||||
request.onerror = (event) => reject(`Update Error: ${event.target.error}`);
|
||||
// 有传入 key:直接使用 key 更新
|
||||
if (key !== undefined) {
|
||||
const request = store.put(data, key);
|
||||
request.onsuccess = () => resolve("数据更新成功(指定 key)");
|
||||
request.onerror = (event) => reject(`更新失败: ${event.target.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 无传入 key:依赖 keyPath 更新
|
||||
if (!keyPath) {
|
||||
reject("当前 store 未设置 keyPath,必须传入 key 参数");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查数据是否包含 keyPath 属性
|
||||
let missingKeys = [];
|
||||
if (Array.isArray(keyPath)) {
|
||||
missingKeys = keyPath.filter(k => !data.hasOwnProperty(k));
|
||||
} else if (typeof keyPath === 'string') {
|
||||
if (!data.hasOwnProperty(keyPath)) {
|
||||
missingKeys.push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
reject(`数据缺少必要的 keyPath 属性: ${missingKeys.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认使用 keyPath 更新
|
||||
const request = store.put(data);
|
||||
request.onsuccess = () => resolve("数据更新成功(默认 keyPath)");
|
||||
request.onerror = (event) => reject(`更新失败: ${event.target.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<view v-show="internalShow" :style="fadeStyle" class="fade-wrapper">
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
duration: { type: Number, default: 300 }, // ms
|
||||
});
|
||||
|
||||
const internalShow = ref(props.show);
|
||||
const fadeStyle = ref({
|
||||
opacity: props.show ? 1 : 0,
|
||||
transition: `opacity ${props.duration}ms ease`,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
internalShow.value = true;
|
||||
requestAnimationFrame(() => {
|
||||
fadeStyle.value.opacity = 1;
|
||||
});
|
||||
} else {
|
||||
fadeStyle.value.opacity = 0;
|
||||
// 动画结束后隐藏 DOM
|
||||
setTimeout(() => {
|
||||
internalShow.value = false;
|
||||
}, props.duration);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
140
components/MsgTips/MsgTips.vue
Normal file
140
components/MsgTips/MsgTips.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<uni-popup
|
||||
ref="popup"
|
||||
type="center"
|
||||
borderRadius="10px 10px 10px 10px"
|
||||
background-color="#F6F6F6"
|
||||
:mask-click="maskClick"
|
||||
>
|
||||
<view class="popup-content">
|
||||
<view class="text-h2">
|
||||
<image v-if="icon" class="text-h2-icon" :src="icon"></image>
|
||||
{{ title }}
|
||||
</view>
|
||||
<text class="text-content button-click">{{ content }}</text>
|
||||
<template v-if="showButton">
|
||||
<uni-button class="popup-button button-click" v-if="isTip" @click="close">{{ buttonText }}</uni-button>
|
||||
<view v-else class="confirm-btns">
|
||||
<uni-button class="popup-button button-click" @click="close">{{ cancelText }}</uni-button>
|
||||
<uni-button class="popup-button button-click" @click="confirm">{{ confirmText }}</uni-button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MsgTips',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: '', // 如:'/static/success.png'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '提示',
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '这是提示内容',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '我知道了',
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消',
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '保存并退出',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maskClosable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isTip: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maskClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
// mounted() {
|
||||
// this.$refs.popup.open('center');
|
||||
// },
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.popup.open('center');
|
||||
},
|
||||
close() {
|
||||
this.$refs.popup.close('center');
|
||||
},
|
||||
confirm() {},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-content {
|
||||
display: flex;
|
||||
padding: 40rpx;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: calc(630rpx - 80rpx);
|
||||
.text-h2 {
|
||||
font-weight: 500;
|
||||
font-size: 36rpx;
|
||||
color: #333333;
|
||||
line-height: 42rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.text-h2-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
.text-content {
|
||||
margin-top: 12rpx;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
color: #6c7282;
|
||||
line-height: 33rpx;
|
||||
text-align: justified;
|
||||
}
|
||||
.popup-button {
|
||||
background-color: #256bfa;
|
||||
color: white;
|
||||
border-radius: 30px;
|
||||
text-align: center;
|
||||
height: 90rpx;
|
||||
line-height: 90rpx;
|
||||
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
||||
margin-top: 48rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.confirm-btns {
|
||||
display: flex;
|
||||
.popup-button {
|
||||
width: 260rpx;
|
||||
}
|
||||
.popup-button:first-child {
|
||||
background-color: #e8eaee;
|
||||
margin-right: 30rpx;
|
||||
color: #333333;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view class="markdown-body">
|
||||
<rich-text class="markdownRich" id="markdown-content" :nodes="renderedHtml" @itemclick="handleItemClick" />
|
||||
<!-- <view class="markdown-body" v-html="renderedHtml"></view> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -72,15 +73,37 @@ const handleItemClick = (e) => {
|
||||
padding-inline-start: 40rpx;
|
||||
li {
|
||||
margin-bottom: -30rpx;
|
||||
display: list-item;
|
||||
list-style-position: outside; /* 确保数字/点在左侧 */
|
||||
word-break: break-word;
|
||||
p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li:nth-child(1) {
|
||||
margin-top: -40rpx;
|
||||
margin-top: -20rpx;
|
||||
}
|
||||
}
|
||||
ol {
|
||||
li {
|
||||
display: list-item;
|
||||
list-style-position: outside; /* 确保数字/点在左侧 */
|
||||
word-break: break-word;
|
||||
p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
li:nth-child(1) {
|
||||
margin-top: -20rpx;
|
||||
}
|
||||
}
|
||||
p {
|
||||
font-weight: 500;
|
||||
line-height: 44.8rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
.markdown-body {
|
||||
@@ -233,7 +256,7 @@ ol {
|
||||
box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04);
|
||||
border-radius: 20rpx 20rpx 20rpx 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
@@ -244,49 +267,66 @@ ol {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between
|
||||
.title-text
|
||||
max-width: calc(100% - 160rpx);
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
.card-tag
|
||||
font-weight: 500;
|
||||
font-size: 24rpx;
|
||||
color: #333333;
|
||||
width: fit-content;
|
||||
background: #F4F4F4;
|
||||
border-radius: 4rpx 4rpx 4rpx 4rpx;
|
||||
padding: 0rpx 20rpx;
|
||||
margin-left: 16rpx;
|
||||
font-size: 30rpx
|
||||
.card-salary
|
||||
font-size: 28rpx;
|
||||
color: #FF6E1C;
|
||||
|
||||
.card-company
|
||||
margin-top: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
max-width: calc(100%);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis
|
||||
color: #6C7282;
|
||||
.card-info
|
||||
margin-top: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 40rpx;
|
||||
.info-item
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
color: #256BFA;
|
||||
font-size: 28rpx;
|
||||
padding-right: 10rpx
|
||||
.position-nav
|
||||
position: absolute;
|
||||
right: 34rpx;
|
||||
right: -10rpx;
|
||||
top: 50%;
|
||||
.position-nav::before
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
top: -4rpx;
|
||||
content: '';
|
||||
width: 4rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 2rpx
|
||||
background: #8A8A8A;
|
||||
background: #256BFA;
|
||||
transform: translate(0, -50%) rotate(-45deg) ;
|
||||
.position-nav::after
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
top: -4rpx;
|
||||
content: '';
|
||||
width: 4rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 2rpx
|
||||
background: #8A8A8A;
|
||||
background: #256BFA;
|
||||
transform: rotate(45deg)
|
||||
.card-tag
|
||||
font-weight: 500;
|
||||
font-size: 24rpx;
|
||||
color: #333333;
|
||||
width: fit-content;
|
||||
background: #F4F4F4;
|
||||
border-radius: 4rpx 4rpx 4rpx 4rpx;
|
||||
padding: 4rpx 20rpx;
|
||||
margin-right: 16rpx;
|
||||
</style>
|
||||
|
@@ -8,8 +8,12 @@ export default {
|
||||
// 语音转文字
|
||||
// vioceBaseURl: 'ws://39.98.44.136:8080/speech-recognition',
|
||||
vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/speech-recognition',
|
||||
// 语音合成
|
||||
speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis',
|
||||
// indexedDB
|
||||
DBversion: 2,
|
||||
// 只使用本地缓寸的数据
|
||||
OnlyUseCachedDB: true,
|
||||
// 应用信息
|
||||
appInfo: {
|
||||
// 应用名称
|
||||
|
84
directives/collapse.js
Normal file
84
directives/collapse.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// directives/collapse.js
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
el._collapse = {
|
||||
duration: binding.arg ? parseInt(binding.arg) : 300, // 使用指令参数设置 duration
|
||||
expanded: binding.value,
|
||||
};
|
||||
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.transition = `height ${el._collapse.duration}ms ease, opacity ${el._collapse.duration}ms ease`;
|
||||
|
||||
if (!binding.value) {
|
||||
el.style.height = '0px';
|
||||
el.style.opacity = '0';
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
getHeight(el).then((height) => {
|
||||
el.style.height = height + 'px';
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const duration = el._collapse.duration;
|
||||
const isShow = binding.value;
|
||||
|
||||
if (isShow === el._collapse.expanded) return;
|
||||
|
||||
el._collapse.expanded = isShow;
|
||||
|
||||
if (isShow) {
|
||||
getHeight(el).then((height) => {
|
||||
el.style.transition = `none`;
|
||||
el.style.height = '0px';
|
||||
el.style.opacity = '0';
|
||||
|
||||
// 动画开始
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = `height ${duration}ms ease, opacity ${duration}ms ease`;
|
||||
el.style.height = height + 'px';
|
||||
el.style.opacity = '1';
|
||||
|
||||
// 动画结束后设置为 auto(避免内容变化导致高度错误)
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
}, duration);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
getHeight(el).then((height) => {
|
||||
console.log(height)
|
||||
el.style.height = height + 'px';
|
||||
el.style.opacity = '1';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.style.height = '0px';
|
||||
el.style.opacity = '0';
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
delete el._collapse;
|
||||
},
|
||||
};
|
||||
|
||||
// 获取元素高度(兼容 H5 和小程序)
|
||||
function getHeight(el) {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef H5
|
||||
resolve(el.scrollHeight);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(el).boundingClientRect((res) => {
|
||||
resolve(res?.height || 0);
|
||||
}).exec();
|
||||
// #endif
|
||||
});
|
||||
}
|
23
directives/fade.js
Normal file
23
directives/fade.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
const duration = binding.arg ? parseInt(binding.arg) : 300;
|
||||
el.style.transition = `opacity ${duration}ms ease`;
|
||||
el.style.opacity = binding.value ? '1' : '0';
|
||||
if (!binding.value) el.style.display = 'none';
|
||||
},
|
||||
updated(el, binding) {
|
||||
const duration = binding.arg ? parseInt(binding.arg) : 300;
|
||||
|
||||
if (binding.value) {
|
||||
el.style.display = '';
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
} else {
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
el.style.display = 'none';
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
};
|
BIN
hook/.DS_Store
vendored
BIN
hook/.DS_Store
vendored
Binary file not shown.
@@ -31,8 +31,8 @@ export function useAudioRecorder(wsUrl) {
|
||||
// 配置常量
|
||||
const SAMPLE_RATE = 16000;
|
||||
const SILENCE_THRESHOLD = 0.02; // 静音阈值 (0-1)
|
||||
const SILENCE_DURATION = 400; // 静音持续时间(ms)后切片
|
||||
const MIN_SOUND_DURATION = 300; // 最小有效声音持续时间(ms)
|
||||
const SILENCE_DURATION = 100; // 静音持续时间(ms)后切片
|
||||
const MIN_SOUND_DURATION = 200; // 最小有效声音持续时间(ms)
|
||||
|
||||
// 音频处理变量
|
||||
const lastSoundTime = ref(0);
|
||||
@@ -125,6 +125,7 @@ export function useAudioRecorder(wsUrl) {
|
||||
socket.value = new WebSocket(wsUrl);
|
||||
|
||||
socket.value.onopen = () => {
|
||||
console.log('open')
|
||||
isSocketConnected.value = true;
|
||||
resolve();
|
||||
};
|
||||
@@ -191,7 +192,6 @@ export function useAudioRecorder(wsUrl) {
|
||||
if (audioChunks.value.length === 0 || !socket.value || socket.value.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 合并所有块
|
||||
const totalBytes = audioChunks.value.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
|
249
hook/useTTSPlayer.js
Normal file
249
hook/useTTSPlayer.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted,
|
||||
onBeforeUnmount,
|
||||
onMounted
|
||||
} from 'vue'
|
||||
import {
|
||||
onHide,
|
||||
onUnload
|
||||
} from '@dcloudio/uni-app'
|
||||
import WavDecoder from '@/lib/wav-decoder@1.3.0.js'
|
||||
|
||||
export function useTTSPlayer(wsUrl) {
|
||||
const isSpeaking = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const isComplete = ref(false)
|
||||
|
||||
const audioContext = new(window.AudioContext || window.webkitAudioContext)()
|
||||
let playTime = audioContext.currentTime
|
||||
let sourceNodes = []
|
||||
let socket = null
|
||||
let sampleRate = 16000
|
||||
let numChannels = 1
|
||||
let isHeaderDecoded = false
|
||||
let pendingText = null
|
||||
|
||||
let currentPlayId = 0
|
||||
let activePlayId = 0
|
||||
|
||||
const speak = (text) => {
|
||||
currentPlayId++
|
||||
const myPlayId = currentPlayId
|
||||
reset()
|
||||
pendingText = text
|
||||
activePlayId = myPlayId
|
||||
}
|
||||
|
||||
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(wsUrl)
|
||||
socket.binaryType = 'arraybuffer'
|
||||
|
||||
socket.onopen = () => {
|
||||
if (pendingText && thisPlayId === activePlayId) {
|
||||
const seepdText = extractSpeechText(pendingText)
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 channelData = audioBuffer.getChannelData(ch)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const sample = pcm[i * numChannels + ch]
|
||||
channelData[i] = sample / 32768
|
||||
}
|
||||
}
|
||||
return audioBuffer
|
||||
}
|
||||
|
||||
const playBuffer = (audioBuffer) => {
|
||||
if (!isSpeaking.value) {
|
||||
playTime = audioContext.currentTime
|
||||
}
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(audioContext.destination)
|
||||
source.start(playTime)
|
||||
sourceNodes.push(source)
|
||||
playTime += audioBuffer.duration
|
||||
isSpeaking.value = true
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
// 页面刷新/关闭时
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', cancelAudio)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAudio()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('beforeunload', cancelAudio)
|
||||
}
|
||||
})
|
||||
|
||||
onHide(cancelAudio)
|
||||
onUnload(cancelAudio)
|
||||
|
||||
initWebSocket()
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
cancelAudio,
|
||||
isSpeaking,
|
||||
isPaused,
|
||||
isComplete
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
8
lib/wav-decoder@1.3.0.js
Normal file
8
lib/wav-decoder@1.3.0.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
|
||||
* Original file: /npm/wav-decoder@1.3.0/index.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
var n="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},t={exports:{}};!function(t){var r={1:"lpcm",3:"lpcm"};function e(t,r){r=r||{},n.Buffer&&t instanceof n.Buffer&&(t=Uint8Array.from(t).buffer);var e=function(n){var t=0;return{remain:function(){return n.byteLength-t},skip:function(n){t+=n},uint8:function(){var r=n.getUint8(t,!0);return t+=1,r},int16:function(){var r=n.getInt16(t,!0);return t+=2,r},uint16:function(){var r=n.getUint16(t,!0);return t+=2,r},uint32:function(){var r=n.getUint32(t,!0);return t+=4,r},string:function(n){for(var t="",r=0;r<n;r++)t+=String.fromCharCode(this.uint8());return t},pcm8:function(){var r=n.getUint8(t)-128;return t+=1,r<0?r/128:r/127},pcm8s:function(){var r=n.getUint8(t)-127.5;return t+=1,r/127.5},pcm16:function(){var r=n.getInt16(t,!0);return t+=2,r<0?r/32768:r/32767},pcm16s:function(){var r=n.getInt16(t,!0);return t+=2,r/32768},pcm24:function(){var r=n.getUint8(t+0)+(n.getUint8(t+1)<<8)+(n.getUint8(t+2)<<16),e=r>8388608?r-16777216:r;return t+=3,e<0?e/8388608:e/8388607},pcm24s:function(){var r=n.getUint8(t+0)+(n.getUint8(t+1)<<8)+(n.getUint8(t+2)<<16);return t+=3,(r>8388608?r-16777216:r)/8388608},pcm32:function(){var r=n.getInt32(t,!0);return t+=4,r<0?r/2147483648:r/2147483647},pcm32s:function(){var r=n.getInt32(t,!0);return t+=4,r/2147483648},pcm32f:function(){var r=n.getFloat32(t,!0);return t+=4,r},pcm64f:function(){var r=n.getFloat64(t,!0);return t+=8,r}}}(new DataView(t));if("RIFF"!==e.string(4))throw new TypeError("Invalid WAV file");if(e.uint32(),"WAVE"!==e.string(4))throw new TypeError("Invalid WAV file");var a=null,u=null;do{var f=e.string(4),c=e.uint32();switch(f){case"fmt ":if((a=i(e,c))instanceof Error)throw a;break;case"data":if((u=o(e,c,a,r))instanceof Error)throw u;break;default:e.skip(c)}}while(null===u);return u}function i(n,t){var e=n.uint16();if(!r.hasOwnProperty(e))return new TypeError("Unsupported format in WAV file: 0x"+e.toString(16));var i={formatId:e,floatingPoint:3===e,numberOfChannels:n.uint16(),sampleRate:n.uint32(),byteRate:n.uint32(),blockSize:n.uint16(),bitDepth:n.uint16()};return n.skip(t-16),i}function o(n,t,r,e){t=Math.min(t,n.remain());for(var i=Math.floor(t/r.blockSize),o=r.numberOfChannels,a=r.sampleRate,u=new Array(o),f=0;f<o;f++)u[f]=new Float32Array(i);var c=function(n,t,r,e,i){var o=e.bitDepth,a=e.floatingPoint?"f":i.symmetric?"s":"",u="pcm"+o+a;if(!n[u])return new TypeError("Not supported bit depth: "+e.bitDepth);for(var f=n[u].bind(n),c=e.numberOfChannels,l=0;l<r;l++)for(var s=0;s<c;s++)t[s][l]=f();return null}(n,u,i,r,e);return c instanceof Error?c:{numberOfChannels:o,length:i,sampleRate:a,channelData:u}}t.exports.decode=function(n,t){return new Promise((function(r){r(e(n,t))}))},t.exports.decode.sync=e}(t);var r=t.exports,e=t.exports.decode;export{e as decode,r as default};
|
||||
//# sourceMappingURL=/sm/fbea2696df56c6f4d12f9fbda87f17f1f841032310912954cb174af4406eea33.map
|
13
main.js
13
main.js
@@ -4,7 +4,12 @@ import globalFunction from '@/common/globalFunction'
|
||||
import '@/lib/string-similarity.min.js'
|
||||
import similarityJobs from '@/utils/similarity_Job.js';
|
||||
import NoBouncePage from '@/components/NoBouncePage/NoBouncePage.vue'
|
||||
import MsgTips from '@/components/MsgTips/MsgTips.vue'
|
||||
// import Tabbar from '@/components/tabbar/midell-box.vue'
|
||||
// 自动导入 directives 目录下所有指令
|
||||
const directives = import.meta.glob('./directives/*.js', {
|
||||
eager: true
|
||||
});
|
||||
|
||||
import {
|
||||
createSSRApp,
|
||||
@@ -15,8 +20,16 @@ export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
|
||||
app.component('NoBouncePage', NoBouncePage)
|
||||
app.component('MsgTips', MsgTips)
|
||||
// app.component('tabbar-custom', Tabbar)
|
||||
|
||||
for (const path in directives) {
|
||||
const directiveModule = directives[path];
|
||||
// 文件名作为指令名,./directives/fade.js => v-fade
|
||||
const name = path.match(/\.\/directives\/(.*)\.js$/)[1];
|
||||
app.directive(name, directiveModule.default);
|
||||
}
|
||||
|
||||
app.provide('globalFunction', {
|
||||
...globalFunction,
|
||||
similarityJobs
|
||||
|
@@ -63,6 +63,7 @@
|
||||
<ai-paging ref="paging"></ai-paging>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义tabbar -->
|
||||
<!-- <tabbar-custom :currentpage="2"></tabbar-custom> -->
|
||||
</view>
|
||||
@@ -143,12 +144,13 @@ function updateSetting() {
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
header-height = 88rpx
|
||||
|
||||
/* 页面容器 */
|
||||
.container {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
z-index: 1000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: calc(100% - var(--window-bottom));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ header-height = 88rpx
|
||||
top: 0;
|
||||
left: 0; /* 如果要右侧弹出改为 right: 0; */
|
||||
width: 523rpx;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
background: #e7e7e6;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
@@ -271,7 +273,7 @@ header-height = 88rpx
|
||||
/* 主要内容区域 */
|
||||
.main-content
|
||||
width: 100%;
|
||||
height: calc(100% - var(--window-bottom));
|
||||
height: 100%;
|
||||
transition: margin-left 0.3s ease-in-out;
|
||||
position: relative
|
||||
background: #FFFFFF
|
||||
@@ -286,9 +288,8 @@ header-height = 88rpx
|
||||
right: var(--window-right);
|
||||
height:header-height;
|
||||
padding-top: calc(14rpx);
|
||||
border: 2rpx solid #F4F4F4;
|
||||
background: #FFFFFF
|
||||
z-index: 998;
|
||||
z-index: 1;
|
||||
transition-property: all;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -308,6 +309,7 @@ header-height = 88rpx
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border-top: 2rpx solid #F4F4F4;
|
||||
|
||||
/* 页面被挤压时向右移动 */
|
||||
.main-content.shift {
|
||||
|
@@ -102,43 +102,36 @@ const initWaveBars = () => {
|
||||
// 更新波形显示
|
||||
const updateWaveform = () => {
|
||||
if (!props.isActive) return;
|
||||
// 如果没有传入音频数据,则使用模拟数据
|
||||
|
||||
const AMPLIFY = 1.6; // 振幅放大
|
||||
const center = centerIndex.value;
|
||||
|
||||
// 如果没有传入音频数据,则使用模拟数据(加强振幅)
|
||||
const audioData =
|
||||
props.audioData.length > 0
|
||||
? props.audioData
|
||||
: Array(centerIndex.value + 1)
|
||||
? props.audioData.map((v) => Math.min(v * AMPLIFY, 1))
|
||||
: Array(center + 1)
|
||||
.fill(0)
|
||||
.map(() => Math.random() * 0.5 + 0.2);
|
||||
// 从中间向两侧处理
|
||||
for (let i = 0; i <= centerIndex.value; i++) {
|
||||
// 左侧条索引
|
||||
const leftIndex = centerIndex.value - i;
|
||||
// 右侧条索引
|
||||
const rightIndex = centerIndex.value + i;
|
||||
.map(() => Math.random() * 0.7 + 0.3); // 模拟值更明显
|
||||
|
||||
// 获取音频数据值 (归一化到0-1)
|
||||
for (let i = 0; i <= center; i++) {
|
||||
const leftIndex = center - i;
|
||||
const rightIndex = center + i;
|
||||
const value = audioData[i] || 0;
|
||||
|
||||
// 更新左侧条
|
||||
if (leftIndex >= 0) {
|
||||
updateWaveBar(leftIndex, value);
|
||||
}
|
||||
|
||||
// 更新右侧条(避免重复更新中心条)
|
||||
if (leftIndex >= 0) updateWaveBar(leftIndex, value);
|
||||
if (rightIndex < waveBars.value.length && rightIndex !== leftIndex) {
|
||||
updateWaveBar(rightIndex, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 继续动画
|
||||
animationId = requestAnimationFrame(updateWaveform);
|
||||
};
|
||||
|
||||
// 更新单个波形条
|
||||
const updateWaveBar = (index, value) => {
|
||||
// 动态高度 (4rpx到200rpx之间)
|
||||
const height = 2 + value * 98;
|
||||
|
||||
// 动态高度 (4rpx到42rpx之间)
|
||||
const height = 2 + value * 38;
|
||||
// // 动态颜色
|
||||
// let color;
|
||||
// if (props.isCanceling) {
|
||||
|
@@ -1,116 +1,114 @@
|
||||
<template>
|
||||
<view class="chat-container">
|
||||
<FadeView :show="!messages.length" :duration="600">
|
||||
<view class="chat-background">
|
||||
<image class="backlogo" src="/static/icon/backAI.png"></image>
|
||||
<view class="back-rowTitle">嗨!欢迎使用青岛AI智能求职</view>
|
||||
<view class="back-rowText">
|
||||
我可以根据您的简历和求职需求,帮你精准匹配青岛市互联网招聘信息,对比招聘信息的优缺点,提供面试指导等,请把你的任务交给我吧~
|
||||
</view>
|
||||
<view class="back-rowh3">猜你所想</view>
|
||||
<view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)">
|
||||
{{ item }}
|
||||
<view class="chat-background" v-fade:600="!messages.length">
|
||||
<image class="backlogo" src="/static/icon/backAI.png"></image>
|
||||
<view class="back-rowTitle">嗨!欢迎使用青岛AI智能求职</view>
|
||||
<view class="back-rowText">
|
||||
我可以根据您的简历和求职需求,帮你精准匹配青岛市互联网招聘信息,对比招聘信息的优缺点,提供面试指导等,请把你的任务交给我吧~
|
||||
</view>
|
||||
<view class="back-rowh3">猜你所想</view>
|
||||
<view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)">
|
||||
{{ item }}
|
||||
</view>
|
||||
<view class="chat-item self" v-if="isRecording">
|
||||
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
|
||||
<view class="chat-list list-content" v-fade:600="messages.length >= 1">
|
||||
<view
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
:id="'msg-' + index"
|
||||
class="chat-item"
|
||||
:class="{ self: msg.self }"
|
||||
>
|
||||
<view class="message" v-if="msg.self">
|
||||
<view class="msg-filecontent" v-if="msg.files.length">
|
||||
<view
|
||||
class="msg-files btn-light"
|
||||
v-for="(file, vInex) in msg.files"
|
||||
:key="vInex"
|
||||
@click="jumpUrl(file)"
|
||||
>
|
||||
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
|
||||
<text class="msg-file-text">{{ file.name || '附件' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
{{ msg.displayText }}
|
||||
</view>
|
||||
<view class="message" :class="{ messageNull: !msg.displayText }" v-else>
|
||||
<!-- {{ msg.displayText }} -->
|
||||
<view class="message-markdown">
|
||||
<md-render
|
||||
:content="msg.displayText"
|
||||
:typing="isTyping && messages.length - 1 === index"
|
||||
></md-render>
|
||||
<view class="message-controll" v-show="showControll(index)">
|
||||
<view class="controll-left">
|
||||
<image
|
||||
class="controll-icon btn-light"
|
||||
src="/static/icon/copy.png"
|
||||
@click="copyMarkdown(msg.displayText)"
|
||||
></image>
|
||||
<image
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/feedback.png"
|
||||
v-if="!msg.userBadFeedback"
|
||||
@click="userGoodFeedback(msg)"
|
||||
></image>
|
||||
<image
|
||||
v-if="isSpeaking && !isPaused && speechIndex === index"
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/stop.png"
|
||||
@click="stopMarkdown(msg.displayText, index)"
|
||||
></image>
|
||||
<image
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/broadcast.png"
|
||||
@click="readMarkdown(msg.displayText, index)"
|
||||
v-else
|
||||
></image>
|
||||
</view>
|
||||
<view class="controll-right">
|
||||
<image
|
||||
class="controll-icon mar_ri10 btn-light"
|
||||
src="/static/icon/refresh.png"
|
||||
@click="refreshMarkdown(index)"
|
||||
></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- guess -->
|
||||
<view
|
||||
class="guess"
|
||||
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
|
||||
>
|
||||
<view class="gulist">
|
||||
<view
|
||||
class="guess-list"
|
||||
@click="sendMessageGuess(item)"
|
||||
v-for="(item, index) in guessList"
|
||||
:key="index"
|
||||
>
|
||||
{{ item }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chat-item self" v-if="isRecording">
|
||||
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</FadeView>
|
||||
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
|
||||
<FadeView :show="messages.length >= 1" :duration="600">
|
||||
<view class="chat-list list-content">
|
||||
<view
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
:id="'msg-' + index"
|
||||
class="chat-item"
|
||||
:class="{ self: msg.self }"
|
||||
>
|
||||
<view class="message" v-if="msg.self">
|
||||
<view class="msg-filecontent" v-if="msg.files.length">
|
||||
<view
|
||||
class="msg-files btn-light"
|
||||
v-for="(file, vInex) in msg.files"
|
||||
:key="vInex"
|
||||
@click="jumpUrl(file)"
|
||||
>
|
||||
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
|
||||
<text class="msg-file-text">{{ file.name || '附件' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
{{ msg.displayText }}
|
||||
</view>
|
||||
<view class="message" :class="{ messageNull: !msg.displayText }" v-else>
|
||||
<!-- {{ msg.displayText }} -->
|
||||
<view class="message-markdown">
|
||||
<md-render
|
||||
:content="msg.displayText"
|
||||
:typing="isTyping && messages.length - 1 === index"
|
||||
></md-render>
|
||||
<view class="message-controll" v-show="showControll(index)">
|
||||
<view class="controll-left">
|
||||
<image
|
||||
class="controll-icon btn-light"
|
||||
src="/static/icon/copy.png"
|
||||
@click="copyMarkdown(msg.displayText)"
|
||||
></image>
|
||||
<image
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/feedback.png"
|
||||
@click="userGoodFeedback(msg)"
|
||||
></image>
|
||||
<image
|
||||
v-if="isSpeaking && !isPaused && speechIndex === index"
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/stop.png"
|
||||
@click="stopMarkdown(msg.displayText, index)"
|
||||
></image>
|
||||
<image
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/broadcast.png"
|
||||
@click="readMarkdown(msg.displayText, index)"
|
||||
v-else
|
||||
></image>
|
||||
</view>
|
||||
<view class="controll-right">
|
||||
<image
|
||||
class="controll-icon mar_ri10 btn-light"
|
||||
src="/static/icon/refresh.png"
|
||||
@click="refreshMarkdown(index)"
|
||||
></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- guess -->
|
||||
<view
|
||||
class="guess"
|
||||
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
|
||||
>
|
||||
<view class="gulist">
|
||||
<view
|
||||
class="guess-list"
|
||||
@click="sendMessageGuess(item)"
|
||||
v-for="(item, index) in guessList"
|
||||
:key="index"
|
||||
>
|
||||
{{ item }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chat-item self" v-if="isRecording">
|
||||
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
|
||||
</view>
|
||||
<view v-if="isTyping" class="self">
|
||||
<text class="message msg-loading">
|
||||
<span class="ai-loading"></span>
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="isTyping" class="self">
|
||||
<text class="message msg-loading">
|
||||
<span class="ai-loading"></span>
|
||||
</text>
|
||||
</view>
|
||||
</FadeView>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="vio_container" @click="handleTouchEnd" :class="status" v-if="status !== 'idle'">
|
||||
<!-- 使用 v-show 保证不销毁事件 -->
|
||||
<view class="vio_container" :class="status" v-show="status !== 'idle'">
|
||||
<view class="record-tip">{{ statusText }}</view>
|
||||
<WaveDisplay
|
||||
:background="audiowaveStyle"
|
||||
@@ -119,7 +117,7 @@
|
||||
:showInfo="isRecording"
|
||||
/>
|
||||
</view>
|
||||
<view class="input-area" v-else>
|
||||
<view class="input-area" v-show="status === 'idle'">
|
||||
<view class="areatext">
|
||||
<input
|
||||
v-model="textInput"
|
||||
@@ -133,7 +131,7 @@
|
||||
/>
|
||||
<view
|
||||
class="input_vio"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchstart.prevent="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@touchcancel="handleTouchCancel"
|
||||
@@ -222,28 +220,42 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
<PopupFeeBack ref="feeback" @onSend="confirmFeeBack"></PopupFeeBack>
|
||||
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject, nextTick, defineProps, defineEmits, onMounted, toRaw, reactive, computed } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
inject,
|
||||
nextTick,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toRaw,
|
||||
reactive,
|
||||
computed,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import config from '@/config.js';
|
||||
import useChatGroupDBStore from '@/stores/userChatGroupStore';
|
||||
import MdRender from '@/components/md-render/md-render.vue';
|
||||
import CollapseTransition from '@/components/CollapseTransition/CollapseTransition.vue';
|
||||
import FadeView from '@/components/FadeView/FadeView.vue';
|
||||
import PopupFeeBack from './popupbadFeeback.vue';
|
||||
import AudioWave from './AudioWave.vue';
|
||||
import WaveDisplay from './WaveDisplay.vue';
|
||||
import FileIcon from './fileIcon.vue';
|
||||
import FileText from './fileText.vue';
|
||||
import { useSpeechReader } from '@/hook/useSpeechReader';
|
||||
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
|
||||
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
|
||||
// 全局
|
||||
const { $api, navTo, throttle } = inject('globalFunction');
|
||||
const emit = defineEmits(['onConfirm']);
|
||||
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
|
||||
|
||||
import successIcon from '@/static/icon/success.png';
|
||||
// hook
|
||||
const {
|
||||
isRecording,
|
||||
@@ -256,7 +268,7 @@ const {
|
||||
lastFinalText,
|
||||
} = useAudioRecorder(config.vioceBaseURl);
|
||||
|
||||
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useSpeechReader();
|
||||
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio, audioUrl } = useTTSPlayer(config.speechSynthesis);
|
||||
|
||||
// state
|
||||
const queries = ref([]);
|
||||
@@ -272,7 +284,11 @@ const startY = ref(0);
|
||||
const cancelThreshold = 100;
|
||||
const speechIndex = ref(0);
|
||||
const isAudioPermission = ref(false);
|
||||
|
||||
const feebackData = ref(null);
|
||||
// ref for DOM element
|
||||
const voiceBtn = ref(null);
|
||||
const feeback = ref(null);
|
||||
const feeBackTips = ref(null);
|
||||
const state = reactive({
|
||||
uploadFileTips: '请根据以上附件,帮我推荐岗位。',
|
||||
});
|
||||
@@ -510,7 +526,6 @@ const handleTouchStart = async (e) => {
|
||||
return tipsPermisson();
|
||||
}
|
||||
cancelAudio();
|
||||
console.log('handleTouchStart');
|
||||
startY.value = e.touches[0].clientY;
|
||||
status.value = 'recording';
|
||||
showfile.value = false;
|
||||
@@ -521,7 +536,6 @@ const handleTouchStart = async (e) => {
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
console.log('handleTouchMove');
|
||||
const moveY = e.touches[0].clientY;
|
||||
if (startY.value - moveY > cancelThreshold) {
|
||||
status.value = 'cancel';
|
||||
@@ -531,28 +545,23 @@ const handleTouchMove = (e) => {
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
console.log('handleTouchEnd');
|
||||
if (status.value === 'cancel') {
|
||||
console.log('取消发送');
|
||||
cancelRecording();
|
||||
} else {
|
||||
console.log('stopRecording');
|
||||
stopRecording();
|
||||
$api.sleep(1000).then(() => {
|
||||
if (isAudioPermission.value) {
|
||||
if (recognizedText.value) {
|
||||
sendMessage(recognizedText.value);
|
||||
} else {
|
||||
$api.msg('说话时长太短');
|
||||
}
|
||||
if (isAudioPermission.value) {
|
||||
if (recognizedText.value) {
|
||||
sendMessage(recognizedText.value);
|
||||
} else {
|
||||
$api.msg('说话时长太短');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
status.value = 'idle';
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
console.log('handleTouchCancel');
|
||||
stopRecording();
|
||||
status.value = 'idle';
|
||||
};
|
||||
@@ -578,9 +587,18 @@ function copyMarkdown(value) {
|
||||
}
|
||||
|
||||
function userGoodFeedback(msg) {
|
||||
$api.msg('该功能正在开发中,敬请期待后续更新!');
|
||||
console.log(msg.dataId);
|
||||
// useChatGroupDBStore().badFeedback(msg.dataId, msg.parentGroupId);
|
||||
// $api.msg('该功能正在开发中,敬请期待后续更新!');
|
||||
feeback.value?.open();
|
||||
feebackData.value = msg;
|
||||
}
|
||||
|
||||
function confirmFeeBack(value) {
|
||||
useChatGroupDBStore()
|
||||
.badFeedback(feebackData.value, value)
|
||||
.then(() => {
|
||||
feeback.value?.close();
|
||||
feeBackTips.value?.open();
|
||||
});
|
||||
}
|
||||
|
||||
function readMarkdown(value, index) {
|
||||
@@ -788,14 +806,14 @@ image-margin-top = 40rpx
|
||||
-webkit-user-select: text;
|
||||
.message-markdown
|
||||
border-radius: 0 20rpx 20rpx 20rpx;
|
||||
padding: 20rpx 20rpx 0 20rpx;
|
||||
padding: 20rpx 20rpx 20rpx 20rpx;
|
||||
background: #F6F6F6;
|
||||
.message-controll
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
border-top: 2rpx solid #EAEAEA
|
||||
padding: 24rpx 0
|
||||
padding: 24rpx 0 4rpx 0
|
||||
margin-top: 10rpx
|
||||
.controll-left
|
||||
.controll-right
|
||||
@@ -870,12 +888,19 @@ image-margin-top = 40rpx
|
||||
-khtml-user-select:none;
|
||||
-moz-user-select:none;
|
||||
-ms-user-select:none;
|
||||
touch-action: none; /* 禁用默认滚动 */
|
||||
.input_vio:active
|
||||
background: #e8e8e8
|
||||
.vio_container
|
||||
background: transparent
|
||||
padding: 28rpx
|
||||
text-align: center
|
||||
-webkit-touch-callout:none;
|
||||
-webkit-user-select:none;
|
||||
-khtml-user-select:none;
|
||||
-moz-user-select:none;
|
||||
-ms-user-select:none;
|
||||
touch-action: none; /* 禁用默认滚动 */
|
||||
.record-tip
|
||||
font-weight: 400;
|
||||
color: #909090;
|
||||
|
@@ -1,8 +1,197 @@
|
||||
<template>
|
||||
<uni-popup ref="popup" type="bottom" borderRadius="12px 12px 0 0" background-color="#F6F6F6">
|
||||
<view class="feeback">
|
||||
<view class="titile">反馈</view>
|
||||
<view class="pop-h3">针对问题</view>
|
||||
<view class="pop-content">
|
||||
<view
|
||||
class="item"
|
||||
:class="{ active: item.check }"
|
||||
@click="toggleCheck(item.id)"
|
||||
v-for="item in wt"
|
||||
:key="item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="pop-h3">针对回答</view>
|
||||
<view class="pop-content">
|
||||
<view
|
||||
class="item"
|
||||
:class="{ active: item.check }"
|
||||
@click="toggleCheck(item.id)"
|
||||
v-for="item in hd"
|
||||
:key="item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="pop-h3">我要补充</view>
|
||||
<view class="supplement">
|
||||
<textarea v-model="inputText" cols="30" rows="10"></textarea>
|
||||
</view>
|
||||
<view class="btn button-click" @click="send">提交</view>
|
||||
<view class="close-btn" @click="close"></view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, inject, defineEmits } from 'vue';
|
||||
const emit = defineEmits(['onSend']);
|
||||
const { $api } = inject('globalFunction');
|
||||
const popup = ref(null);
|
||||
const inputText = ref('');
|
||||
const wt = ref([
|
||||
{ label: '上下文错误', id: 1, check: false },
|
||||
{ label: '理解错误', id: 2, check: false },
|
||||
{ label: '未识别问题中的错误', id: 3, check: false },
|
||||
]);
|
||||
const hd = ref([
|
||||
{ label: '违法有害', id: 11, check: false },
|
||||
{ label: '内容不专业', id: 12, check: false },
|
||||
{ label: '推理错误', id: 13, check: false },
|
||||
{ label: '计算错误', id: 14, check: false },
|
||||
{ label: '内容不完整', id: 15, check: false },
|
||||
{ label: '事实错误', id: 16, check: false },
|
||||
]);
|
||||
|
||||
function open() {
|
||||
resetCheck();
|
||||
inputText.value = '';
|
||||
popup.value.open();
|
||||
}
|
||||
|
||||
function close() {
|
||||
popup.value.close();
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = getLabel();
|
||||
if (text) {
|
||||
emit('onSend', text);
|
||||
// close();
|
||||
} else {
|
||||
$api.msg('清输入反馈内容');
|
||||
}
|
||||
}
|
||||
|
||||
function getLabel() {
|
||||
const wtArr = wt.value.filter((item) => item.check).map((item) => item.label);
|
||||
const hdArr = hd.value.filter((item) => item.check).map((item) => item.label);
|
||||
let str = '';
|
||||
wtArr.length ? (str += `问题:${wtArr.join(',')}. `) : '';
|
||||
hdArr.length ? (str += `回答:${hdArr.join(',')}. `) : '';
|
||||
inputText.value ? (str += `描述:${inputText.value}. `) : '';
|
||||
return str;
|
||||
}
|
||||
|
||||
function resetCheck() {
|
||||
wt.value = wt.value.map((item) => {
|
||||
return { ...item, check: false }; // 创建新对象,确保更新响应式
|
||||
});
|
||||
hd.value = hd.value.map((item) => {
|
||||
return { ...item, check: false }; // 创建新对象,确保更新响应式
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCheck(id) {
|
||||
if (id < 10) {
|
||||
const newWt = wt.value.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, check: !item.check };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
wt.value = newWt;
|
||||
} else {
|
||||
const newHd = hd.value.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, check: !item.check };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
hd.value = newHd;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
<style lang="stylus" scoped>
|
||||
.feeback
|
||||
padding: 38rpx 32rpx;
|
||||
.titile
|
||||
font-weight: 500;
|
||||
font-size: 36rpx;
|
||||
color: #333333;
|
||||
line-height: 42rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
.pop-h3
|
||||
font-weight: 600;
|
||||
font-size: 32rpx;
|
||||
color: #000000;
|
||||
line-height: 38rpx;
|
||||
text-align: left;
|
||||
padding: 8rpx 0
|
||||
margin-top: 32rpx
|
||||
.pop-content
|
||||
.item
|
||||
width: fit-content;
|
||||
height: 80rpx;
|
||||
background: #E8EAEE;
|
||||
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
||||
text-align: center;
|
||||
line-height: 80rpx;
|
||||
padding: 0 36rpx
|
||||
border: 2rpx solid transparent;
|
||||
display: inline-block
|
||||
margin-right: 28rpx
|
||||
margin-top: 28rpx
|
||||
.active
|
||||
border: 2rpx solid #256BFA;
|
||||
color: #256BFA;
|
||||
.supplement
|
||||
height: 200rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx 20rpx 20rpx 20rpx;
|
||||
margin-top: 28rpx
|
||||
padding: 20rpx 24rpx
|
||||
.btn
|
||||
height: 90rpx;
|
||||
background: #256BFA;
|
||||
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
||||
font-weight: 500;
|
||||
font-size: 32rpx;
|
||||
color: #FFFFFF;
|
||||
line-height: 90rpx;
|
||||
text-align: center;
|
||||
margin-top: 62rpx
|
||||
.close-btn
|
||||
position: absolute;
|
||||
right: 32rpx;
|
||||
top: 32rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
.close-btn::before
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
content: '';
|
||||
width: 4rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 2rpx
|
||||
background: #5A5A68;
|
||||
transform: translate(50%, -50%) rotate(-45deg) ;
|
||||
.close-btn::after
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
content: '';
|
||||
width: 4rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 2rpx
|
||||
background: #5A5A68;
|
||||
transform: translate(50%, -50%) rotate(45deg)
|
||||
</style>
|
||||
|
BIN
static/.DS_Store
vendored
BIN
static/.DS_Store
vendored
Binary file not shown.
BIN
static/icon/.DS_Store
vendored
BIN
static/icon/.DS_Store
vendored
Binary file not shown.
BIN
static/icon/success.png
Normal file
BIN
static/icon/success.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 399 B |
@@ -13,7 +13,9 @@ class BaseStore {
|
||||
}
|
||||
checkAndInitDB() {
|
||||
// 获取本地数据库版本
|
||||
// this.initDB()
|
||||
if (config.OnlyUseCachedDB) {
|
||||
return this.initDB()
|
||||
}
|
||||
const localVersion = uni.getStorageSync('indexedDBVersion') || 1
|
||||
console.log('DBVersion: ', localVersion, config.DBversion)
|
||||
if (localVersion === config.DBversion) {
|
||||
|
@@ -47,7 +47,8 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
chatSessionID.value = tabelRow.sessionId
|
||||
initMessage(tabelRow.sessionId)
|
||||
} else {
|
||||
console.warn('本地数据库存在数据')
|
||||
if (config.OnlyUseCachedDB) return;
|
||||
console.warn('本地数据库不存在数据')
|
||||
getHistory('refresh')
|
||||
}
|
||||
}, 1000)
|
||||
@@ -132,7 +133,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
displayText: '', // 用于流式渲染展示
|
||||
dataId: customDataID
|
||||
};
|
||||
|
||||
console.log(messages.value)
|
||||
const index = messages.value.length;
|
||||
messages.value.push(newMsg);
|
||||
|
||||
@@ -143,7 +144,12 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
|
||||
function handleUnload() {
|
||||
newMsg.parentGroupId = chatSessionID.value;
|
||||
baseDB.db.add(massageName.value, newMsg);
|
||||
baseDB.db.add(massageName.value, newMsg).then((id) => {
|
||||
messages.value[index] = {
|
||||
...newMsg,
|
||||
id
|
||||
};
|
||||
});
|
||||
}
|
||||
window.addEventListener("unload", handleUnload);
|
||||
|
||||
@@ -192,15 +198,32 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
}
|
||||
|
||||
// 云端数据
|
||||
function badFeedback(dataId, sessionId = chatSessionID.value, content = '') {
|
||||
let parmas = {
|
||||
dataId: dataId,
|
||||
sessionId: sessionId,
|
||||
userBadFeedback: '这是反馈',
|
||||
}
|
||||
// $api.chatRequest('/stepped', parmas, 'POST').then((res) => {
|
||||
// console.log('反馈成功')
|
||||
// })
|
||||
function badFeedback(msgs, content = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!msgs.dataId) {
|
||||
return msg('旧数据,没有dataId')
|
||||
}
|
||||
let parmas = {
|
||||
dataId: msgs.dataId,
|
||||
sessionId: msgs.parentGroupId,
|
||||
userBadFeedback: content,
|
||||
}
|
||||
|
||||
const dbData = {
|
||||
...toRaw(msgs),
|
||||
userBadFeedback: content,
|
||||
}
|
||||
|
||||
$api.chatRequest('/stepped', parmas, 'POST').then((res) => {
|
||||
baseDB.db.update(massageName.value, dbData) // 更新本地数据库
|
||||
messages.value.forEach((item) => {
|
||||
if (item.id === dbData.id) {
|
||||
item.userBadFeedback = content
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 云端数据
|
||||
@@ -226,11 +249,11 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
sessionId: chatSessionID.value
|
||||
}
|
||||
$api.chatRequest('/detail', params, 'GET', loading).then((res) => {
|
||||
console.log('detail:', res.data)
|
||||
let list = parseHistoryDetail(res.data.list, chatSessionID.value)
|
||||
if (list.length) {
|
||||
messages.value = list
|
||||
baseDB.db.add(massageName.value, list);
|
||||
baseDB.db.add(massageName.value, list).then((ids) => {
|
||||
messages.value = listAddId(list, ids)
|
||||
});
|
||||
}
|
||||
console.log('解析后:', list)
|
||||
}).catch(() => {
|
||||
@@ -271,7 +294,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
displayText: text,
|
||||
self: self,
|
||||
text: text,
|
||||
userBadFeedback: element.userBadFeedback,
|
||||
userBadFeedback: element.userBadFeedback || '',
|
||||
dataId: element.dataId,
|
||||
files,
|
||||
})
|
||||
@@ -279,12 +302,19 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
return arr
|
||||
}
|
||||
|
||||
|
||||
function listAddId(list, ids) {
|
||||
return list.map((item, index) => ({
|
||||
...item,
|
||||
id: ids[index] || ids
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isTyping,
|
||||
textInput,
|
||||
chatSessionID,
|
||||
addMessage,
|
||||
tabeList,
|
||||
init,
|
||||
toggleTyping,
|
||||
|
BIN
uni_modules/.DS_Store
vendored
BIN
uni_modules/.DS_Store
vendored
Binary file not shown.
BIN
unpackage/.DS_Store
vendored
BIN
unpackage/.DS_Store
vendored
Binary file not shown.
BIN
unpackage/dist/.DS_Store
vendored
BIN
unpackage/dist/.DS_Store
vendored
Binary file not shown.
BIN
unpackage/dist/build/.DS_Store
vendored
BIN
unpackage/dist/build/.DS_Store
vendored
Binary file not shown.
@@ -21,18 +21,21 @@ const md = new MarkdownIt({
|
||||
<a class="custom-card" data-job-id="${jobId}">
|
||||
<div class="card-title">
|
||||
<span class="title-text" >${result.jobTitle}</span>
|
||||
<div class="card-tag">${result.location}</div>
|
||||
<div class="card-salary">${result.salary}</div>
|
||||
</div>
|
||||
<div class="card-company">${result.companyName}</div>
|
||||
<div class="card-company">${result.location}·${result.companyName}</div>
|
||||
<div class="card-info">
|
||||
<div class="info-item">${result.salary}</div>
|
||||
<div class="info-item">${result.education}</div>
|
||||
<div class="info-item">${result.experience}</div>
|
||||
<div class="info-item">
|
||||
<div class="card-tag">${result.education}</div>
|
||||
<div class="card-tag">${result.experience}</div>
|
||||
</div>
|
||||
<div class="info-item">查看详情<div class="position-nav"></div></div>
|
||||
</div>
|
||||
<div class="position-nav"></div>
|
||||
</a>
|
||||
`
|
||||
}
|
||||
// <div class="card-tag">${result.location}</div>
|
||||
// <div class="info-item">${result.salary}</div>
|
||||
// 代码块
|
||||
let preCode = ""
|
||||
try {
|
||||
|
Reference in New Issue
Block a user