flat: 优化语音

This commit is contained in:
史典卓
2025-04-16 14:24:06 +08:00
parent 0d2b8ae65f
commit 446b48ef6d
28 changed files with 1059 additions and 264 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -57,14 +57,17 @@ onHide(() => {
margin-top: -1rpx; margin-top: -1rpx;
} }
.uni-tabbar-border { .uni-tabbar-border {
/* background-color: transparent !important; */ background-color: transparent !important;
background-color: #e4e4e4 !important; /* background-color: #e4e4e4 !important; */
}
.uni-popup {
z-index: 1001 !important;
} }
/* 提升toast层级 */ /* 提升toast层级 */
uni-toast, uni-toast,
uni-modal, uni-modal,
.uni-modal, .uni-modal,
.uni-mask { .uni-mask {
z-index: 999; z-index: 998;
} }
</style> </style>

View File

@@ -186,19 +186,51 @@ class IndexedDBHelper {
} }
/** /**
* 更新数据 * 更新数据(支持指定 key 或自动使用 keyPath
* @param {string} storeName * @param {string} storeName 存储对象名
* @param {Object} data * @param {Object} data 待更新数据
* @returns {Promise} * @param {IDBValidKey|IDBKeyRange} [key] 可选参数,指定更新的 key
* @returns {Promise<string>} 更新结果
*/ */
update(storeName, data) { update(storeName, data, key) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], "readwrite"); const transaction = this.db.transaction([storeName], "readwrite");
const store = transaction.objectStore(storeName); const store = transaction.objectStore(storeName);
const request = store.put(data); const keyPath = store.keyPath;
request.onsuccess = () => resolve("Data updated successfully"); // 有传入 key直接使用 key 更新
request.onerror = (event) => reject(`Update Error: ${event.target.error}`); 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}`);
}); });
} }

View File

@@ -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>

View 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>

View File

@@ -1,6 +1,7 @@
<template> <template>
<view class="markdown-body"> <view class="markdown-body">
<rich-text class="markdownRich" id="markdown-content" :nodes="renderedHtml" @itemclick="handleItemClick" /> <rich-text class="markdownRich" id="markdown-content" :nodes="renderedHtml" @itemclick="handleItemClick" />
<!-- <view class="markdown-body" v-html="renderedHtml"></view> -->
</view> </view>
</template> </template>
@@ -72,15 +73,37 @@ const handleItemClick = (e) => {
padding-inline-start: 40rpx; padding-inline-start: 40rpx;
li { li {
margin-bottom: -30rpx; 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) { 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 { p {
font-weight: 500; font-weight: 500;
line-height: 44.8rpx; line-height: 1.5;
} }
} }
.markdown-body { .markdown-body {
@@ -233,7 +256,7 @@ ol {
box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04); box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04);
border-radius: 20rpx 20rpx 20rpx 20rpx; border-radius: 20rpx 20rpx 20rpx 20rpx;
padding: 28rpx 24rpx; padding: 28rpx 24rpx;
font-weight: 500; font-weight: 400;
font-size: 28rpx; font-size: 28rpx;
color: #333333; color: #333333;
margin-bottom: 20rpx; margin-bottom: 20rpx;
@@ -244,49 +267,66 @@ ol {
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between
.title-text .title-text
max-width: calc(100% - 160rpx); max-width: calc(100% - 160rpx);
overflow: hidden overflow: hidden
text-overflow: ellipsis text-overflow: ellipsis
.card-tag font-size: 30rpx
font-weight: 500; .card-salary
font-size: 24rpx; font-size: 28rpx;
color: #333333; color: #FF6E1C;
width: fit-content;
background: #F4F4F4;
border-radius: 4rpx 4rpx 4rpx 4rpx;
padding: 0rpx 20rpx;
margin-left: 16rpx;
.card-company .card-company
margin-top: 12rpx; margin-top: 16rpx;
max-width: calc(100%);
overflow: hidden;
text-overflow: ellipsis
color: #6C7282;
.card-info .card-info
margin-top: 12rpx; margin-top: 22rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-right: 40rpx; padding-right: 40rpx;
.info-item
display: flex;
position: relative;
align-items: center;
color: #256BFA;
font-size: 28rpx;
padding-right: 10rpx
.position-nav .position-nav
position: absolute; position: absolute;
right: 34rpx; right: -10rpx;
top: 50%; top: 50%;
.position-nav::before .position-nav::before
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: -4rpx;
content: ''; content: '';
width: 4rpx; width: 4rpx;
height: 16rpx; height: 16rpx;
border-radius: 2rpx border-radius: 2rpx
background: #8A8A8A; background: #256BFA;
transform: translate(0, -50%) rotate(-45deg) ; transform: translate(0, -50%) rotate(-45deg) ;
.position-nav::after .position-nav::after
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: -4rpx;
content: ''; content: '';
width: 4rpx; width: 4rpx;
height: 16rpx; height: 16rpx;
border-radius: 2rpx border-radius: 2rpx
background: #8A8A8A; background: #256BFA;
transform: rotate(45deg) 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> </style>

View File

@@ -8,8 +8,12 @@ export default {
// 语音转文字 // 语音转文字
// vioceBaseURl: 'ws://39.98.44.136:8080/speech-recognition', // vioceBaseURl: 'ws://39.98.44.136:8080/speech-recognition',
vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/speech-recognition', vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/speech-recognition',
// 语音合成
speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis',
// indexedDB // indexedDB
DBversion: 2, DBversion: 2,
// 只使用本地缓寸的数据
OnlyUseCachedDB: true,
// 应用信息 // 应用信息
appInfo: { appInfo: {
// 应用名称 // 应用名称

84
directives/collapse.js Normal file
View 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
View 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

Binary file not shown.

View File

@@ -31,8 +31,8 @@ export function useAudioRecorder(wsUrl) {
// 配置常量 // 配置常量
const SAMPLE_RATE = 16000; const SAMPLE_RATE = 16000;
const SILENCE_THRESHOLD = 0.02; // 静音阈值 (0-1) const SILENCE_THRESHOLD = 0.02; // 静音阈值 (0-1)
const SILENCE_DURATION = 400; // 静音持续时间(ms)后切片 const SILENCE_DURATION = 100; // 静音持续时间(ms)后切片
const MIN_SOUND_DURATION = 300; // 最小有效声音持续时间(ms) const MIN_SOUND_DURATION = 200; // 最小有效声音持续时间(ms)
// 音频处理变量 // 音频处理变量
const lastSoundTime = ref(0); const lastSoundTime = ref(0);
@@ -125,6 +125,7 @@ export function useAudioRecorder(wsUrl) {
socket.value = new WebSocket(wsUrl); socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => { socket.value.onopen = () => {
console.log('open')
isSocketConnected.value = true; isSocketConnected.value = true;
resolve(); resolve();
}; };
@@ -191,7 +192,6 @@ export function useAudioRecorder(wsUrl) {
if (audioChunks.value.length === 0 || !socket.value || socket.value.readyState !== WebSocket.OPEN) { if (audioChunks.value.length === 0 || !socket.value || socket.value.readyState !== WebSocket.OPEN) {
return; return;
} }
try { try {
// 合并所有块 // 合并所有块
const totalBytes = audioChunks.value.reduce((total, chunk) => total + chunk.byteLength, 0); const totalBytes = audioChunks.value.reduce((total, chunk) => total + chunk.byteLength, 0);

249
hook/useTTSPlayer.js Normal file
View 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
View 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
View File

@@ -4,7 +4,12 @@ import globalFunction from '@/common/globalFunction'
import '@/lib/string-similarity.min.js' import '@/lib/string-similarity.min.js'
import similarityJobs from '@/utils/similarity_Job.js'; import similarityJobs from '@/utils/similarity_Job.js';
import NoBouncePage from '@/components/NoBouncePage/NoBouncePage.vue' import NoBouncePage from '@/components/NoBouncePage/NoBouncePage.vue'
import MsgTips from '@/components/MsgTips/MsgTips.vue'
// import Tabbar from '@/components/tabbar/midell-box.vue' // import Tabbar from '@/components/tabbar/midell-box.vue'
// 自动导入 directives 目录下所有指令
const directives = import.meta.glob('./directives/*.js', {
eager: true
});
import { import {
createSSRApp, createSSRApp,
@@ -15,8 +20,16 @@ export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
app.component('NoBouncePage', NoBouncePage) app.component('NoBouncePage', NoBouncePage)
app.component('MsgTips', MsgTips)
// app.component('tabbar-custom', Tabbar) // 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', { app.provide('globalFunction', {
...globalFunction, ...globalFunction,
similarityJobs similarityJobs

View File

@@ -63,6 +63,7 @@
<ai-paging ref="paging"></ai-paging> <ai-paging ref="paging"></ai-paging>
</view> </view>
</view> </view>
<!-- 自定义tabbar --> <!-- 自定义tabbar -->
<!-- <tabbar-custom :currentpage="2"></tabbar-custom> --> <!-- <tabbar-custom :currentpage="2"></tabbar-custom> -->
</view> </view>
@@ -143,12 +144,13 @@ function updateSetting() {
<style lang="stylus" scoped> <style lang="stylus" scoped>
header-height = 88rpx header-height = 88rpx
/* 页面容器 */ /* 页面容器 */
.container { .container {
position: fixed; position: fixed;
z-index: 100; z-index: 1000;
width: 100vw; width: 100vw;
height: 100vh; height: calc(100% - var(--window-bottom));
overflow: hidden; overflow: hidden;
} }
@@ -169,7 +171,7 @@ header-height = 88rpx
top: 0; top: 0;
left: 0; /* 如果要右侧弹出改为 right: 0; */ left: 0; /* 如果要右侧弹出改为 right: 0; */
width: 523rpx; width: 523rpx;
height: 100vh; height: 100%;
background: #e7e7e6; background: #e7e7e6;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
@@ -271,7 +273,7 @@ header-height = 88rpx
/* 主要内容区域 */ /* 主要内容区域 */
.main-content .main-content
width: 100%; width: 100%;
height: calc(100% - var(--window-bottom)); height: 100%;
transition: margin-left 0.3s ease-in-out; transition: margin-left 0.3s ease-in-out;
position: relative position: relative
background: #FFFFFF background: #FFFFFF
@@ -286,9 +288,8 @@ header-height = 88rpx
right: var(--window-right); right: var(--window-right);
height:header-height; height:header-height;
padding-top: calc(14rpx); padding-top: calc(14rpx);
border: 2rpx solid #F4F4F4;
background: #FFFFFF background: #FFFFFF
z-index: 998; z-index: 1;
transition-property: all; transition-property: all;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -308,6 +309,7 @@ header-height = 88rpx
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
border-top: 2rpx solid #F4F4F4;
/* 页面被挤压时向右移动 */ /* 页面被挤压时向右移动 */
.main-content.shift { .main-content.shift {

View File

@@ -102,43 +102,36 @@ const initWaveBars = () => {
// 更新波形显示 // 更新波形显示
const updateWaveform = () => { const updateWaveform = () => {
if (!props.isActive) return; if (!props.isActive) return;
// 如果没有传入音频数据,则使用模拟数据
const AMPLIFY = 1.6; // 振幅放大
const center = centerIndex.value;
// 如果没有传入音频数据,则使用模拟数据(加强振幅)
const audioData = const audioData =
props.audioData.length > 0 props.audioData.length > 0
? props.audioData ? props.audioData.map((v) => Math.min(v * AMPLIFY, 1))
: Array(centerIndex.value + 1) : Array(center + 1)
.fill(0) .fill(0)
.map(() => Math.random() * 0.5 + 0.2); .map(() => Math.random() * 0.7 + 0.3); // 模拟值更明显
// 从中间向两侧处理
for (let i = 0; i <= centerIndex.value; i++) {
// 左侧条索引
const leftIndex = centerIndex.value - i;
// 右侧条索引
const rightIndex = centerIndex.value + i;
// 获取音频数据值 (归一化到0-1) for (let i = 0; i <= center; i++) {
const leftIndex = center - i;
const rightIndex = center + i;
const value = audioData[i] || 0; const value = audioData[i] || 0;
// 更新左侧条 if (leftIndex >= 0) updateWaveBar(leftIndex, value);
if (leftIndex >= 0) {
updateWaveBar(leftIndex, value);
}
// 更新右侧条(避免重复更新中心条)
if (rightIndex < waveBars.value.length && rightIndex !== leftIndex) { if (rightIndex < waveBars.value.length && rightIndex !== leftIndex) {
updateWaveBar(rightIndex, value); updateWaveBar(rightIndex, value);
} }
} }
// 继续动画
animationId = requestAnimationFrame(updateWaveform); animationId = requestAnimationFrame(updateWaveform);
}; };
// 更新单个波形条 // 更新单个波形条
const updateWaveBar = (index, value) => { const updateWaveBar = (index, value) => {
// 动态高度 (4rpx到200rpx之间) // 动态高度 (4rpx到42rpx之间)
const height = 2 + value * 98; const height = 2 + value * 38;
// // 动态颜色 // // 动态颜色
// let color; // let color;
// if (props.isCanceling) { // if (props.isCanceling) {

View File

@@ -1,116 +1,114 @@
<template> <template>
<view class="chat-container"> <view class="chat-container">
<FadeView :show="!messages.length" :duration="600"> <view class="chat-background" v-fade:600="!messages.length">
<view class="chat-background"> <image class="backlogo" src="/static/icon/backAI.png"></image>
<image class="backlogo" src="/static/icon/backAI.png"></image> <view class="back-rowTitle">欢迎使用青岛AI智能求职</view>
<view class="back-rowTitle">欢迎使用青岛AI智能求职</view> <view class="back-rowText">
<view class="back-rowText"> 我可以根据您的简历和求职需求帮你精准匹配青岛市互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~
我可以根据您的简历和求职需求帮你精准匹配青岛市互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~ </view>
</view> <view class="back-rowh3">猜你所想</view>
<view class="back-rowh3">猜你所想</view> <view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)">
<view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)"> {{ 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>
<view class="chat-item self" v-if="isRecording"> <view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view> <view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view> </view>
</view> <view v-if="isTyping" class="self">
</FadeView> <text class="message msg-loading">
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation> <span class="ai-loading"></span>
<FadeView :show="messages.length >= 1" :duration="600"> </text>
<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> </view>
</FadeView> </view>
</scroll-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> <view class="record-tip">{{ statusText }}</view>
<WaveDisplay <WaveDisplay
:background="audiowaveStyle" :background="audiowaveStyle"
@@ -119,7 +117,7 @@
:showInfo="isRecording" :showInfo="isRecording"
/> />
</view> </view>
<view class="input-area" v-else> <view class="input-area" v-show="status === 'idle'">
<view class="areatext"> <view class="areatext">
<input <input
v-model="textInput" v-model="textInput"
@@ -133,7 +131,7 @@
/> />
<view <view
class="input_vio" class="input_vio"
@touchstart="handleTouchStart" @touchstart.prevent="handleTouchStart"
@touchmove="handleTouchMove" @touchmove="handleTouchMove"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchCancel" @touchcancel="handleTouchCancel"
@@ -222,28 +220,42 @@
</scroll-view> </scroll-view>
</view> </view>
</view> </view>
<PopupFeeBack ref="feeback" @onSend="confirmFeeBack"></PopupFeeBack>
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
</view> </view>
</template> </template>
<script setup> <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 { storeToRefs } from 'pinia';
import config from '@/config.js'; import config from '@/config.js';
import useChatGroupDBStore from '@/stores/userChatGroupStore'; import useChatGroupDBStore from '@/stores/userChatGroupStore';
import MdRender from '@/components/md-render/md-render.vue'; import MdRender from '@/components/md-render/md-render.vue';
import CollapseTransition from '@/components/CollapseTransition/CollapseTransition.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 AudioWave from './AudioWave.vue';
import WaveDisplay from './WaveDisplay.vue'; import WaveDisplay from './WaveDisplay.vue';
import FileIcon from './fileIcon.vue'; import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue'; import FileText from './fileText.vue';
import { useSpeechReader } from '@/hook/useSpeechReader';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js'; import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
// 全局 // 全局
const { $api, navTo, throttle } = inject('globalFunction'); const { $api, navTo, throttle } = inject('globalFunction');
const emit = defineEmits(['onConfirm']); const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore()); const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import successIcon from '@/static/icon/success.png';
// hook // hook
const { const {
isRecording, isRecording,
@@ -256,7 +268,7 @@ const {
lastFinalText, lastFinalText,
} = useAudioRecorder(config.vioceBaseURl); } = useAudioRecorder(config.vioceBaseURl);
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useSpeechReader(); const { speak, pause, resume, isSpeaking, isPaused, cancelAudio, audioUrl } = useTTSPlayer(config.speechSynthesis);
// state // state
const queries = ref([]); const queries = ref([]);
@@ -272,7 +284,11 @@ const startY = ref(0);
const cancelThreshold = 100; const cancelThreshold = 100;
const speechIndex = ref(0); const speechIndex = ref(0);
const isAudioPermission = ref(false); 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({ const state = reactive({
uploadFileTips: '请根据以上附件,帮我推荐岗位。', uploadFileTips: '请根据以上附件,帮我推荐岗位。',
}); });
@@ -510,7 +526,6 @@ const handleTouchStart = async (e) => {
return tipsPermisson(); return tipsPermisson();
} }
cancelAudio(); cancelAudio();
console.log('handleTouchStart');
startY.value = e.touches[0].clientY; startY.value = e.touches[0].clientY;
status.value = 'recording'; status.value = 'recording';
showfile.value = false; showfile.value = false;
@@ -521,7 +536,6 @@ const handleTouchStart = async (e) => {
}; };
const handleTouchMove = (e) => { const handleTouchMove = (e) => {
console.log('handleTouchMove');
const moveY = e.touches[0].clientY; const moveY = e.touches[0].clientY;
if (startY.value - moveY > cancelThreshold) { if (startY.value - moveY > cancelThreshold) {
status.value = 'cancel'; status.value = 'cancel';
@@ -531,28 +545,23 @@ const handleTouchMove = (e) => {
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
console.log('handleTouchEnd');
if (status.value === 'cancel') { if (status.value === 'cancel') {
console.log('取消发送'); console.log('取消发送');
cancelRecording(); cancelRecording();
} else { } else {
console.log('stopRecording');
stopRecording(); stopRecording();
$api.sleep(1000).then(() => { if (isAudioPermission.value) {
if (isAudioPermission.value) { if (recognizedText.value) {
if (recognizedText.value) { sendMessage(recognizedText.value);
sendMessage(recognizedText.value); } else {
} else { $api.msg('说话时长太短');
$api.msg('说话时长太短');
}
} }
}); }
} }
status.value = 'idle'; status.value = 'idle';
}; };
const handleTouchCancel = () => { const handleTouchCancel = () => {
console.log('handleTouchCancel');
stopRecording(); stopRecording();
status.value = 'idle'; status.value = 'idle';
}; };
@@ -578,9 +587,18 @@ function copyMarkdown(value) {
} }
function userGoodFeedback(msg) { function userGoodFeedback(msg) {
$api.msg('该功能正在开发中,敬请期待后续更新!'); // $api.msg('该功能正在开发中,敬请期待后续更新!');
console.log(msg.dataId); feeback.value?.open();
// useChatGroupDBStore().badFeedback(msg.dataId, msg.parentGroupId); feebackData.value = msg;
}
function confirmFeeBack(value) {
useChatGroupDBStore()
.badFeedback(feebackData.value, value)
.then(() => {
feeback.value?.close();
feeBackTips.value?.open();
});
} }
function readMarkdown(value, index) { function readMarkdown(value, index) {
@@ -788,14 +806,14 @@ image-margin-top = 40rpx
-webkit-user-select: text; -webkit-user-select: text;
.message-markdown .message-markdown
border-radius: 0 20rpx 20rpx 20rpx; border-radius: 0 20rpx 20rpx 20rpx;
padding: 20rpx 20rpx 0 20rpx; padding: 20rpx 20rpx 20rpx 20rpx;
background: #F6F6F6; background: #F6F6F6;
.message-controll .message-controll
display: flex display: flex
justify-content: space-between justify-content: space-between
align-items: center align-items: center
border-top: 2rpx solid #EAEAEA border-top: 2rpx solid #EAEAEA
padding: 24rpx 0 padding: 24rpx 0 4rpx 0
margin-top: 10rpx margin-top: 10rpx
.controll-left .controll-left
.controll-right .controll-right
@@ -870,12 +888,19 @@ image-margin-top = 40rpx
-khtml-user-select:none; -khtml-user-select:none;
-moz-user-select:none; -moz-user-select:none;
-ms-user-select:none; -ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
.input_vio:active .input_vio:active
background: #e8e8e8 background: #e8e8e8
.vio_container .vio_container
background: transparent background: transparent
padding: 28rpx padding: 28rpx
text-align: center 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 .record-tip
font-weight: 400; font-weight: 400;
color: #909090; color: #909090;

View File

@@ -1,8 +1,197 @@
<template> <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> </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> </script>
<style> <style lang="stylus" scoped>
</style> .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

Binary file not shown.

BIN
static/icon/.DS_Store vendored

Binary file not shown.

BIN
static/icon/success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -13,7 +13,9 @@ class BaseStore {
} }
checkAndInitDB() { checkAndInitDB() {
// 获取本地数据库版本 // 获取本地数据库版本
// this.initDB() if (config.OnlyUseCachedDB) {
return this.initDB()
}
const localVersion = uni.getStorageSync('indexedDBVersion') || 1 const localVersion = uni.getStorageSync('indexedDBVersion') || 1
console.log('DBVersion: ', localVersion, config.DBversion) console.log('DBVersion: ', localVersion, config.DBversion)
if (localVersion === config.DBversion) { if (localVersion === config.DBversion) {

View File

@@ -47,7 +47,8 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
chatSessionID.value = tabelRow.sessionId chatSessionID.value = tabelRow.sessionId
initMessage(tabelRow.sessionId) initMessage(tabelRow.sessionId)
} else { } else {
console.warn('本地数据库存在数据') if (config.OnlyUseCachedDB) return;
console.warn('本地数据库不存在数据')
getHistory('refresh') getHistory('refresh')
} }
}, 1000) }, 1000)
@@ -132,7 +133,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
displayText: '', // 用于流式渲染展示 displayText: '', // 用于流式渲染展示
dataId: customDataID dataId: customDataID
}; };
console.log(messages.value)
const index = messages.value.length; const index = messages.value.length;
messages.value.push(newMsg); messages.value.push(newMsg);
@@ -143,7 +144,12 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
function handleUnload() { function handleUnload() {
newMsg.parentGroupId = chatSessionID.value; 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); window.addEventListener("unload", handleUnload);
@@ -192,15 +198,32 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
} }
// 云端数据 // 云端数据
function badFeedback(dataId, sessionId = chatSessionID.value, content = '') { function badFeedback(msgs, content = '') {
let parmas = { return new Promise((resolve, reject) => {
dataId: dataId, if (!msgs.dataId) {
sessionId: sessionId, return msg('旧数据没有dataId')
userBadFeedback: '这是反馈', }
} let parmas = {
// $api.chatRequest('/stepped', parmas, 'POST').then((res) => { dataId: msgs.dataId,
// console.log('反馈成功') 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 sessionId: chatSessionID.value
} }
$api.chatRequest('/detail', params, 'GET', loading).then((res) => { $api.chatRequest('/detail', params, 'GET', loading).then((res) => {
console.log('detail', res.data)
let list = parseHistoryDetail(res.data.list, chatSessionID.value) let list = parseHistoryDetail(res.data.list, chatSessionID.value)
if (list.length) { if (list.length) {
messages.value = list baseDB.db.add(massageName.value, list).then((ids) => {
baseDB.db.add(massageName.value, list); messages.value = listAddId(list, ids)
});
} }
console.log('解析后:', list) console.log('解析后:', list)
}).catch(() => { }).catch(() => {
@@ -271,7 +294,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
displayText: text, displayText: text,
self: self, self: self,
text: text, text: text,
userBadFeedback: element.userBadFeedback, userBadFeedback: element.userBadFeedback || '',
dataId: element.dataId, dataId: element.dataId,
files, files,
}) })
@@ -279,12 +302,19 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
return arr return arr
} }
function listAddId(list, ids) {
return list.map((item, index) => ({
...item,
id: ids[index] || ids
}))
}
return { return {
messages, messages,
isTyping, isTyping,
textInput, textInput,
chatSessionID, chatSessionID,
addMessage,
tabeList, tabeList,
init, init,
toggleTyping, toggleTyping,

BIN
uni_modules/.DS_Store vendored

Binary file not shown.

BIN
unpackage/.DS_Store vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -21,18 +21,21 @@ const md = new MarkdownIt({
<a class="custom-card" data-job-id="${jobId}"> <a class="custom-card" data-job-id="${jobId}">
<div class="card-title"> <div class="card-title">
<span class="title-text" >${result.jobTitle}</span> <span class="title-text" >${result.jobTitle}</span>
<div class="card-tag">${result.location}</div> <div class="card-salary">${result.salary}</div>
</div> </div>
<div class="card-company">${result.companyName}</div> <div class="card-company">${result.location}·${result.companyName}</div>
<div class="card-info"> <div class="card-info">
<div class="info-item">${result.salary}</div> <div class="info-item">
<div class="info-item">${result.education}</div> <div class="card-tag">${result.education}</div>
<div class="info-item">${result.experience}</div> <div class="card-tag">${result.experience}</div>
</div>
<div class="info-item">查看详情<div class="position-nav"></div></div>
</div> </div>
<div class="position-nav"></div>
</a> </a>
` `
} }
// <div class="card-tag">${result.location}</div>
// <div class="info-item">${result.salary}</div>
// 代码块 // 代码块
let preCode = "" let preCode = ""
try { try {