h5录音功能 适用web + 安卓webview
·
思路:
1.调用web api navigator.mediaDevices.getUserMedia获取音频权限
2.链接websocket
主要问题 navigator.mediaDevices.getUserMedia 在web中能主动获取麦克风权限,但是安卓webview无法获取权限。 需要原生写一些相关授权,注意需要静默授权才能获取权限
完整代码
<template>
<div v-btnAuth="authCode.tab" class="box flex-su ipad-top">
<Header title="AI会议录制" theme="custom"></Header>
<!-- <div>{{ isConnected ? '已连接' : '未连接' }}</div> -->
<div class="main flex-su-order-1">
<nut-row class="ht100 block">
<nut-col :span="15" class="ht100">
<div class="left-main flex-su ht100">
<div class="flex-su-order-1 flex-su pdl20 pdt20 pdb10">
<div class="f3 font-weight500 mgb24">
<span class="curDate">{{ info.curDate }}</span> 记录
</div>
<div id="mainBox" class="flex-su-order-1 pdr20">
<div v-for="(item, index) in info.list" :key="index">
<template v-if="item.speakName">
<div class="flex-heng-between flex-align-center">
<div class="flex">
<img class="taking" src="@/assets/ipad/taking.png" />
<div class="f0 mgl5 mgr10 c6">{{ item.speakName }}</div>
<div class="f0 c6" v-if="item.isComplete">
{{ getTimeMins(item.recordingDurationTime, false) }}
</div>
</div>
<!-- 假的占位 -->
<div class="signBox op" v-if="!item.isComplete">
<img class="taking" src="@/assets/ipad/toDoLo.png" />
</div>
<div class="signBox" v-if="item.isComplete">
<img
class="taking"
v-show="item.focusFlag === 0"
@click="editSign(item, 'focusFlag', 1)"
src="@/assets/ipad/toDoLo.png"
/>
<img
v-show="item.focusFlag !== 0"
@click="editSign(item, 'focusFlag', 0)"
class="taking"
src="@/assets/ipad/toDoLoActive.png"
/>
<img
v-show="item.questFlag === 0"
class="taking"
@click="editSign(item, 'questFlag', 1)"
src="@/assets/ipad/problem.png"
/>
<img
v-show="item.questFlag !== 0"
@click="editSign(item, 'questFlag', 0)"
class="taking"
src="@/assets/ipad/problemActive.png"
/>
<img
v-show="item.waitToDoFlag === 0"
@click="editSign(item, 'waitToDoFlag', 1)"
class="taking"
src="@/assets/ipad/sign.png"
/>
<img
v-show="item.waitToDoFlag !== 0"
@click="editSign(item, 'waitToDoFlag', 0)"
class="taking"
src="@/assets/ipad/signActive.png"
/>
</div>
</div>
<div class="mgl27 taking-text">
{{ item.contentText }}
</div>
</template>
</div>
</div>
</div>
<div class="btn-box flex-heng-between flex-align-center">
<div class="flex">
<img
v-if="isRecording && !isEnd"
class="vedioMp3"
src="@/assets/ipad/vedioStart.gif"
/>
<img v-else class="vedioMp3" src="@/assets/ipad/vedioMp3.png" />
<ul>
<li class="f2 mgb2 font-weight500">
{{
isEnd
? '录音结束'
: isRecording
? '正在录音...'
: '暂停录音'
}}
</li>
<li class="f0 c9 font-weight500">
{{
getTimeMins(
route.query.meetId
? curMeetTime - stopMeetTime + initMeetTime
: curMeetTime - stopMeetTime,
true
)
}}
</li>
</ul>
</div>
<div class="flex">
<div
class="start-btn"
@click="stopBtnClick"
v-if="isRecording && !isEnd"
>
<img src="@/assets/ipad/stop.png" />
</div>
<div
v-if="!isRecording && !isEnd"
class="start-btn"
@click="startBtnClick"
>
<img class="start" src="@/assets/ipad/start.svg" />
</div>
<div v-if="!isEnd" class="stop-btn f2" @click="opendEndAudio">
结束
</div>
</div>
</div>
</div>
</nut-col>
<nut-col :span="9" class="ht100">
<div class="right-main flex-su">
<div class="flex-heng mgb16">
<div class="active f2 mgr38">标记内容</div>
<!-- <div class="c9 f2">思维导图</div> -->
</div>
<nut-popover v-model:visible="show" location="bottom">
<template #reference>
<div class="c9">提取标记类型 <RectDown /></div>
</template>
<template #content>
<div class="down-data">
<div
class="flex-heng-between mgb20 flex-align-center"
@click="changeSign(1)"
>
<div>
<img
class="toDoLoSmall"
src="@/assets/ipad/toDoLoSmall.png"
/>
<span class="f2">重点标记</span>
</div>
<Check v-if="rightActive.includes(1)" color="#4070ff" />
</div>
<div
class="flex-heng-between mgb20 flex-align-center"
@click="changeSign(2)"
>
<div>
<img
class="problemSmall"
src="@/assets/ipad/problemSmall.png"
/>
<span class="f2">问题标记</span>
</div>
<Check v-if="rightActive.includes(2)" color="#4070ff" />
</div>
<div
class="flex-heng-between flex-align-center"
@click="changeSign(3)"
>
<div>
<img
class="signSmall"
src="@/assets/ipad/signSmall.png"
/>
<span class="f2">重点标记</span>
</div>
<Check v-if="rightActive.includes(3)" color="#4070ff" />
</div>
</div>
</template>
</nut-popover>
<div class="mgt20 pdr20 flex-su-order-1">
<div
v-for="(item, index) in info.list.filter(
(ele) => ele.focusFlag || ele.questFlag || ele.waitToDoFlag
)"
:key="index"
>
<template
v-if="
(item.focusFlag && rightActive.includes(1)) ||
(item.questFlag && rightActive.includes(2)) ||
(item.waitToDoFlag && rightActive.includes(3))
"
>
<div>
<img
v-if="item.focusFlag"
class="toDoLoSmall"
src="@/assets/ipad/toDoLoSmall.png"
/>
<img
v-if="item.questFlag"
class="problemSmall"
src="@/assets/ipad/problemSmall.png"
/>
<img
v-if="item.waitToDoFlag"
class="signSmall"
src="@/assets/ipad/signSmall.png"
/>
<span class="f2">标记</span>
<span v-if="item.focusFlag" class="f0 dib mgl5">重点</span>
<span v-if="item.questFlag" class="f0 dib mgl5">问题</span>
<span v-if="item.waitToDoFlag" class="f0 dib mgl5"
>待办</span
>
</div>
<div class="mgl22 mgb20 mgt8">
{{ item.contentText }}
</div>
</template>
</div>
</div>
<!-- <div class="btn-right-box" @click="baseClick">AI生成待办</div> -->
</div>
</nut-col>
</nut-row>
<nut-dialog
key="1"
teleport="#app"
title="温馨提醒"
ok-text="确定"
content="当前录制时间最长3小时,还剩最后10分钟,请注意时间一到会自动结束录制!!是否现在结束会议?"
v-model:visible="endTimeVisible"
@ok="endAudio"
>
</nut-dialog>
<nut-dialog
key="2"
teleport="#app"
title="111"
ok-text="确定"
content="确定要结束录制吗?"
v-model:visible="endVisible"
@ok="endAudio"
>
<template #header>
<div>
<img
class="w20 vaf4"
src="@/assets/ipad/warning.svg"
alt=""
srcset=""
/>
<span v-if="isRecording">当前会议正在录制中</span>
<span v-else>当前会议正在暂停中</span>
</div>
</template>
</nut-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import {
authCode,
API,
InfoFaceTranscribe,
TakingFace,
getTimeMins,
} from './indexMods/configure.js';
import { showToast, showDialog } from '@nutui/nutui';
import moment from 'moment';
defineOptions({
name: 'ipadHomeIndex',
});
/*-----------------------------公共-------------------------------------------*/
const store = useStore();
const route = useRoute();
const router = useRouter();
const info = reactive<InfoFaceTranscribe>({
list: [],
curDate: moment().format('YYYY-MM-DD HH:mm:ss'),
});
let startMeetTime = ''; //会议开始时间
let curMeetTime = ref(0); //会议的时长 curMeetTime - stopMeetTime 录音的时长
let stopMeetTime = ref(0); // 录音暂停了多少时间
let initMeetTime = 0; //初始的录音时长 例如下次进来的时长
let oneTip = 0;
let timer = setInterval(() => {
info.curDate = moment(new Date(info.curDate).getTime() + 1000).format(
'YYYY-MM-DD HH:mm:ss'
);
//实时计算会议时间
curMeetTime.value =
(new Date(info.curDate).getTime() - new Date(startMeetTime).getTime()) /
1000;
if (!isRecording.value) {
// 暂停
stopMeetTime.value = stopMeetTime.value + 1;
}
// 超过3小时结束会议
if (curMeetTime.value > 10200 && oneTip === 0) {
oneTip++;
opendTimeEndAudio();
}
if (curMeetTime.value > 10800) {
endAudio();
}
}, 1000);
const show = ref(false);
/*------------------------------------初始化1.获取会议信息 2.新建会议并同时连接websocket------------------------------------*/
let meetId = '';
const init = async () => {
// requestMicrophonePermission();
//
console.log('开始获取录音权限');
try {
await startRecording();
} catch (error) {
console.log(error);
return false;
}
// 防止刷新页面 热更新 反复创建 会议
let storageData: any = localStorage.getItem('meetBox');
if (route.query.meetId) {
// 说明是继续会议 进来的
meetId = route.query.meetId as string;
websocketInfo.value = {
MeetingJoinUrl: route.query.externalWebsocketUrl,
TaskId: route.query.taskId,
};
startMeetTime = moment().format('YYYY-MM-DD HH:mm:ss') as string;
initMeetTime = Number(route.query.initMeetTime);
await doHistory();
return false;
} else if (storageData) {
storageData = JSON.parse(storageData);
websocketInfo.value = storageData.taskInfo;
meetId = storageData.meetId;
startMeetTime = storageData.startMeetTime;
stopMeetTime.value = storageData.stopMeetTime;
await doHistory();
return false;
}
requestAi({
// mock: true,
url: API.taskAiCreate,
data: {
ignoreRepeat: true,
task_key: new Date().getTime(),
},
}).then((res) => {
websocketInfo.value = res;
// 调用景天新建
startMeetTime = info.curDate;
addMeet().then((two) => {
localStorage.setItem(
'meetBox',
JSON.stringify({
meetId: two,
taskInfo: res,
startMeetTime: startMeetTime,
stopMeetTime: stopMeetTime.value,
})
);
meetId = two;
connectWebSocket(websocketInfo.value);
});
});
};
const doHistory = async () => {
// 获取历史明细200 记录总差值
let res = await getOnceMeetDetail();
let num = res.total - info.list.length;
info.list = [];
for (let i = 0; i < num; i++) {
// 添加假数据
info.list.push({
recordingDurationTime: 1, //录音时长,单位:秒
isComplete: true, //是否一句话完成
contentText: '',
speakName: '', //发言人
sort: 0,
focusFlag: 0, //重点标记标识:1 是重点;0 否
questFlag: 0, //问题标记标识:1 是; 0 否
waitToDoFlag: 0, //待办标识:1 是;0 否
});
}
let arr = res.rows.reverse();
arr.forEach((ele) => {
ele.isComplete = true;
});
info.list = info.list.concat(arr);
startIndex = info.list.length;
scrollToBottom(mainBox);
connectWebSocket(websocketInfo.value);
};
// 一次性获取景天100条会议详情
const getOnceMeetDetail = async () => {
return requestOmp({
url: API.detailList,
data: {
apiNum: 4,
ignoreRepeat: true,
pageQry: {
meetingSummaryId: meetId,
order: 'desc',
sort: 'sort',
page: 1,
size: 200,
},
},
});
};
/*-----------------------------------会议的新增 编辑 结束-------------------------------------*/
// 新增会议
const addMeet = async () => {
return requestOmp({
url: API.add,
data: {
apiNum: 4,
addCmd: {
groupCode: route.query.type,
startTime: startMeetTime,
externalWebsocketUrl: websocketInfo.value.MeetingJoinUrl,
externalMeetingFile: '',
externalMeetingId: websocketInfo.value.TaskId,
},
},
});
};
// 新增会议明细
const addMeetDetail = async (val: TakingFace) => {
requestOmp({
url: API.addDetail,
data: {
apiNum: 4,
ignoreRepeat: true,
addCmd: {
...val,
meetingSummaryId: meetId,
},
},
}).then((res) => {
val.id = res;
});
};
// 修改会议想去
const editMeet = async () => {
requestOmp({
url: API.edit,
data: {
apiNum: 4,
ignoreRepeat: true,
updateCmd: {
id: meetId,
totalTime: curMeetTime.value - stopMeetTime.value + initMeetTime,
},
},
});
};
// 修改会议明细
const editSign = (val: TakingFace, str: string, num: number): void => {
if (isEnd.value) {
showToast.hide();
showToast.text('当前录音已结束。', {
'custom-class': 'ipad-toast',
});
return;
}
val[str] = num;
editMeetDetail(val);
};
const editMeetDetail = async (val: TakingFace) => {
requestOmp({
url: API.editDetail,
data: {
apiNum: 4,
ignoreRepeat: true,
updateCmd: {
...val,
meetingSummaryId: meetId,
},
},
}).then(() => {});
};
// 结束会议明细
const endMeetOmp = async (val: any) => {
return requestOmp({
url: API.finish,
data: {
apiNum: 4,
ignoreRepeat: true,
finishCmd: {
id: meetId,
endTime: moment(new Date(info.curDate).getTime()).format(
'YYYY-MM-DD HH:mm:ss'
),
totalTime: curMeetTime.value - stopMeetTime.value + initMeetTime,
// totalTime: curMeetTime.value - stopMeetTime.value,
externalMeetingId: val.TaskId,
externalMeetingFile: val.OutputMp3Path, //文件地址
keywordWorks: val.Result?.MeetingAssistance?.MeetingAssistance?.Keywords
? JSON.stringify(
val.Result.MeetingAssistance.MeetingAssistance.Keywords
)
: '', //关键字
fullSummary: val.Result?.Summarization?.Summarization?.ParagraphSummary, //全文概要
importantClassif: val.Result?.MeetingAssistance?.MeetingAssistance
?.KeySentences
? JSON.stringify(
val.Result.MeetingAssistance.MeetingAssistance.KeySentences
)
: '', // 要点归类
summarySpeech: val.Result?.Summarization?.Summarization
?.ConversationalSummary
? JSON.stringify(
val.Result.Summarization.Summarization.ConversationalSummary
)
: '', //发言总结
mindMap: val.Result?.Summarization?.Summarization?.MindMapSummary
? JSON.stringify(
val.Result.Summarization.Summarization.MindMapSummary
)
: '', //思维导图
},
},
});
};
//跟ai交互 停止会议任务
const endMeetAi = async () => {
return requestAi({
url: API.stopTask,
data: {
ignoreRepeat: true,
task_id: websocketInfo.value.TaskId,
task_key: new Date().getTime(),
},
});
};
//ai总结
const sumMeetAi = async () => {
return requestAi({
url: API.taskInfo,
method: 'get',
data: {
ignoreRepeat: true,
task_id: websocketInfo.value.TaskId,
},
});
};
/*----------------------------------连接websocket 初始化录音 获取录音返回--------------------------------------*/
// 连接websocket
const wsRef = ref<any>(null);
const isConnected = ref<boolean>(false);
const isRecording = ref<boolean>(false);
const prevIsRecordingRef = ref<boolean>(false);
const recorderRef = ref<any>(null);
const streamRef = ref<any>(null);
const isClosingRef = ref<boolean>(false);
const websocketInfo = ref<any>({});
// 监听isRecording属性变化,自动控制录音
watch(isRecording, (newValue, oldValue) => {
// 避免初始渲染时触发
if (prevIsRecordingRef.value !== isRecording.value) {
if (isRecording.value) {
if (!isConnected.value) {
connectWebSocket(websocketInfo.value);
} else {
startRecording();
}
} else {
stopRecording();
}
}
prevIsRecordingRef.value = isRecording.value;
});
// 开始连接
let startIndex = 0;
let startIndexNew = 0;
const connectWebSocket = (val) => {
// 获取认证 token
const token = store.getters['user/getToken'];
// 创建 WebSocket 连接
try {
// 这里使用项目中的 WebSocket URL
const wsUrl = val.MeetingJoinUrl;
wsRef.value = new WebSocket(wsUrl);
wsRef.value.binaryType = 'arraybuffer';
wsRef.value.onopen = () => {
console.log('WebSocket 连接已打开');
isConnected.value = true;
// startRecording();
// 发送开始转写请求
const params = {
header: {
name: 'StartTranscription',
namespace: 'SpeechTranscriber',
},
payload: {
format: 'pcm',
sample_rate: 16000,
channels: 2,
bits: 16,
},
};
wsRef.value?.send(JSON.stringify(params));
};
wsRef.value.onmessage = (msg) => {
if (typeof msg.data === 'string') {
try {
const dataJson = JSON.parse(msg.data);
let zhongBox: any = {};
let endBox: any = {};
switch (dataJson.header.name) {
case 'SentenceBegin': {
startIndexNew = startIndex;
// 句子开始事件
// start = {
// sentenceId: dataJson.payload.index,
// text: dataJson.payload.result,
// confidence: dataJson.payload.confidence,
// startTime: dataJson.payload.begin_time,
// endTime: dataJson.payload.time,
// };
// console.log('句子开始事件', start.text, start);
break;
}
case 'TranscriptionResultChanged': {
// 句中识别结果变化事件
zhongBox = {
// sentenceId: dataJson.payload.index,
// text: dataJson.payload.result,
// confidence: dataJson.payload.confidence,
// startTime: dataJson.payload.begin_time,
// endTime: dataJson.payload.time,
recordingDurationTime:
(dataJson.payload.time - dataJson.payload.begin_time) / 1000, //录音时长,单位:秒
speakName: '发言人', //发言人
isComplete: false,
contentText: dataJson.payload.result,
sort: Math.round(startIndexNew + dataJson.payload.index), //内容排序
focusFlag: 0, //重点标记标识:1 是重点;0 否
questFlag: 0, //问题标记标识:1 是; 0 否
waitToDoFlag: 0, //待办标识:1 是;0 否
};
console.log(
'句子中间结果',
startIndexNew + dataJson.payload.index,
zhongBox,
dataJson.payload
);
info.list[Math.round(startIndexNew + dataJson.payload.index)] =
zhongBox as TakingFace;
scrollToBottom(mainBox);
// if (!isRecording.value) {
// startIndex = info.list.length;
// }
break;
}
case 'SentenceEnd':
endBox = {
recordingDurationTime:
(dataJson.payload.time - dataJson.payload.begin_time) / 1000, //录音时长,单位:秒
speakName: '发言人', //发言人
isComplete: true,
contentText:
dataJson.payload.result + dataJson.payload.stash_result.text,
sort: Math.round(startIndexNew + dataJson.payload.index), //内容排序
focusFlag: 0, //重点标记标识:1 是重点;0 否
questFlag: 0, //问题标记标识:1 是; 0 否
waitToDoFlag: 0, //待办标识:1 是;0 否
};
info.list[Math.round(startIndexNew + dataJson.payload.index)] =
endBox as TakingFace;
console.log(
'结束句子结果',
startIndexNew + dataJson.payload.index,
endBox,
dataJson.payload
);
// 结束 新增会议明细
addMeetDetail(
info.list[Math.round(startIndexNew + dataJson.payload.index)]
);
// 结束 实时记录下录音时长
editMeet();
// if (onTranscriptionResult) {
// onTranscriptionResult(result);
// }
break;
case 'TaskFailed':
console.log(`任务失败: ${dataJson.header.status_text}`);
showToast.fail(`任务失败: ${dataJson.header.status_text}`, {
'custom-class': 'ipad-toast',
});
stopRecording();
break;
}
} catch (err) {
console.error('解析消息失败:', err);
showToast.fail('解析消息失败', {
'custom-class': 'ipad-toast',
});
}
}
};
wsRef.value.onerror = (err) => {
console.error('WebSocket 错误:', err);
const error = new Error('WebSocket 连接错误');
showToast.fail('WebSocket 连接错误', {
'custom-class': 'ipad-toast',
});
stopRecording();
};
wsRef.value.onclose = () => {
console.log('WebSocket 连接已关闭');
isConnected.value = false;
isClosingRef.value = false;
};
} catch (err) {
console.log('获取麦克风权限失败', err);
showToast.fail('获取麦克风权限失败', {
'custom-class': 'ipad-toast',
});
}
};
// 开始录音
const startRecording = () => {
return new Promise((resolve, reject) => {
console.log('isRecording.value', isRecording.value);
if (isRecording.value) return;
// 检查浏览器是否支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
reject(new Error('浏览器不支持录音功能'));
}
console.log(
'浏览器支持',
!(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia),
navigator.mediaDevices.getUserMedia
);
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
recorderRef.value = initRecorder(stream);
recorderRef.value.start();
isRecording.value = true;
showToast.success('录音已开始', {
'custom-class': 'ipad-toast',
});
resolve(stream);
})
.catch((error) => {
console.log('获取麦克风权限失败', error);
// showToast.fail('获取麦克风权限失败', {
// 'custom-class': 'ipad-toast',
// // })
reject(new Error('web提示获取麦克风权限失败'));
});
});
};
// 初始化录音机
const initRecorder = (stream: any) => {
streamRef.value = stream;
const context = new window.AudioContext();
const audioInput = context.createMediaStreamSource(stream);
const recorder = context.createScriptProcessor(4096, 1, 1);
const audioData = {
size: 0,
buffer: [],
inputSampleRate: context.sampleRate,
inputSampleBits: 16,
outputSampleRate: 16000,
oututSampleBits: 16,
clear: function () {
this.buffer = [];
this.size = 0;
},
input: function (data) {
this.buffer.push(new Float32Array(data));
this.size += data.length;
},
compress: function () {
// 合并所有缓冲区数据
const data = new Float32Array(this.size);
let offset = 0;
for (let i = 0; i < this.buffer.length; i++) {
data.set(this.buffer[i], offset);
offset += this.buffer[i].length;
}
// 计算压缩比
const compression = this.inputSampleRate / this.outputSampleRate;
const length = Math.floor(data.length / compression);
const result = new Float32Array(length);
// 简单的低通滤波和重采样
const filter = (x: number) => x; // 实际应用中可使用更复杂的滤波器
for (let i = 0; i < length; i++) {
const srcIndex = Math.floor(i * compression);
result[i] = filter(data[srcIndex]);
}
return result;
},
encodePCM: function () {
const sampleRate = this.outputSampleRate;
const sampleBits = this.oututSampleBits;
const bytes = this.compress();
const dataLength = bytes.length * (sampleBits / 8);
const buffer = new ArrayBuffer(dataLength);
const data = new DataView(buffer);
let offset = 0;
// 正确处理16位PCM编码
for (let i = 0; i < bytes.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, bytes[i]));
// 转换为16位有符号整数,小端字节序
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([data]);
},
};
const sendAudioData = () => {
// 检查WebSocket状态
if (!wsRef.value || wsRef.value.readyState !== WebSocket.OPEN) {
console.warn('WebSocket is not open, skipping audio data send');
audioData.clear();
return;
}
// 对数据进行分片处理
const reader = new FileReader();
reader.onload = (e) => {
if (!e.target?.result) return;
const outbuffer = e.target.result as ArrayBuffer;
const arr = new Int8Array(outbuffer);
if (arr.length > 0) {
// 使用4096字节的分片大小
const chunkSize = 4096;
for (let start = 0; start < arr.length; start += chunkSize) {
const end = Math.min(start + chunkSize, arr.length);
const chunk = arr.slice(start, end);
if (wsRef.value?.readyState === WebSocket.OPEN) {
wsRef.value.send(chunk);
} else {
console.warn('WebSocket closed during data send');
break;
}
}
}
};
reader.readAsArrayBuffer(audioData.encodePCM());
audioData.clear();
};
recorder.onaudioprocess = (e) => {
// 如果连接正在关闭,则不处理新的音频数据
if (isClosingRef.value) return;
const inputBuffer = e.inputBuffer.getChannelData(0);
audioData.input(inputBuffer);
sendAudioData();
};
return {
start: () => {
audioInput.connect(recorder);
recorder.connect(context.destination);
},
stop: () => {
recorder.disconnect();
},
clear: () => {
audioData.clear();
},
};
};
// 停止录音
const stopRecording = () => {
if (!isRecording.value) return;
// 停止录音
if (recorderRef.value) {
recorderRef.value.stop();
recorderRef.value = null;
}
// 停止媒体流
if (streamRef.value) {
streamRef.value.getTracks().forEach((track) => track.stop());
streamRef.value = null;
}
// 关闭WebSocket连接
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
// isClosingRef.value = true;
// 发送停止转写请求
const params = {
header: {
name: 'StopTranscription',
namespace: 'SpeechTranscriber',
},
payload: {},
};
wsRef.value.send(JSON.stringify(params));
// // 等待5秒后关闭连接
const timer = setTimeout(() => {
// if (wsRef.value) {
// wsRef.value.close();
// wsRef.value = null;
// }
startIndex = info.list.length;
info.list[info.list.length - 1].isComplete = true;
clearTimeout(timer);
}, 1000);
}
isRecording.value = false;
showToast.success('录音已停止', {
duration: 2000,
cover: true,
'custom-class': 'ipad-toast',
});
};
// 方法2:使用 scrollTo() 方法(更简洁)
const scrollToBottom = (element: HTMLElement) => {
if (!element) return;
// console.log(element, element.scrollHeight, element.clientHeight);
// 滚动到底部(scrollHeight 是内容总高度,clientHeight 是可视区域高度)
element.scrollTop = element.scrollHeight - element.clientHeight;
};
const stopBtnClick = () => {
stopRecording();
};
const startBtnClick = () => {
startRecording();
};
/**
* @description: 结束
* @return {*}
*/
const endVisible = ref(false);
const endTimeVisible = ref(false);
const isEnd = ref<boolean>(false); //结束
const opendEndAudio = () => {
endVisible.value = true;
};
const opendTimeEndAudio = () => {
endTimeVisible.value = true;
};
const endAudio = async () => {
isEnd.value = true;
stopRecording();
showToast.hide();
showToast.loading('保存内容中', {
duration: 2000,
cover: true,
'custom-class': 'ipad-toast',
});
// ai 停止会议
await endMeetAi();
// ai总结
let two = await sumMeetAi();
// 景天结束会议
clearInterval(timer);
await endMeetOmp(two);
showToast.success('保存成功', {
'custom-class': 'ipad-toast',
});
localStorage.removeItem('meetBox');
let go = setTimeout(() => {
router.replace({
path: '/ipad/meeting/summarizeRouter',
query: {
meetId: meetId,
},
});
clearTimeout(go);
}, 1000);
// try {
// // ai总结
// let two = await sumMeetAi();
// showToast.success('AI总结成功');
// // 信息保存到景天
// console.log('two', two);
// // await editMeet()
// } catch (error) {
// showToast.fail('AI总结失败,请到记录详情中重试');
// }
//
};
/*---------------------------------右边交互---------------------------------------*/
const rightActive = ref<number[]>([1, 2, 3]);
const changeSign = (val: number) => {
let index = rightActive.value.indexOf(val);
if (index === -1) {
rightActive.value.push(val);
} else {
rightActive.value.splice(index, 1);
}
};
/*------------------------------------------------------------------------*/
// 路由导航守卫:处理路由跳转时的提醒
const removeRouteGuard = router.beforeEach((to, from, next) => {
// 开始录音 未结束
if (isRecording.value && !isEnd.value) {
showDialog({
title: '温馨提醒',
content: '会议尚未结束,确定要结束会议并离开吗?',
onOk: () => {
endAudio();
},
onCancel: () => {
next(false); // 取消离开
},
});
} else {
next(); // 已保存,直接离开
}
});
// 浏览器窗口关闭/刷新时的提醒
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isRecording.value && !isEnd.value) {
// 标准浏览器会显示默认提示语,自定义文字可能不生效
e.preventDefault();
e.returnValue = '会议尚未结束,确定要离开吗?';
return e.returnValue;
}
};
// 注册事件监听
window.addEventListener('beforeunload', handleBeforeUnload);
//----------------------------------------------------------
onBeforeMount(() => {});
let mainBox: HTMLElement = null;
onMounted(() => {
mainBox = document.getElementById('mainBox');
init();
});
// 组件卸载时清理资源
onDeactivated(() => {
// 移除路由守卫
removeRouteGuard();
// 移除窗口事件监听
window.removeEventListener('beforeunload', handleBeforeUnload);
stopRecording();
clearInterval(timer);
});
</script>
<style lang="scss" scoped>
.box {
background: #dce9fe;
}
.main {
margin: 10px 40px 20px 40px;
background: #fff;
border-radius: 7px;
}
.taking {
width: 27px;
}
.signBox {
img {
width: 32px;
margin-left: 10px;
margin-bottom: 6px;
}
}
.left-main {
border-right: 1px solid #eff0f1;
}
.taking-text {
background: #f9f9f9;
padding: 6px 9px;
display: inline-block;
margin-bottom: 10px;
border-radius: 4px;
}
.btn-box {
background: #ffffff;
box-shadow: 0px -1px 10px 0px rgba(0, 0, 0, 0.06);
padding: 12px 28px;
}
.vedioMp3 {
width: 31px;
margin-right: 16px;
}
.stop-btn {
width: 75px;
height: 41px;
background: #dbe4ff;
border-radius: 42px;
text-align: center;
line-height: 42px;
}
.start-btn {
width: 75px;
height: 41px;
background: #4070ff;
border-radius: 42px;
text-align: center;
margin-right: 10px;
img {
width: 14px;
margin-top: 13.5px;
}
.start {
width: 10px;
margin-left: 5px;
}
:deep(.nut-icon) {
width: 40px !important;
}
}
.right-main {
padding: 20px 0px 12px 24px;
}
.active {
font-size: $c3;
font-weight: 500;
padding: 0 0 4px 0;
border-bottom: 3px solid #487ffb;
}
:deep(.nut-icon) {
width: 10px;
vertical-align: -2px;
margin-left: 2px;
}
.down-data {
width: 213px;
height: 160px;
background: #ffffff;
box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 24px 17px;
:deep(.nut-icon) {
width: 16px;
vertical-align: -2px;
margin-left: 2px;
}
}
.toDoLoSmall {
width: 11px;
vertical-align: -3px;
margin-left: 2px;
margin-right: 9px;
}
.problemSmall {
width: 15px;
vertical-align: -1px;
margin-right: 8px;
}
.signSmall {
width: 14px;
vertical-align: -1px;
margin-right: 8px;
}
.btn-right-box {
width: 90%;
height: 41px;
background: #4070ff;
border-radius: 100px;
font-weight: 600;
font-size: 18px;
color: #ffffff;
line-height: 41px;
text-align: center;
}
:deep(.nut-dialog__footer .nut-button .nut-button__wrap) {
line-height: 32px !important;
}
.curDate {
display: inline-block;
width: 190px;
}
</style>
更多推荐



所有评论(0)