chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,459 @@
<template>
<!-- :hover-class="currentState === 'recording' ? 'hover-class' : ''" -->
<button class="voice-recorder-container"
hover-class="hover-class"
hover-start-time="50"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="cancelRecording"
@touchmove="handleTouchMove">
<!-- 点击时隐藏按钮文字 -->
<view v-show="!isTapped">
<!-- 状态提示文字 -->
<text class="hover-text" v-if="currentState === 'default'">{{
t("Mobile.Voice.holdToTalk")
}}</text>
<view class="icon-loading-text" v-else-if="currentState === 'uploading'">
<image
class="icon-loading-image"
src="@/static/assets/icon_loading.svg"
alt=""
:show-menu-by-longpress="false"
@longtap.stop.prevent
@contextmenu.stop.prevent="true"
mode="widthFix" />
<text class="loading-text">{{ t("Mobile.Voice.recognizing") }}</text>
</view>
</view>
</button>
</template>
<script lang="uts" setup>
// #ifdef H5
import jzRecorder from '@/uni_modules/jz-h5-recorder-manager'
// #endif
import {
createAudioUploader,
type AudioFile as UploaderAudioFile,
type TranscriptResult as UploaderTranscriptResult,
type UploadProgress
} from '@/servers/audioUploader'
import { MicrophonePermissionHelper, type PermissionState } from '@/utils/permissionHelper'
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 组件Props类型定义
interface VoiceRecorderProps {
// 录音配置
duration?: number // 最大录音时长 (ms)
format?: string // 音频格式
sampleRate?: number // 采样率
// 会话是否正在进行中(有消息正在处理)
isConversationActive?: boolean
}
// 录音状态类型
type RecordState = 'default' | 'recording' | 'uploading' | 'success' | 'error'
// 音频文件接口
interface AudioFile {
tempFilePath: string
duration: number
fileSize: number
format?: string
}
// 转换结果接口
interface TranscriptResult {
success: boolean
text: string
confidence: number
duration: number
error?: string
}
const props = withDefaults(defineProps<VoiceRecorderProps>(), {
duration: 600000, // 录音时长10分钟
format: 'mp3', // 录音格式
sampleRate: 44100, // 采样率
isConversationActive: false // 会话是否正在进行中
})
const emit = defineEmits<{
// 事件回调
onRecordStart?: () => void,
onRecordStop?: (result: string) => void,
onError?: (error: any) => void,
onUploadProgress?: (progress: number) => void
// onCancelChange?: (isCancelled: boolean) => void
onRecordCancel?: () => void
onRecordEnd?: () => void
}>()
// 响应式状态
const currentState = ref<RecordState>('default')
const isCancelled = ref<boolean>(false)
// 是否点击
const isTapped = ref<boolean>(false)
// 录音管理器
let recorderManager: any = null
// 音频上传服务
let audioUploadService: any = null // 使用audioUploader
// 重置状态
const resetState = () => {
currentState.value = 'default'
isCancelled.value = false
isTapped.value = false
}
// 录音错误
const handleRecordError = (err: any) => {
resetState()
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
emit('onError', err)
}
// 开始录音
const handleTouchStart = async (e) => {
console.log('开始录音handleTouchStart')
e.preventDefault() // Prevent default to avoid context menu
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
if (props.isConversationActive) {
emit("onError", new Error(t("Mobile.Voice.conversationActiveError")));
return
}
if (currentState.value !== 'default') {
// emit('onError', new Error('请先停止当前录音' + currentState.value))
return
}
isTapped.value = true;
uni.showLoading({
title: t("Mobile.Voice.connecting"),
})
// 开始录音
if (recorderManager) {
try {
recorderManager.start({
duration: Math.min(props.duration, 600000), // 小程序最大录音时长限制为10分钟
sampleRate: props.sampleRate,
numberOfChannels: 1,
encodeBitRate: 192000,
// #ifdef H5
format: props.format || 'mp3' // 小程序只支持mp3和aac
// #endif
// #ifdef MP-WEIXIN
format: 'mp3'
// #endif
})
} catch (error: any) {
uni.hideLoading()
resetState()
emit('onError', error)
}
}
}
// 监听录音开始
const handleRecordStart = () => {
console.log('录音开始handleRecordStart')
// 设置状态,开始录音,并触发事件
currentState.value = 'recording'
emit('onRecordStart')
uni.hideLoading()
}
// 录音中结束
const handleTouchEnd = () => {
console.log('录音中结束handleTouchEnd')
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = true;
}
// #endif
isTapped.value = false;
// 如果正在上传,则不停止录音
if (currentState.value === 'uploading') {
return
}
if (isCancelled.value) {
cancelRecording()
} else {
stopRecording()
}
}
// 取消录音
const cancelRecording = () => {
if (recorderManager) {
recorderManager.stop()
}
resetState()
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
emit('onRecordCancel')
}
// 录音停止
const handleRecordStop = async (res: any) => {
console.log('录音停止handleRecordStop')
// 录音总时长H5端单位秒小程序端单位毫秒, 后续判断时长是否超过10分钟是以秒为单位所以小程序端需要将毫秒转为秒
let duration = res.duration || 0
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
duration = duration / 1000
// #endif
// 小程序端的录音结果格式可能与H5端不同需要统一处理
const audioFile: AudioFile = {
tempFilePath: res.tempFilePath, // 录音文件的临时路径
duration,
fileSize: res.fileSize || 0, // 录音文件大小,单位字节
format: res.format || 'mp3'
}
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
// 立即显示上传中状态,给用户明确反馈
currentState.value = 'uploading'
try {
// 使用音频上传服务转换音频
const result = await uploadAndConvertWithService(audioFile)
if (result.success) {
currentState.value = 'success'
emit('onRecordStop', result.text)
// 重置状态
resetState()
} else {
throw new Error(result?.text || t("Mobile.Voice.noSpeechDetected"))
}
} catch (error: any) {
handleRecordError(error)
}
}
// 停止录音
const stopRecording = () => {
if (recorderManager) {
try {
recorderManager.stop()
emit('onRecordEnd')
} catch (error: any) {
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
currentState.value = 'error'
emit('onError', error)
}
}
// 重置状态
resetState()
}
// 使用audioUploader转换音频
const uploadAndConvertWithService = async (audioFile: AudioFile): Promise<TranscriptResult> => {
if (!audioUploadService) {
throw new Error(t("Mobile.Voice.serviceNotReady"))
}
try {
// 转换音频文件格式
const uploaderAudioFile: UploaderAudioFile = {
tempFilePath: audioFile.tempFilePath,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
format: audioFile.format
}
// 调用audioUploader的上传方法
const result = await audioUploadService.uploadAudio(uploaderAudioFile)
// 转换结果格式
return {
success: result.success,
text: result.text,
}
} catch (error: any) {
return {
success: false,
text: error.message,
}
}
}
// 初始化录音事件监听
const initRecorderEvents = async() => {
if (!recorderManager) return
// 录音开始
recorderManager.onStart(handleRecordStart)
// 录音停止
recorderManager.onStop(handleRecordStop)
// 录音错误
recorderManager.onError(handleRecordError)
}
const initRecorderManager = () => {
// #ifdef H5
// 获取录音管理器
recorderManager = jzRecorder.getRecorderManager()
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
// 获取录音管理器
recorderManager = uni.getRecorderManager()
// #endif
// 监听录音事件
initRecorderEvents()
// 初始化音频上传服务
audioUploadService = createAudioUploader()
}
// 初始化录音管理器
onShow(() => {
// #ifdef MP-WEIXIN
initRecorderManager()
// #endif
})
// 初始化录音管理器
onMounted(() => {
initRecorderManager()
})
// 销毁录音管理器等资源
const destroyRecorderManager = () => {
resetState()
if (recorderManager) {
// 清理所有事件监听器
/**
* App/H5端, 支持offStart / offStop / offError 方法解绑回调函数,小程序不支持解绑回调函数,所以需要手动清理事件监听器
*/
// #ifdef H5
if (recorderManager.offStart) {
recorderManager.offStart(handleRecordStart)
}
if (recorderManager.offStop) {
recorderManager.offStop(handleRecordStop)
}
if (recorderManager.offError) {
recorderManager.offError(handleRecordError)
}
// #endif
recorderManager = null
}
audioUploadService = null
}
// 组件卸载时清理资源
onHide(() => {
// #ifdef MP-WEIXIN
destroyRecorderManager()
// #endif
})
// 组件卸载时清理资源
onUnmounted(() => {
destroyRecorderManager()
})
// Add touchmove handler to the button
const handleTouchMove = (e) => {
e.preventDefault();
}
</script>
<style lang="scss" scoped>
.voice-recorder-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 90rpx;
// height: 112rpx;
border-radius: 24rpx;
transition: all 0.15s ease;
-webkit-touch-callout: none;
color: #000;
font-size: 32rpx;
font-weight: 400;
background-color: transparent;
padding: 0; // 删除uni-button默认padding
user-select: none;
-webkit-user-select: none;
touch-action: none; /* 防止系统手势干预 */
border: none;
&::after {
border: none !important;
}
.hover-text {
color: #000;
}
&.hover-class {
background-color: rgb(108, 101, 244);
z-index: 100;
.hover-text {
color: #fff;
}
}
.icon-loading-text {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
user-select: none;
.icon-loading-image {
width: 48rpx;
height: 48rpx;
margin-right: 10rpx;
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.loading-text {
color: #666;
}
}
}
</style>