@@ -175,9 +189,6 @@
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
- :catchtouchstart="true"
- :catchtouchmove="true"
- :catchtouchend="true"
v-show="isVoice"
type="default"
>
@@ -294,11 +305,11 @@ import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
+import successIcon from '@/static/icon/success.png';
// 全局
const { $api, navTo, throttle, config } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
-import successIcon from '@/static/icon/success.png';
// hook
const {
isRecording,
@@ -309,8 +320,32 @@ const {
volumeLevel,
recognizedText,
lastFinalText,
+ recordingDuration,
+ isRecognizing,
+ reset
} = useAudioRecorder();
+// 监听语音识别结果变化,自动发送消息
+watch(
+ () => recognizedText.value,
+ (newVal) => {
+ if (newVal && newVal.trim()) {
+ console.log('监听到语音识别结果变化,自动发送消息:', newVal);
+ sendMessage(newVal);
+ }
+ }
+);
+
+// 监听isRecognizing状态,显示提示
+watch(
+ () => isRecognizing.value,
+ (newVal) => {
+ if (newVal) {
+ $api.msg('正在识别语音...');
+ }
+ }
+);
+
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
// 获取组件实例(用于小程序 SelectorQuery)
@@ -362,6 +397,7 @@ onMounted(async () => {
changeQueries();
scrollToBottom();
isAudioPermission.value = await requestMicPermission();
+ reset(); // 重置语音识别状态
});
const requestMicPermission = async () => {
@@ -684,17 +720,22 @@ const handleTouchEnd = () => {
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
+ status.value = 'idle';
} else {
stopRecording();
if (isAudioPermission.value) {
- if (recognizedText.value) {
- sendMessage(recognizedText.value);
- } else {
+ // 主要根据录音时长判断,而不是完全依赖识别结果
+ // 由于setInterval是异步的,这里需要考虑计时延迟
+ const actualDuration = recordingDuration.value > 0 ? recordingDuration.value : (isRecording.value ? 0.5 : 0);
+ if (actualDuration < 1) {
$api.msg('说话时长太短');
+ status.value = 'idle';
+ } else {
+ // 状态管理由useAudioRecorder hook内部处理
+ status.value = 'idle';
}
}
}
- status.value = 'idle';
};
const handleTouchCancel = () => {
@@ -1118,6 +1159,11 @@ image-margin-top = 40rpx
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 160rpx; /* 为底部导航栏留出空间 */
+ z-index: 9999; /* 确保高于其他元素 */
.record-tip
font-weight: 400;
color: #909090;
From 07e0f3083b8f206436ec04b9d2b27ce88c615086 Mon Sep 17 00:00:00 2001
From: francis_fh <13935151924@163.com>
Date: Sat, 24 Jan 2026 16:47:11 +0800
Subject: [PATCH 02/18] =?UTF-8?q?AI=E5=9B=9E=E5=A4=8D=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=EF=BC=8C=E5=B2=97=E4=BD=8D=E5=8D=A1=E7=89=87=E7=9A=84=E7=82=B9?=
=?UTF-8?q?=E5=87=BB=E4=BA=8B=E4=BB=B6=E7=BB=91=E5=AE=9A=E9=97=AE=E9=A2=98?=
=?UTF-8?q?=E8=A7=A3=E5=86=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/md-render/md-render.vue | 745 ++++++++++++++++++++++++-----
utils/markdownParser.js | 351 ++++++++------
2 files changed, 818 insertions(+), 278 deletions(-)
diff --git a/components/md-render/md-render.vue b/components/md-render/md-render.vue
index 1865468..6a1bd41 100644
--- a/components/md-render/md-render.vue
+++ b/components/md-render/md-render.vue
@@ -2,17 +2,44 @@
-
+
+
+
+
+
+
+
+
+
+ {{ card.jobTitle }}
+ {{ card.salary }}
+
+ {{ card.location }}·{{ card.companyName }}
+
+
+ {{ card.education }}
+ {{ card.experience }}
+
+ 查看详情
+
+
+
+
-
+
diff --git a/utils/markdownParser.js b/utils/markdownParser.js
index 451c4a1..c877742 100644
--- a/utils/markdownParser.js
+++ b/utils/markdownParser.js
@@ -1,157 +1,194 @@
-import MarkdownIt from '@/lib/markdown-it.min.js';
-import hljs from "@/lib/highlight/highlight-uni.min.js";
-import parseHtml from '@/lib/html-parser.js';
-// import DOMPurify from '@/lib/dompurify@3.2.4es.js';
-
-export let codeDataList = []
-export let jobMoreMap = new Map()
-
-const md = new MarkdownIt({
- html: true, // 允许 HTML 标签
- linkify: true, // 自动解析 URL
- typographer: true, // 美化标点符号
- tables: true,
- breaks: true, // 让 \n 自动换行
- langPrefix: 'language-', // 代码高亮前缀
- // 如果结果以 ${result.jobTitle}${result.salary}
${result.location}·${result.companyName}
${result.education}
${result.experience}
`
- if (result.data) {
- jobMoreMap.set(jobId, result.data)
- domContext += `查看更多岗位`
- }
- return domContext
- }
- }
- // 代码块
- let preCode = ""
- try {
- preCode = hljs.highlightAuto(str).value
- } catch (err) {
- preCode = md.utils.escapeHtml(str);
- }
- // 以换行进行分割 , 按行拆分代码
- const lines = preCode.split(/\n/).slice(0, -1);
- const html = lines
- .map((line, index) =>
- line ?
- `
${line}` :
- ''
- )
- .join('');
-
- // 代码复制功能
- const cacheIndex = codeDataList.length;
- codeDataList.push(str);
- return `
-
- `;
- }
-})
-
-function extractFirstJson(text) {
- let stack = [];
- let startIndex = -1;
- let endIndex = -1;
-
- for (let i = 0; i < text.length; i++) {
- const char = text[i];
-
- if (char === '{') {
- if (stack.length === 0) startIndex = i; // 记录第一个 '{' 的位置
- stack.push(char);
- } else if (char === '}') {
- stack.pop();
- if (stack.length === 0) {
- endIndex = i; // 找到配对的 '}'
- break;
- }
- }
- }
-
- if (startIndex !== -1 && endIndex !== -1) {
- const jsonString = text.slice(startIndex, endIndex + 1);
- try {
- const jsonObject = JSON.parse(jsonString);
- return jsonObject;
- } catch (e) {
- return null; // 如果不是有效的 JSON
- }
- }
-
- return null; // 如果没有找到有效的 JSON 对象
-}
-
-
-function safeExtractJson(text) {
- try {
- const jsonObject = extractFirstJson(text);
- return jsonObject
- } catch (e) {
- console.error('JSON 解析失败:', e);
- }
- return null;
-}
-
-export function clearJobMoreMap() { // 切换对话清空
- jobMoreMap.clear()
-}
-
-export function parseMarkdown(content) {
- if (!content) {
- return [] //处理特殊情况,比如网络异常导致的响应的 content 的值为空
- }
-
- // 过滤掉标签及其内容,这些是AI内部思考过程,不应该显示给用户
- // 1. 处理原始标签(支持多行)
- content = content.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
- // 2. 处理HTML编码的标签
- content = content.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
- // 3. 处理部分编码的标签
- content = content.replace(/<\s*think\s*>/gi, '')
- content = content.replace(/<\s*\/\s*think\s*>/gi, '')
-
- codeDataList = []
- const unsafeHtml = md.render(content || '')
-
- // 在markdown渲染后再次过滤,确保没有遗漏
- let filteredHtml = unsafeHtml
- // 1. 处理原始标签(支持多行)
- filteredHtml = filteredHtml.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
- // 2. 处理HTML编码的标签
- filteredHtml = filteredHtml.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
- // 3. 处理部分编码的标签
- filteredHtml = filteredHtml.replace(/<\s*think\s*>/gi, '')
- filteredHtml = filteredHtml.replace(/<\s*\/\s*think\s*>/gi, '')
- // 4. 单独处理剩余的think标签对
- filteredHtml = filteredHtml.replace(/<think>/gi, '')
- filteredHtml = filteredHtml.replace(/<\/think>/gi, '')
- filteredHtml = filteredHtml.replace(//gi, '')
- filteredHtml = filteredHtml.replace(/<\/think>/gi, '')
-
- // 根据平台返回不同的内容格式
- // 微信小程序:返回rich-text组件支持的nodes格式
- // H5:直接返回HTML字符串,避免HTML解析错误
- if (process.env.UNI_PLATFORM === 'mp-weixin') {
- try {
- return parseHtml(filteredHtml)
- } catch (error) {
- console.error('HTML解析失败:', error)
- // 解析失败时返回空数组,避免页面崩溃
- return []
- }
- } else {
- // H5端直接返回HTML字符串
- return filteredHtml
- }
-}
\ No newline at end of file
+import MarkdownIt from '@/lib/markdown-it.min.js';
+import hljs from "@/lib/highlight/highlight-uni.min.js";
+import parseHtml from '@/lib/html-parser.js';
+// import DOMPurify from '@/lib/dompurify@3.2.4es.js';
+
+export let codeDataList = []
+export let jobMoreMap = new Map()
+export let jobCardsList = []
+
+const md = new MarkdownIt({
+ html: true, // 允许 HTML 标签
+ linkify: true, // 自动解析 URL
+ typographer: true, // 美化标点符号
+ tables: true,
+ breaks: true, // 让 \n 自动换行
+ langPrefix: 'language-', // 代码高亮前缀
+ // 如果结果以 ${result.jobTitle}${result.salary}
${result.location}·${result.companyName}
${result.education}
${result.experience}
`
+ if (result.data) {
+ jobMoreMap.set(jobId, result.data)
+ domContext += `查看更多岗位`
+ }
+ return domContext
+ }
+ }
+ // 代码块
+ let preCode = ""
+ try {
+ preCode = hljs.highlightAuto(str).value
+ } catch (err) {
+ preCode = md.utils.escapeHtml(str);
+ }
+ // 以换行进行分割 , 按行拆分代码
+ const lines = preCode.split(/\n/).slice(0, -1);
+ const html = lines
+ .map((line, index) =>
+ line ?
+ `
${line}` :
+ ''
+ )
+ .join('');
+
+ // 代码复制功能
+ const cacheIndex = codeDataList.length;
+ codeDataList.push(str);
+ return `
+
+ `;
+ }
+})
+
+function extractFirstJson(text) {
+ let stack = [];
+ let startIndex = -1;
+ let endIndex = -1;
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+
+ if (char === '{') {
+ if (stack.length === 0) startIndex = i; // 记录第一个 '{' 的位置
+ stack.push(char);
+ } else if (char === '}') {
+ stack.pop();
+ if (stack.length === 0) {
+ endIndex = i; // 找到配对的 '}'
+ break;
+ }
+ }
+ }
+
+ if (startIndex !== -1 && endIndex !== -1) {
+ const jsonString = text.slice(startIndex, endIndex + 1);
+ try {
+ const jsonObject = JSON.parse(jsonString);
+ return jsonObject;
+ } catch (e) {
+ return null; // 如果不是有效的 JSON
+ }
+ }
+
+ return null; // 如果没有找到有效的 JSON 对象
+}
+
+
+function safeExtractJson(text) {
+ try {
+ const jsonObject = extractFirstJson(text);
+ return jsonObject
+ } catch (e) {
+ console.error('JSON 解析失败:', e);
+ }
+ return null;
+}
+
+export function clearJobMoreMap() { // 切换对话清空
+ jobMoreMap.clear()
+}
+
+export function parseMarkdown(content) {
+ if (!content) {
+ return [] //处理特殊情况,比如网络异常导致的响应的 content 的值为空
+ }
+
+ // 过滤掉标签及其内容,这些是AI内部思考过程,不应该显示给用户
+ // 1. 处理原始标签(支持多行)
+ content = content.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
+ // 2. 处理HTML编码的标签
+ content = content.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
+ // 3. 处理部分编码的标签
+ content = content.replace(/<\s*think\s*>/gi, '')
+ content = content.replace(/<\s*\/\s*think\s*>/gi, '')
+
+ codeDataList = []
+ jobCardsList = [] // 清空岗位卡片列表,避免重复
+ const unsafeHtml = md.render(content || '')
+
+ // 在markdown渲染后再次过滤,确保没有遗漏
+ let filteredHtml = unsafeHtml
+ // 1. 处理原始标签(支持多行)
+ filteredHtml = filteredHtml.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
+ // 2. 处理HTML编码的标签
+ filteredHtml = filteredHtml.replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, '')
+ // 3. 处理部分编码的标签
+ filteredHtml = filteredHtml.replace(/<\s*think\s*>/gi, '')
+ filteredHtml = filteredHtml.replace(/<\s*\/\s*think\s*>/gi, '')
+ // 4. 单独处理剩余的think标签对
+ filteredHtml = filteredHtml.replace(/<think>/gi, '')
+ filteredHtml = filteredHtml.replace(/<\/think>/gi, '')
+ filteredHtml = filteredHtml.replace(//gi, '')
+ filteredHtml = filteredHtml.replace(/<\/think>/gi, '')
+
+ // 根据平台返回不同的内容格式
+ // 微信小程序:返回rich-text组件支持的nodes格式
+ // H5:直接返回HTML字符串,避免HTML解析错误
+ if (process.env.UNI_PLATFORM === 'mp-weixin') {
+ try {
+ return parseHtml(filteredHtml)
+ } catch (error) {
+ console.error('HTML解析失败:', error)
+ // 解析失败时返回空数组,避免页面崩溃
+ return []
+ }
+ } else {
+ // H5端直接返回HTML字符串
+ return filteredHtml
+ }
+}
From cbf8bd7c41ea89bc7c702dc6845572eb06c15d35 Mon Sep 17 00:00:00 2001
From: francis_fh <13935151924@163.com>
Date: Sat, 24 Jan 2026 16:51:32 +0800
Subject: [PATCH 03/18] =?UTF-8?q?H5=E5=8D=A1=E7=89=87=E6=A0=B7=E5=BC=8F?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/md-render/md-render.vue | 163 +++++++++++++++++++----------
1 file changed, 107 insertions(+), 56 deletions(-)
diff --git a/components/md-render/md-render.vue b/components/md-render/md-render.vue
index 6a1bd41..22fdd96 100644
--- a/components/md-render/md-render.vue
+++ b/components/md-render/md-render.vue
@@ -768,19 +768,13 @@ ol {
/* 额外的H5端样式优化 */
/* #ifndef MP-WEIXIN */
-/* 确保样式能正确应用到v-html生成的内容,使用深度选择器 */
+/* 确保样式能正确应用到v-html生成的内容 */
.markdown-body {
- /* 确保样式能正确应用到v-html生成的内容 */
- & > div {
- display: flex !important;
- flex-direction: column !important;
- }
+ /* 重置v-html容器样式 */
+ display: block !important;
- /* 为v-html生成的a标签添加样式,使用!important确保优先级 */
- & > div > a.custom-card,
- & > a.custom-card,
- & * > a.custom-card,
- & * * > a.custom-card {
+ /* 为v-html生成的a.custom-card标签添加基础样式 */
+ a.custom-card {
display: block !important;
margin-bottom: 22rpx !important;
background: #FFFFFF !important;
@@ -792,13 +786,12 @@ ol {
color: #333333 !important;
text-decoration: none !important;
overflow: hidden !important;
+ box-sizing: border-box !important;
+ width: 100% !important;
}
- /* 为v-html生成的内容添加样式 */
- & > div > a.custom-card .card-title,
- & > a.custom-card .card-title,
- & * > a.custom-card .card-title,
- & * * > a.custom-card .card-title {
+ /* 卡片标题样式 */
+ a.custom-card .card-title {
font-weight: 600 !important;
display: flex !important;
align-items: center !important;
@@ -806,10 +799,7 @@ ol {
margin-bottom: 16rpx !important;
}
- & > div > a.custom-card .card-title .title-text,
- & > a.custom-card .card-title .title-text,
- & * > a.custom-card .card-title .title-text,
- & * * > a.custom-card .card-title .title-text {
+ a.custom-card .card-title .title-text {
font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif !important;
font-size: 32rpx !important;
line-height: 1.4 !important;
@@ -821,10 +811,7 @@ ol {
margin-bottom: 0 !important;
}
- & > div > a.custom-card .card-title .card-salary,
- & > a.custom-card .card-title .card-salary,
- & * > a.custom-card .card-title .card-salary,
- & * * > a.custom-card .card-title .card-salary {
+ a.custom-card .card-title .card-salary {
font-family: DIN-Medium !important;
font-size: 32rpx !important;
color: #4C6EFB !important;
@@ -833,10 +820,8 @@ ol {
margin-bottom: 0 !important;
}
- & > div > a.custom-card .card-company,
- & > a.custom-card .card-company,
- & * > a.custom-card .card-company,
- & * * > a.custom-card .card-company {
+ /* 公司信息样式 */
+ a.custom-card .card-company {
margin-bottom: 18rpx !important;
font-size: 28rpx !important;
color: #6C7282 !important;
@@ -849,19 +834,15 @@ ol {
display: block !important;
}
- & > div > a.custom-card .card-tags,
- & > a.custom-card .card-tags,
- & * > a.custom-card .card-tags,
- & * * > a.custom-card .card-tags {
+ /* 标签容器样式 */
+ a.custom-card .card-tags {
display: flex !important;
flex-wrap: wrap !important;
margin-bottom: 24rpx !important;
}
- & > div > a.custom-card .card-tag,
- & > a.custom-card .card-tag,
- & * > a.custom-card .card-tag,
- & * * > a.custom-card .card-tag {
+ /* 单个标签样式 */
+ a.custom-card .card-tag {
font-weight: 400 !important;
font-size: 24rpx !important;
color: #6C7282 !important;
@@ -878,10 +859,8 @@ ol {
line-height: 30rpx !important;
}
- & > div > a.custom-card .card-bottom,
- & > a.custom-card .card-bottom,
- & * > a.custom-card .card-bottom,
- & * * > a.custom-card .card-bottom {
+ /* 卡片底部样式 */
+ a.custom-card .card-bottom {
display: flex !important;
justify-content: space-between !important;
font-size: 24rpx !important;
@@ -890,41 +869,113 @@ ol {
margin-bottom: 0 !important;
}
- & > div > a.custom-card .card-bottom .info-item,
- & > a.custom-card .card-bottom .info-item,
- & * > a.custom-card .card-bottom .info-item,
- & * * > a.custom-card .card-bottom .info-item {
+ a.custom-card .card-bottom .info-item {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin-bottom: 0 !important;
}
- & > div > a.custom-card .card-info,
- & > a.custom-card .card-info,
- & * > a.custom-card .card-info,
- & * * > a.custom-card .card-info {
+ /* 卡片信息区域样式 */
+ a.custom-card .card-info {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding-right: 40rpx !important;
}
- & > div > a.custom-card .card-info .info-item,
- & > a.custom-card .card-info .info-item,
- & * > a.custom-card .card-info .info-item,
- & * * > a.custom-card .card-info .info-item {
+ a.custom-card .card-info .info-item {
display: flex !important;
align-items: center !important;
}
- & > div > a.custom-card .card-info .info-item:last-child,
- & > a.custom-card .card-info .info-item:last-child,
- & * > a.custom-card .card-info .info-item:last-child,
- & * * > a.custom-card .card-info .info-item:last-child {
+ a.custom-card .card-info .info-item:last-child {
color: #256BFA !important;
font-size: 28rpx !important;
padding-right: 10rpx !important;
+ position: relative !important;
+ }
+
+ /* 查看详情箭头样式 */
+ a.custom-card .position-nav {
+ position: absolute !important;
+ right: -10rpx !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ }
+
+ a.custom-card .position-nav::before {
+ position: absolute !important;
+ left: 0 !important;
+ top: -4rpx !important;
+ content: '' !important;
+ width: 4rpx !important;
+ height: 16rpx !important;
+ border-radius: 2rpx !important;
+ background: #256BFA !important;
+ transform: translate(0, -50%) rotate(-45deg) !important;
+ }
+
+ a.custom-card .position-nav::after {
+ position: absolute !important;
+ left: 0 !important;
+ top: -4rpx !important;
+ content: '' !important;
+ width: 4rpx !important;
+ height: 16rpx !important;
+ border-radius: 2rpx !important;
+ background: #256BFA !important;
+ transform: rotate(45deg) !important;
+ }
+
+ /* 查看更多按钮样式 */
+ a.custom-more {
+ display: flex !important;
+ justify-content: center !important;
+ align-items: center !important;
+ color: #FFFFFF !important;
+ background: linear-gradient(135deg, #256BFA 0%, #9E74FD 100%) !important;
+ border-radius: 50rpx !important;
+ padding: 20rpx 32rpx !important;
+ margin: 20rpx 0 !important;
+ font-size: 28rpx !important;
+ font-weight: 600 !important;
+ box-shadow: 0rpx 8rpx 24rpx rgba(37, 107, 250, 0.3) !important;
+ transition: all 0.3s ease !important;
+ position: relative !important;
+ overflow: hidden !important;
+ text-decoration: none !important;
+ box-sizing: border-box !important;
+ width: 100% !important;
+ }
+
+ a.custom-more .more-icon {
+ width: 32rpx !important;
+ height: 32rpx !important;
+ background: url('@/static/svg/seemore.svg') center center no-repeat !important;
+ background-size: 100% 100% !important;
+ margin-left: 12rpx !important;
+ filter: brightness(0) invert(1) !important;
+ }
+
+ a.custom-more::before {
+ content: '' !important;
+ position: absolute !important;
+ top: 0 !important;
+ left: -100% !important;
+ width: 100% !important;
+ height: 100% !important;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent) !important;
+ transition: left 0.5s ease !important;
+ }
+
+ a.custom-more:active {
+ transform: translateY(2rpx) !important;
+ box-shadow: 0rpx 4rpx 16rpx rgba(37, 107, 250, 0.4) !important;
+ }
+
+ a.custom-more:active::before {
+ left: 100% !important;
}
}
/* #endif */
From 7a7aa3312888a3271c7d8df30906f9744904e290 Mon Sep 17 00:00:00 2001
From: francis_fh <13935151924@163.com>
Date: Sat, 24 Jan 2026 17:57:25 +0800
Subject: [PATCH 04/18] =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=92=AD=E6=94=BE?=
=?UTF-8?q?=E8=81=8A=E6=9D=A1=E9=9F=B3=E9=A2=91=E7=9A=84=E9=97=AE=E9=A2=98?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
hook/useTTSPlayer.js | 742 +++++++++++++++-------------
pages/chat/components/ai-paging.vue | 23 +-
2 files changed, 400 insertions(+), 365 deletions(-)
diff --git a/hook/useTTSPlayer.js b/hook/useTTSPlayer.js
index a7a6d1b..b8f4b99 100644
--- a/hook/useTTSPlayer.js
+++ b/hook/useTTSPlayer.js
@@ -9,77 +9,79 @@ import {
onUnload
} from '@dcloudio/uni-app'
-export function useTTSPlayer(httpUrl) {
- const isSpeaking = ref(false)
- const isPaused = ref(false)
- const isComplete = ref(false)
+// 创建单例实例
+let ttsInstance = null
- // #ifdef H5
- // H5环境,使用 AudioContext
- // 初始化时不立即创建,而是在需要时创建,确保在用户交互后创建
- let audioContext = null
- // #endif
+// 创建音频播放器类
+class TTSPlayer {
+ constructor(httpUrl) {
+ this.httpUrl = httpUrl
+ this.isSpeaking = ref(false)
+ this.isPaused = ref(false)
+ this.isComplete = ref(false)
+
+ // #ifdef H5
+ this.audioContext = null
+ this.htmlAudioElement = null
+ this.abortController = null
+ // #endif
+
+ // #ifdef MP-WEIXIN
+ this.backgroundAudioManager = null
+ this.innerAudioContext = null
+ // #endif
+
+ this.currentAudioBuffer = null
+ this.currentSource = null
+ this.playTimeOffset = 0
+ this.isProcessingRequest = false
+ }
- // #ifdef MP-WEIXIN
- const audioContext = null // 微信小程序不支持 AudioContext
- let innerAudioContext = null // 微信小程序音频上下文
- let backgroundAudioManager = null // 微信小程序背景音频管理器
- // #endif
-
- let currentAudioBuffer = null
- let currentSource = null
- let playTimeOffset = 0
-
// 初始化微信小程序音频上下文
- // #ifdef MP-WEIXIN
- const initAudioManager = () => {
+ initAudioManager() {
try {
console.log('📱 微信小程序:创建背景音频管理器')
- backgroundAudioManager = uni.getBackgroundAudioManager()
+ this.backgroundAudioManager = uni.getBackgroundAudioManager()
// 设置默认配置
- backgroundAudioManager.title = 'AI语音播报'
- backgroundAudioManager.singer = 'KS AI'
- backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
- backgroundAudioManager.volume = 1.0
+ this.backgroundAudioManager.title = 'AI语音播报'
+ this.backgroundAudioManager.singer = 'KS AI'
+ this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
+ this.backgroundAudioManager.volume = 1.0
- backgroundAudioManager.onPlay(() => {
+ this.backgroundAudioManager.onPlay(() => {
console.log('🎵 微信小程序背景音频播放开始')
- isSpeaking.value = true
- isPaused.value = false
+ this.isSpeaking.value = true
+ this.isPaused.value = false
})
- backgroundAudioManager.onPause(() => {
+ this.backgroundAudioManager.onPause(() => {
console.log('⏸️ 微信小程序背景音频播放暂停')
- isPaused.value = true
+ this.isPaused.value = true
})
- backgroundAudioManager.onStop(() => {
+ this.backgroundAudioManager.onStop(() => {
console.log('⏹️ 微信小程序背景音频播放停止')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- backgroundAudioManager.onEnded(() => {
+ this.backgroundAudioManager.onEnded(() => {
console.log('🎵 微信小程序背景音频播放结束')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- backgroundAudioManager.onError((res) => {
+ this.backgroundAudioManager.onError((res) => {
console.error('❌ 微信小程序背景音频播放错误:', res.errMsg, '错误码:', res.errCode)
- isSpeaking.value = false
- isComplete.value = false
+ this.isSpeaking.value = false
+ this.isComplete.value = false
})
- backgroundAudioManager.onCanplay(() => {
+ this.backgroundAudioManager.onCanplay(() => {
console.log('🎵 微信小程序背景音频可以播放了')
})
- backgroundAudioManager.onWaiting(() => {
- console.log('⏳ 微信小程序背景音频加载中...')
- })
-
console.log('✅ 微信小程序背景音频管理器初始化成功')
return true
} catch (e) {
@@ -87,137 +89,361 @@ export function useTTSPlayer(httpUrl) {
// 降级使用InnerAudioContext
console.log('🔄 微信小程序:背景音频不可用,降级使用InnerAudioContext')
- if (!innerAudioContext) {
- innerAudioContext = uni.createInnerAudioContext()
- innerAudioContext.autoplay = false
- innerAudioContext.obeyMuteSwitch = false
+ if (!this.innerAudioContext) {
+ this.innerAudioContext = uni.createInnerAudioContext()
+ this.innerAudioContext.autoplay = false
+ this.innerAudioContext.obeyMuteSwitch = false
- innerAudioContext.onPlay(() => {
+ this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始')
- isSpeaking.value = true
- isPaused.value = false
+ this.isSpeaking.value = true
+ this.isPaused.value = false
})
- innerAudioContext.onPause(() => {
+ this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
- isPaused.value = true
+ this.isPaused.value = true
})
- innerAudioContext.onStop(() => {
+ this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- innerAudioContext.onEnded(() => {
+ this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- innerAudioContext.onError((res) => {
+ this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
- isSpeaking.value = false
- isComplete.value = false
+ this.isSpeaking.value = false
+ this.isComplete.value = false
})
- innerAudioContext.onCanplay(() => {
+ this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了')
- if (isSpeaking.value && !isPaused.value) {
- innerAudioContext.play()
+ if (this.isSpeaking.value && !this.isPaused.value) {
+ this.innerAudioContext.play()
}
})
}
return false
}
}
- // #endif
-
- const speak = async (text) => {
- // 停止当前播放
- stop()
+
+ // H5环境播放解码后的音频
+ playDecodedAudio(decoded) {
+ // #ifdef H5
+ if (!this.audioContext) return;
+
+ // 创建音频源
+ this.currentSource = this.audioContext.createBufferSource()
+ this.currentSource.buffer = decoded
+ this.currentSource.connect(this.audioContext.destination)
+
+ // 监听播放结束
+ this.currentSource.onended = () => {
+ console.log('🎵 Audio playback completed');
+ this.isSpeaking.value = false
+ this.isComplete.value = true
+ }
+
+ // 开始播放
+ this.currentSource.start()
+ this.isSpeaking.value = true
+ this.isPaused.value = false
+ this.isComplete.value = false
+ console.log('🎵 Audio playback started');
+ // #endif
+ }
+
+ // 降级处理:创建一个简单的音频缓冲区
+ createFallbackAudio(arrayBuffer) {
+ // #ifdef H5
+ console.log('🔄 使用降级方案创建音频');
+
+ // 创建一个简单的音频缓冲区,生成提示音
+ const sampleRate = 44100
+ const duration = 1 // 1秒
+ const frameCount = sampleRate * duration
+
+ const audioBuffer = this.audioContext.createBuffer(1, frameCount, sampleRate)
+ const channelData = audioBuffer.getChannelData(0)
+
+ // 生成一个简单的提示音(正弦波)
+ for (let i = 0; i < frameCount; i++) {
+ const t = i / sampleRate
+ channelData[i] = Math.sin(2 * Math.PI * 440 * t) * 0.1 // 440Hz正弦波,音量0.1
+ }
+
+ this.playDecodedAudio(audioBuffer)
+ // #endif
+ }
+
+ // 暂停播放
+ pause() {
+ console.log('⏸️ TTS pause called');
+
+ // #ifdef MP-WEIXIN
+ // 优先使用背景音频管理器
+ if (this.backgroundAudioManager) {
+ try {
+ this.backgroundAudioManager.pause()
+ console.log('⏸️ 微信小程序背景音频暂停');
+ return
+ } catch (e) {
+ console.error('❌ 微信小程序背景音频暂停失败:', e);
+ }
+ }
+
+ // 降级使用InnerAudioContext
+ if (this.innerAudioContext && this.isSpeaking.value && !this.isPaused.value) {
+ try {
+ this.innerAudioContext.pause()
+ console.log('⏸️ 微信小程序InnerAudioContext暂停');
+ return
+ } catch (e) {
+ console.error('❌ 微信小程序InnerAudioContext暂停失败:', e);
+ }
+ }
+ // #endif
+
+ // #ifdef H5
+ if (this.audioContext && !this.isSpeaking.value || this.isPaused.value) {
+ console.warn('⚠️ Cannot pause TTS playback');
+ return;
+ }
+
+ if (this.audioContext.state === 'running') {
+ this.audioContext.suspend()
+ this.isPaused.value = true
+ // 保存当前播放位置
+ this.playTimeOffset = this.audioContext.currentTime
+ console.log('✅ H5 Audio paused successfully');
+ }
+ // #endif
+ }
+
+ // 恢复播放
+ resume() {
+ console.log('▶️ TTS resume called');
+
+ // #ifdef MP-WEIXIN
+ // 优先使用背景音频管理器
+ if (this.backgroundAudioManager) {
+ try {
+ this.backgroundAudioManager.play()
+ console.log('▶️ 微信小程序背景音频恢复播放');
+ return
+ } catch (e) {
+ console.error('❌ 微信小程序背景音频恢复失败:', e);
+ }
+ }
+
+ // 降级使用InnerAudioContext
+ if (this.innerAudioContext && this.isSpeaking.value && this.isPaused.value) {
+ try {
+ this.innerAudioContext.play()
+ console.log('▶️ 微信小程序InnerAudioContext恢复播放');
+ return
+ } catch (e) {
+ console.error('❌ 微信小程序InnerAudioContext恢复失败:', e);
+ }
+ }
+ // #endif
+
+ // #ifdef H5
+ if (this.audioContext && !this.isSpeaking.value || !this.isPaused.value) {
+ console.warn('⚠️ Cannot resume TTS playback');
+ return;
+ }
+
+ if (this.audioContext.state === 'suspended') {
+ this.audioContext.resume()
+ this.isPaused.value = false
+ console.log('✅ H5 Audio resumed successfully');
+ }
+ // #endif
+ }
+
+ // 停止播放
+ stop() {
+ console.log('⏹️ TTS stop called');
+
+ // #ifdef MP-WEIXIN
+ // 优先使用背景音频管理器
+ if (this.backgroundAudioManager) {
+ try {
+ this.backgroundAudioManager.stop()
+ console.log('✅ 微信小程序背景音频停止');
+ } catch (e) {
+ console.error('❌ 微信小程序背景音频停止失败:', e);
+ }
+ }
+
+ // 降级使用InnerAudioContext
+ if (this.innerAudioContext) {
+ try {
+ this.innerAudioContext.stop()
+ console.log('✅ 微信小程序InnerAudioContext停止');
+ this.innerAudioContext.destroy()
+ this.innerAudioContext = null
+ } catch (e) {
+ console.error('❌ 微信小程序InnerAudioContext停止错误:', e);
+ }
+ }
+ // #endif
+
+ // #ifdef H5
+ // 取消正在进行的fetch请求
+ if (this.abortController) {
+ try {
+ this.abortController.abort();
+ this.abortController = null;
+ console.log('✅ H5 fetch request aborted');
+ } catch (e) {
+ console.error('❌ Error aborting H5 fetch request:', e);
+ }
+ }
+
+ if (this.currentSource) {
+ try {
+ this.currentSource.stop()
+ this.currentSource.disconnect()
+ } catch (e) {
+ console.error('❌ Error stopping H5 audio source:', e);
+ }
+ this.currentSource = null
+ }
+
+ // 停止并清理HTML5 Audio元素
+ if (this.htmlAudioElement) {
+ try {
+ this.htmlAudioElement.pause();
+ this.htmlAudioElement.src = '';
+ this.htmlAudioElement = null;
+ console.log('✅ H5 HTML5 Audio element stopped and cleaned up');
+ } catch (e) {
+ console.error('❌ Error stopping H5 HTML5 Audio element:', e);
+ }
+ }
+
+ if (this.audioContext && this.audioContext.state === 'running') {
+ try {
+ this.audioContext.suspend()
+ } catch (e) {
+ console.error('❌ Error suspending H5 audio context:', e);
+ }
+ }
+ // #endif
+
+ this.isSpeaking.value = false
+ this.isPaused.value = false
+ this.isComplete.value = false
+ this.currentAudioBuffer = null
+ this.playTimeOffset = 0
+ this.isProcessingRequest = false
+
+ console.log('✅ TTS playback stopped');
+ }
+
+ // 取消音频播放
+ cancelAudio() {
+ this.stop()
+ }
+
+ // 合成并播放语音
+ async speak(text) {
+ // 停止当前播放和处理中的请求
+ this.stop()
try {
+ // 标记开始处理请求
+ this.isProcessingRequest = true
+
// 提取要合成的文本
const speechText = extractSpeechText(text)
console.log('📤 Sending text to TTS server via GET:', speechText.substring(0, 100) + '...');
// 构建GET请求URL
- const url = `${httpUrl}?text=${encodeURIComponent(speechText)}`
+ const url = `${this.httpUrl}?text=${encodeURIComponent(speechText)}`
console.log('🔗 Final GET URL:', url);
// #ifdef MP-WEIXIN
// 微信小程序环境,使用背景音频管理器
- const isBackgroundAudioAvailable = initAudioManager()
+ const isBackgroundAudioAvailable = this.initAudioManager()
// 重置音频状态
- isSpeaking.value = true
- isPaused.value = false
- isComplete.value = false
+ this.isSpeaking.value = true
+ this.isPaused.value = false
+ this.isComplete.value = false
- if (isBackgroundAudioAvailable && backgroundAudioManager) {
+ if (isBackgroundAudioAvailable && this.backgroundAudioManager) {
console.log('🎵 微信小程序:使用背景音频管理器播放,URL:', url);
// 设置背景音频参数
- backgroundAudioManager.title = 'AI语音播报'
- backgroundAudioManager.singer = 'KS AI'
- backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
+ this.backgroundAudioManager.title = 'AI语音播报'
+ this.backgroundAudioManager.singer = 'KS AI'
+ this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
// 直接设置src并播放
- backgroundAudioManager.src = url
+ this.backgroundAudioManager.src = url
console.log('🎵 微信小程序背景音频开始播放');
} else {
// 降级方案:使用InnerAudioContext
console.log('🔄 微信小程序:背景音频不可用,降级使用InnerAudioContext');
// 如果已有音频上下文,先销毁再重新创建
- if (innerAudioContext) {
- innerAudioContext.destroy()
- innerAudioContext = null
+ if (this.innerAudioContext) {
+ this.innerAudioContext.destroy()
+ this.innerAudioContext = null
}
- innerAudioContext = uni.createInnerAudioContext()
- innerAudioContext.autoplay = false
- innerAudioContext.obeyMuteSwitch = false
- innerAudioContext.volume = 1.0
+ this.innerAudioContext = uni.createInnerAudioContext()
+ this.innerAudioContext.autoplay = false
+ this.innerAudioContext.obeyMuteSwitch = false
+ this.innerAudioContext.volume = 1.0
- innerAudioContext.onPlay(() => {
+ this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始')
- isSpeaking.value = true
- isPaused.value = false
+ this.isSpeaking.value = true
+ this.isPaused.value = false
})
- innerAudioContext.onPause(() => {
+ this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
- isPaused.value = true
+ this.isPaused.value = true
})
- innerAudioContext.onStop(() => {
+ this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- innerAudioContext.onEnded(() => {
+ this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束')
- isSpeaking.value = false
- isComplete.value = true
+ this.isSpeaking.value = false
+ this.isComplete.value = true
})
- innerAudioContext.onError((res) => {
+ this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
- isSpeaking.value = false
- isComplete.value = false
+ this.isSpeaking.value = false
+ this.isComplete.value = false
})
- innerAudioContext.onCanplay(() => {
+ this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了')
- if (isSpeaking.value && !isPaused.value) {
- innerAudioContext.play()
+ if (this.isSpeaking.value && !this.isPaused.value) {
+ this.innerAudioContext.play()
}
})
- innerAudioContext.src = url
+ this.innerAudioContext.src = url
console.log('🎵 微信小程序InnerAudioContext开始播放');
}
// #endif
@@ -225,21 +451,25 @@ export function useTTSPlayer(httpUrl) {
// #ifdef H5
// H5环境,使用 AudioContext
try {
+ // 创建新的AbortController,用于取消当前请求
+ this.abortController = new AbortController();
+ const signal = this.abortController.signal;
+
// 确保AudioContext已创建
- if (!audioContext) {
+ if (!this.audioContext) {
console.log('🎵 H5: 创建新的AudioContext');
- audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// 检查并恢复AudioContext状态(浏览器安全策略要求用户交互后才能播放音频)
- if (audioContext.state === 'suspended') {
+ if (this.audioContext.state === 'suspended') {
console.log('🎵 H5: 恢复挂起的AudioContext');
- await audioContext.resume();
- console.log('✅ H5: AudioContext已恢复,状态:', audioContext.state);
+ await this.audioContext.resume();
+ console.log('✅ H5: AudioContext已恢复,状态:', this.audioContext.state);
}
- // 发送GET请求获取语音数据
- const response = await fetch(url)
+ // 发送GET请求获取语音数据,添加signal支持取消
+ const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
@@ -250,277 +480,101 @@ export function useTTSPlayer(httpUrl) {
try {
// 直接使用 audioContext.decodeAudioData 解码,不依赖外部库
- const decoded = await audioContext.decodeAudioData(arrayBuffer)
+ const decoded = await this.audioContext.decodeAudioData(arrayBuffer)
console.log('✅ H5: Audio decoded, sampleRate:', decoded.sampleRate, 'channels:', decoded.numberOfChannels);
// 播放音频
- playDecodedAudio(decoded)
+ this.playDecodedAudio(decoded)
} catch (decodeError) {
console.error('❌ H5: AudioContext decodeAudioData failed:', decodeError);
// 降级处理:创建一个简单的音频缓冲区
- createFallbackAudio(arrayBuffer)
+ this.createFallbackAudio(arrayBuffer)
}
} catch (h5Error) {
+ // 检查是否是取消请求导致的错误
+ if (h5Error.name === 'AbortError') {
+ console.log('✅ H5: Fetch request aborted as requested');
+ this.isSpeaking.value = false;
+ this.isComplete.value = false;
+ return;
+ }
+
console.error('❌ H5: Audio playback failed:', h5Error);
// 尝试使用HTML5 Audio元素作为最终降级方案
try {
console.log('🔄 H5: 尝试使用HTML5 Audio元素播放');
+
+ // 如果已有Audio元素,先停止并销毁
+ if (this.htmlAudioElement) {
+ this.htmlAudioElement.pause();
+ this.htmlAudioElement.src = '';
+ this.htmlAudioElement = null;
+ }
+
const audio = new Audio(url);
+ this.htmlAudioElement = audio;
audio.play();
console.log('✅ H5: HTML5 Audio元素开始播放');
// 设置音频状态
- isSpeaking.value = true;
- isPaused.value = false;
- isComplete.value = false;
+ this.isSpeaking.value = true;
+ this.isPaused.value = false;
+ this.isComplete.value = false;
// 监听播放结束
audio.onended = () => {
console.log('🎵 H5: HTML5 Audio播放结束');
- isSpeaking.value = false;
- isComplete.value = true;
+ this.isSpeaking.value = false;
+ this.isComplete.value = true;
+ this.htmlAudioElement = null;
};
// 监听播放错误
audio.onerror = (error) => {
console.error('❌ H5: HTML5 Audio播放错误:', error);
- isSpeaking.value = false;
- isComplete.value = false;
+ this.isSpeaking.value = false;
+ this.isComplete.value = false;
+ this.htmlAudioElement = null;
};
} catch (audioError) {
console.error('❌ H5: HTML5 Audio播放也失败了:', audioError);
- isSpeaking.value = false;
- isComplete.value = false;
+ this.isSpeaking.value = false;
+ this.isComplete.value = false;
+ this.htmlAudioElement = null;
}
+ } finally {
+ // 清除AbortController,因为请求已经完成(无论成功还是失败)
+ this.abortController = null;
}
// #endif
} catch (error) {
console.error('❌ TTS synthesis failed:', error);
- isSpeaking.value = false
- isComplete.value = false
+ this.isSpeaking.value = false
+ this.isComplete.value = false
+ } finally {
+ // 标记请求处理完成
+ this.isProcessingRequest = false
}
}
+}
- // #ifdef H5
- const playDecodedAudio = (decoded) => {
- if (!audioContext) return;
-
- // 创建音频源
- currentSource = audioContext.createBufferSource()
- currentSource.buffer = decoded
- currentSource.connect(audioContext.destination)
-
- // 监听播放结束
- currentSource.onended = () => {
- console.log('🎵 Audio playback completed');
- isSpeaking.value = false
- isComplete.value = true
- }
-
- // 开始播放
- currentSource.start()
- isSpeaking.value = true
- isPaused.value = false
- isComplete.value = false
- console.log('🎵 Audio playback started');
+// 导出单例hook
+export function useTTSPlayer(httpUrl) {
+ // 如果已经有实例,直接返回
+ if (!ttsInstance) {
+ ttsInstance = new TTSPlayer(httpUrl)
}
-
- // 降级处理:创建一个简单的音频缓冲区
- const createFallbackAudio = (arrayBuffer) => {
- console.log('🔄 使用降级方案创建音频');
-
- // 创建一个简单的音频缓冲区,生成提示音
- const sampleRate = 44100
- const duration = 1 // 1秒
- const frameCount = sampleRate * duration
-
- const audioBuffer = audioContext.createBuffer(1, frameCount, sampleRate)
- const channelData = audioBuffer.getChannelData(0)
-
- // 生成一个简单的提示音(正弦波)
- for (let i = 0; i < frameCount; i++) {
- const t = i / sampleRate
- channelData[i] = Math.sin(2 * Math.PI * 440 * t) * 0.1 // 440Hz正弦波,音量0.1
- }
-
- playDecodedAudio(audioBuffer)
- }
- // #endif
-
- const pause = () => {
- console.log('⏸️ TTS pause called');
-
- // #ifdef MP-WEIXIN
- // 优先使用背景音频管理器
- if (backgroundAudioManager) {
- try {
- backgroundAudioManager.pause()
- console.log('⏸️ 微信小程序背景音频暂停');
- return
- } catch (e) {
- console.error('❌ 微信小程序背景音频暂停失败:', e);
- }
- }
-
- // 降级使用InnerAudioContext
- if (innerAudioContext && isSpeaking.value && !isPaused.value) {
- try {
- innerAudioContext.pause()
- console.log('⏸️ 微信小程序InnerAudioContext暂停');
- return
- } catch (e) {
- console.error('❌ 微信小程序InnerAudioContext暂停失败:', e);
- }
- }
- // #endif
-
- // #ifdef H5
- if (audioContext && !isSpeaking.value || isPaused.value) {
- console.warn('⚠️ Cannot pause TTS playback');
- return;
- }
-
- if (audioContext.state === 'running') {
- audioContext.suspend()
- isPaused.value = true
- // 保存当前播放位置
- playTimeOffset = audioContext.currentTime
- console.log('✅ H5 Audio paused successfully');
- }
- // #endif
- }
-
- const resume = () => {
- console.log('▶️ TTS resume called');
-
- // #ifdef MP-WEIXIN
- // 优先使用背景音频管理器
- if (backgroundAudioManager) {
- try {
- backgroundAudioManager.play()
- console.log('▶️ 微信小程序背景音频恢复播放');
- return
- } catch (e) {
- console.error('❌ 微信小程序背景音频恢复失败:', e);
- }
- }
-
- // 降级使用InnerAudioContext
- if (innerAudioContext && isSpeaking.value && isPaused.value) {
- try {
- innerAudioContext.play()
- console.log('▶️ 微信小程序InnerAudioContext恢复播放');
- return
- } catch (e) {
- console.error('❌ 微信小程序InnerAudioContext恢复失败:', e);
- }
- }
- // #endif
-
- // #ifdef H5
- if (audioContext && !isSpeaking.value || !isPaused.value) {
- console.warn('⚠️ Cannot resume TTS playback');
- return;
- }
-
- if (audioContext.state === 'suspended') {
- audioContext.resume()
- isPaused.value = false
- console.log('✅ H5 Audio resumed successfully');
- }
- // #endif
- }
-
- const cancelAudio = () => {
- stop()
- }
-
- const stop = () => {
- console.log('⏹️ TTS stop called');
-
- // #ifdef MP-WEIXIN
- // 优先使用背景音频管理器
- if (backgroundAudioManager) {
- try {
- backgroundAudioManager.stop()
- console.log('✅ 微信小程序背景音频停止');
- } catch (e) {
- console.error('❌ 微信小程序背景音频停止失败:', e);
- }
- }
-
- // 降级使用InnerAudioContext
- if (innerAudioContext) {
- try {
- innerAudioContext.stop()
- console.log('✅ 微信小程序InnerAudioContext停止');
- innerAudioContext.destroy()
- innerAudioContext = null
- } catch (e) {
- console.error('❌ 微信小程序InnerAudioContext停止错误:', e);
- }
- }
- // #endif
-
- // #ifdef H5
- if (currentSource) {
- try {
- currentSource.stop()
- currentSource.disconnect()
- } catch (e) {
- console.error('❌ Error stopping H5 audio source:', e);
- }
- currentSource = null
- }
-
- if (audioContext && audioContext.state === 'running') {
- try {
- audioContext.suspend()
- } catch (e) {
- console.error('❌ Error suspending H5 audio context:', e);
- }
- }
- // #endif
-
- isSpeaking.value = false
- isPaused.value = false
- isComplete.value = false
- currentAudioBuffer = null
- playTimeOffset = 0
-
- console.log('✅ TTS playback stopped');
- }
-
- 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)
-
+
+ // 返回实例的方法和状态
return {
- speak,
- pause,
- resume,
- cancelAudio,
- isSpeaking,
- isPaused,
- isComplete
+ speak: ttsInstance.speak.bind(ttsInstance),
+ pause: ttsInstance.pause.bind(ttsInstance),
+ resume: ttsInstance.resume.bind(ttsInstance),
+ cancelAudio: ttsInstance.cancelAudio.bind(ttsInstance),
+ isSpeaking: ttsInstance.isSpeaking,
+ isPaused: ttsInstance.isPaused,
+ isComplete: ttsInstance.isComplete
}
}
diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue
index 3206870..af01b9a 100644
--- a/pages/chat/components/ai-paging.vue
+++ b/pages/chat/components/ai-paging.vue
@@ -794,29 +794,10 @@ function readMarkdown(value, index) {
clearTimeout(ttsDebounceTimer);
}
- // 如果当前正在播放其他消息,先停止
- if (speechIndex.value !== index && speechIndex.value !== 0) {
- console.log('🛑 Stopping current speech and starting new one');
- speechIndex.value = index;
- speak(value);
- return;
- }
-
+ // 总是先停止当前播放,无论是不是同一消息
+ console.log('🛑 Always stopping current speech before starting new one');
speechIndex.value = index;
- // 如果当前正在播放且暂停了,直接恢复
- if (isPaused.value && isSpeaking.value) {
- console.log('▶️ Resuming paused speech');
- resume();
- return;
- }
-
- // 如果当前正在播放且没有暂停,不需要重新开始
- if (isSpeaking.value && !isPaused.value) {
- console.log('🔊 Already speaking, no need to restart');
- return;
- }
-
// 使用防抖,避免频繁调用TTS
ttsDebounceTimer = setTimeout(() => {
console.log('🎵 Starting new speech');
From a3bc821bc3fd0d226d40b192fbcd0136da43a4f8 Mon Sep 17 00:00:00 2001
From: francis_fh <13935151924@163.com>
Date: Sat, 24 Jan 2026 18:29:47 +0800
Subject: [PATCH 05/18] =?UTF-8?q?=E6=B5=81=E5=BC=8F=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BC=98=E5=8C=96=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
hook/useTTSPlayer.js | 13 +++-----
pages/chat/components/ai-paging.vue | 52 +++++++++++++++++++++--------
2 files changed, 43 insertions(+), 22 deletions(-)
diff --git a/hook/useTTSPlayer.js b/hook/useTTSPlayer.js
index b8f4b99..9f95f3b 100644
--- a/hook/useTTSPlayer.js
+++ b/hook/useTTSPlayer.js
@@ -376,8 +376,7 @@ class TTSPlayer {
// 微信小程序环境,使用背景音频管理器
const isBackgroundAudioAvailable = this.initAudioManager()
- // 重置音频状态
- this.isSpeaking.value = true
+ // 重置音频状态,但不立即设置为正在播放,等待实际播放时再设置
this.isPaused.value = false
this.isComplete.value = false
@@ -391,7 +390,7 @@ class TTSPlayer {
// 直接设置src并播放
this.backgroundAudioManager.src = url
- console.log('🎵 微信小程序背景音频开始播放');
+ console.log('🎵 微信小程序背景音频开始加载');
} else {
// 降级方案:使用InnerAudioContext
console.log('🔄 微信小程序:背景音频不可用,降级使用InnerAudioContext');
@@ -403,7 +402,7 @@ class TTSPlayer {
}
this.innerAudioContext = uni.createInnerAudioContext()
- this.innerAudioContext.autoplay = false
+ this.innerAudioContext.autoplay = true // 设置自动播放,等音频加载完成后自动开始
this.innerAudioContext.obeyMuteSwitch = false
this.innerAudioContext.volume = 1.0
@@ -438,13 +437,11 @@ class TTSPlayer {
this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了')
- if (this.isSpeaking.value && !this.isPaused.value) {
- this.innerAudioContext.play()
- }
+ // 不需要手动调用play,因为已经设置了autoplay
})
this.innerAudioContext.src = url
- console.log('🎵 微信小程序InnerAudioContext开始播放');
+ console.log('🎵 微信小程序InnerAudioContext开始加载');
}
// #endif
diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue
index af01b9a..24352c0 100644
--- a/pages/chat/components/ai-paging.vue
+++ b/pages/chat/components/ai-paging.vue
@@ -479,11 +479,16 @@ const sendMessage = (text) => {
console.log('📝 Has job info:', hasJobInfo);
// 开始朗读当前消息
- speechIndex.value = index;
- readMarkdown(message.displayText, index);
+ speechIndex.value = index;
+ readMarkdown(message.displayText, index, { immediate: false });
+ // 一旦开始朗读,就设置speechIndex,避免重复调用
+ speechIndex.value = index;
} else {
console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length);
}
+ } else {
+ // 已经开始朗读这条消息,不再重复调用
+ console.log('⏭️ Already speaking this message, skipping duplicate TTS call');
}
}
},
@@ -508,7 +513,7 @@ const sendMessage = (text) => {
// 开始朗读完整的内容
speechIndex.value = lastMessageIndex;
- readMarkdown(lastMessage.displayText, lastMessageIndex);
+ readMarkdown(lastMessage.displayText, lastMessageIndex, { immediate: true });
}
}
},
@@ -781,7 +786,10 @@ function confirmFeeBack(value) {
// 防抖定时器
let ttsDebounceTimer = null;
-function readMarkdown(value, index) {
+// 保存上一次调用的文本内容,避免重复调用TTS
+let lastSpeechText = '';
+
+function readMarkdown(value, index, options = {}) {
console.log('🎤 readMarkdown called');
console.log('📝 Text to speak:', value ? value.substring(0, 100) + '...' : 'No text');
console.log('🔢 Index:', index);
@@ -798,17 +806,33 @@ function readMarkdown(value, index) {
console.log('🛑 Always stopping current speech before starting new one');
speechIndex.value = index;
- // 使用防抖,避免频繁调用TTS
- ttsDebounceTimer = setTimeout(() => {
- console.log('🎵 Starting new speech');
- console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
- try {
- speak(value);
- console.log('✅ Speak function called successfully');
- } catch (error) {
- console.error('❌ Error calling speak function:', error);
+ // 立即调用speak,不使用防抖延迟
+ const speakNow = () => {
+ // 检查文本内容是否发生变化,避免重复调用TTS
+ if (value !== lastSpeechText) {
+ console.log('🎵 Starting new speech');
+ console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
+ try {
+ speak(value);
+ console.log('✅ Speak function called successfully');
+ // 更新上一次调用的文本内容
+ lastSpeechText = value;
+ } catch (error) {
+ console.error('❌ Error calling speak function:', error);
+ }
+ } else {
+ console.log('🔄 Same text as last speech, skipping duplicate TTS call');
}
- }, 300); // 300ms防抖延迟
+ };
+
+ // 改进防抖逻辑,确保在短时间内只调用一次
+ if (options.immediate) {
+ // 如果是onComplete回调,立即播放
+ speakNow();
+ } else {
+ // 对于流式数据,总是使用防抖,避免频繁调用
+ ttsDebounceTimer = setTimeout(speakNow, 500); // 延长防抖时间到500ms
+ }
}
function stopMarkdown(value, index) {
console.log('⏸️ stopMarkdown called for index:', index);
From 360d4f96eaf3a862633b03d9af80bef2e9fdec94 Mon Sep 17 00:00:00 2001
From: xuchao <1151716571@qq.com>
Date: Mon, 26 Jan 2026 10:35:25 +0800
Subject: [PATCH 06/18] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=8A=80=E8=83=BD?=
=?UTF-8?q?=E5=9F=B9=E8=AE=AD=E5=85=A5=E5=8F=A3=E5=90=8D=E7=A7=B0=E6=94=B9?=
=?UTF-8?q?=E4=B8=BA=E6=8A=80=E8=83=BD=E8=AF=BE=E5=A0=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages/index/components/index-one.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pages/index/components/index-one.vue b/pages/index/components/index-one.vue
index ac83904..2e3b8c7 100644
--- a/pages/index/components/index-one.vue
+++ b/pages/index/components/index-one.vue
@@ -110,7 +110,7 @@
- 技能培训
+ 技能课堂