feat: implement voice input feature with press-and-hold gesture
- Added new language strings for voice input functionality in English and Chinese. - Updated manifest to include microphone permission for voice recording. - Enhanced ai-chat-message component to support voice input with visual feedback for recording and cancellation gestures. - Integrated voice recorder functionality in ai-chat-voice-panel, allowing users to record audio with a maximum duration of 60 seconds. - Implemented audio handling logic to upload recorded audio files and display them in the chat interface. - Added error handling for voice recording failures and user notifications. - Updated styles for improved UI/UX during voice recording interactions.
This commit is contained in:
522
common/js/voice-recorder.js
Normal file
522
common/js/voice-recorder.js
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* voice-recorder.js — 跨端录音抽象层
|
||||
*
|
||||
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
|
||||
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
|
||||
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
|
||||
*
|
||||
* 平台分支:
|
||||
* - H5 : navigator.mediaDevices.getUserMedia + MediaRecorder,
|
||||
* 走 AudioContext + AnalyserNode 采样振幅
|
||||
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
|
||||
* 走 onFrameRecorded 推 progress 事件
|
||||
*
|
||||
* 统一事件:
|
||||
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
|
||||
* error → Error 录音失败
|
||||
*
|
||||
* 统一结果 (stop() 解析):
|
||||
* {
|
||||
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
|
||||
* blob?: Blob, // H5: 原始 Blob (供 FileReader / uploadMedia 二次处理)
|
||||
* fileName?: string, // H5: 自动生成的 .webm/.ogg/.mp4 文件名
|
||||
* duration: number, // ms
|
||||
* mime: string, // 录音产物 MIME
|
||||
* size: number, // bytes (H5 = blob.size; MP 在 onStop 给出)
|
||||
* cancelled: false
|
||||
* }
|
||||
*
|
||||
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
|
||||
*/
|
||||
|
||||
// H5 录音 MIME 偏好顺序 (从高到低)
|
||||
const H5_MIME_PREFERENCES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
'audio/mp4'
|
||||
]
|
||||
|
||||
const DEFAULT_MAX_DURATION_MS = 60 * 1000
|
||||
|
||||
/**
|
||||
* 构造带 code 字段的 Error
|
||||
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
|
||||
* - message: 兜底英文 message, 用于开发/调试或未识别 code
|
||||
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
|
||||
*/
|
||||
function _err(code, message) {
|
||||
const e = new Error(message || code)
|
||||
e.code = code
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前运行环境 (避免编译器把所有分支都打进去)
|
||||
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
|
||||
*/
|
||||
function isH5() {
|
||||
// #ifdef H5
|
||||
return true
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
|
||||
function isMpOrApp() {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||
return true
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 选取浏览器实际支持的 MediaRecorder MIME
|
||||
* 兜底用空字符串 → MediaRecorder 用浏览器默认编码
|
||||
*/
|
||||
function pickH5Mime() {
|
||||
if (typeof MediaRecorder === 'undefined') return ''
|
||||
for (const m of H5_MIME_PREFERENCES) {
|
||||
try {
|
||||
if (MediaRecorder.isTypeSupported(m)) return m
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 mime 反推文件扩展名
|
||||
*/
|
||||
function mimeToExt(mime) {
|
||||
if (!mime) return 'webm'
|
||||
if (mime.indexOf('webm') !== -1) return 'webm'
|
||||
if (mime.indexOf('ogg') !== -1) return 'ogg'
|
||||
if (mime.indexOf('mp4') !== -1) return 'm4a'
|
||||
if (mime.indexOf('mpeg') !== -1) return 'mp3'
|
||||
if (mime.indexOf('wav') !== -1) return 'wav'
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建录音器实例。
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
|
||||
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
|
||||
* @param {number} [opts.sampleRate=16000]
|
||||
* @param {number} [opts.numberOfChannels=1]
|
||||
* @param {number} [opts.encodeBitRate=48000]
|
||||
*
|
||||
* @returns {VoiceRecorder}
|
||||
*/
|
||||
export function createVoiceRecorder(opts = {}) {
|
||||
return new VoiceRecorder(opts)
|
||||
}
|
||||
|
||||
class VoiceRecorder {
|
||||
constructor(opts) {
|
||||
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
||||
this._format = opts.format || 'mp3'
|
||||
this._sampleRate = opts.sampleRate || 16000
|
||||
this._channels = opts.numberOfChannels || 1
|
||||
this._bitRate = opts.encodeBitRate || 48000
|
||||
|
||||
this._listeners = { progress: [], error: [] }
|
||||
this._isRecording = false
|
||||
this._startTs = 0
|
||||
this._autoStopTimer = null
|
||||
|
||||
// 平台相关句柄
|
||||
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||
}
|
||||
|
||||
on(event, cb) {
|
||||
if (!this._listeners[event]) this._listeners[event] = []
|
||||
this._listeners[event].push(cb)
|
||||
}
|
||||
|
||||
off(event, cb) {
|
||||
const arr = this._listeners[event]
|
||||
if (!arr) return
|
||||
const idx = arr.indexOf(cb)
|
||||
if (idx !== -1) arr.splice(idx, 1)
|
||||
}
|
||||
|
||||
_emit(event, payload) {
|
||||
const arr = this._listeners[event]
|
||||
if (!arr) return
|
||||
for (const cb of arr.slice()) {
|
||||
try { cb(payload) } catch (e) { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
get isRecording() { return this._isRecording }
|
||||
|
||||
/**
|
||||
* 开始录音。
|
||||
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
|
||||
*/
|
||||
start() {
|
||||
if (this._isRecording) {
|
||||
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
||||
}
|
||||
|
||||
if (isH5()) {
|
||||
return this._startH5()
|
||||
}
|
||||
if (isMpOrApp()) {
|
||||
return this._startUni()
|
||||
}
|
||||
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音并返回产物。
|
||||
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||
*/
|
||||
stop() {
|
||||
if (!this._isRecording) return Promise.resolve(null)
|
||||
if (isH5()) return this._stopH5()
|
||||
return this._stopUni()
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断录音并丢弃产物。
|
||||
*/
|
||||
cancel() {
|
||||
if (!this._isRecording) {
|
||||
this._clearAutoStopTimer()
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (isH5()) return this._cancelH5()
|
||||
return this._cancelUni()
|
||||
}
|
||||
|
||||
// ===================== H5 实现 =====================
|
||||
|
||||
_startH5() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
|
||||
return
|
||||
}
|
||||
const mime = pickH5Mime()
|
||||
const chunks = []
|
||||
let mediaRecorder = null
|
||||
let stream = null
|
||||
let audioCtx = null
|
||||
let analyser = null
|
||||
let rafId = null
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then((s) => {
|
||||
stream = s
|
||||
try {
|
||||
mediaRecorder = mime
|
||||
? new MediaRecorder(stream, { mimeType: mime })
|
||||
: new MediaRecorder(stream)
|
||||
} catch (e) {
|
||||
try { stream.getTracks().forEach(t => t.stop()) } catch (_) {}
|
||||
reject(e)
|
||||
return
|
||||
}
|
||||
|
||||
mediaRecorder.ondataavailable = (ev) => {
|
||||
if (ev.data && ev.data.size > 0) chunks.push(ev.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onerror = (ev) => {
|
||||
const err = (ev && ev.error) || new Error('MediaRecorder error')
|
||||
this._emit('error', err)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// 收到 onstop 才算真正的 stop 完成,产物在 _stopH5 的 promise 里组装
|
||||
try {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (analyser) analyser.disconnect()
|
||||
if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {})
|
||||
if (stream) stream.getTracks().forEach(t => t.stop())
|
||||
} catch (e) { /* noop */ }
|
||||
|
||||
this._h5 = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
|
||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || mime || 'audio/webm' })
|
||||
const finalMime = blob.type || (mime || 'audio/webm')
|
||||
const fileName = `voice_${Date.now()}.${mimeToExt(finalMime)}`
|
||||
// 把产物挂回实例,供 _stopH5 的 promise 读取
|
||||
this._h5PendingResult = {
|
||||
blob,
|
||||
mime: finalMime,
|
||||
size: blob.size,
|
||||
duration: Date.now() - this._startTs,
|
||||
fileName
|
||||
}
|
||||
if (this._h5Resolve) {
|
||||
const r = this._h5Resolve
|
||||
this._h5Resolve = null
|
||||
r(this._h5PendingResult)
|
||||
}
|
||||
}
|
||||
|
||||
// AudioContext + AnalyserNode 采样振幅 (驱动波形)
|
||||
try {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||
if (Ctx) {
|
||||
audioCtx = new Ctx()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
const tick = () => {
|
||||
if (!this._isRecording) return
|
||||
analyser.getByteFrequencyData(data)
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) sum += data[i]
|
||||
const level = Math.min(1, sum / (data.length * 255))
|
||||
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
}
|
||||
} catch (e) {
|
||||
// AudioContext 创建失败不影响录音本身
|
||||
console.warn('[voice-recorder] AudioContext 初始化失败:', e)
|
||||
}
|
||||
|
||||
this._h5 = { mediaRecorder, stream, audioCtx, analyser, rafId, mime }
|
||||
this._isRecording = true
|
||||
this._startTs = Date.now()
|
||||
this._setAutoStopTimer()
|
||||
try {
|
||||
mediaRecorder.start(250) // 每 250ms 触发一次 ondataavailable
|
||||
} catch (e) {
|
||||
// 启动失败, 主动清理资源并回滚状态
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
try {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (analyser) analyser.disconnect()
|
||||
if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {})
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
} catch (e2) { /* noop */ }
|
||||
this._h5 = null
|
||||
reject(e)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
_stopH5() {
|
||||
return new Promise((resolve) => {
|
||||
const h5 = this._h5
|
||||
if (!h5 || !h5.mediaRecorder) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
// onstop 回调里会 set _h5PendingResult 并调用 _h5Resolve
|
||||
this._h5Resolve = resolve
|
||||
try { h5.mediaRecorder.stop() } catch (e) { resolve(null) }
|
||||
}).then((result) => {
|
||||
if (!result) return null
|
||||
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
|
||||
? URL.createObjectURL(result.blob)
|
||||
: ''
|
||||
return {
|
||||
filePath: blobUrl,
|
||||
blob: result.blob,
|
||||
fileName: result.fileName,
|
||||
duration: result.duration,
|
||||
mime: result.mime,
|
||||
size: result.size,
|
||||
cancelled: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_cancelH5() {
|
||||
return new Promise((resolve) => {
|
||||
const h5 = this._h5
|
||||
if (!h5 || !h5.mediaRecorder) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this._h5Resolve = (result) => {
|
||||
// 取消时丢掉 blob URL, 但产物本身已不再使用
|
||||
if (result && result.blob) {
|
||||
// Blob 没有显式 release 接口, 浏览器 GC 即可
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
try { h5.mediaRecorder.stop() } catch (e) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
||||
|
||||
_startUni() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let rec = null
|
||||
try {
|
||||
rec = uni.getRecorderManager()
|
||||
} catch (e) {
|
||||
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||
return
|
||||
}
|
||||
if (!rec) {
|
||||
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
this._uniRec = rec
|
||||
|
||||
rec.onStart(() => {
|
||||
this._isRecording = true
|
||||
this._startTs = Date.now()
|
||||
this._setAutoStopTimer()
|
||||
resolve()
|
||||
})
|
||||
|
||||
rec.onStop((res) => {
|
||||
// res = { tempFilePath, duration, fileSize }
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
const mime = this._format === 'wav' ? 'audio/wav'
|
||||
: this._format === 'aac' ? 'audio/aac'
|
||||
: 'audio/mpeg'
|
||||
if (this._uniResolve) {
|
||||
const r = this._uniResolve
|
||||
this._uniResolve = null
|
||||
r({
|
||||
filePath: res.tempFilePath,
|
||||
blob: null,
|
||||
fileName: '',
|
||||
duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs),
|
||||
mime,
|
||||
size: res.fileSize || 0,
|
||||
cancelled: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rec.onError((err) => {
|
||||
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
this._emit('error', e)
|
||||
if (this._uniResolve) {
|
||||
const r = this._uniResolve
|
||||
this._uniResolve = null
|
||||
r(null)
|
||||
} else {
|
||||
// 启动阶段的 reject
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
||||
if (typeof rec.onFrameRecorded === 'function') {
|
||||
rec.onFrameRecorded((res) => {
|
||||
if (!res || !res.frameBuffer) return
|
||||
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
|
||||
try {
|
||||
const view = new DataView(res.frameBuffer)
|
||||
let sum = 0
|
||||
const len = res.frameBuffer.byteLength
|
||||
const step = Math.max(2, Math.floor(len / 256))
|
||||
for (let i = 0; i < len; i += step) {
|
||||
const v = view.getInt16(i, true)
|
||||
sum += Math.abs(v)
|
||||
}
|
||||
const avg = sum / Math.max(1, Math.floor(len / step))
|
||||
const level = Math.min(1, avg / 16384)
|
||||
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||
} catch (e) { /* noop */ }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
rec.start({
|
||||
duration: this._maxDuration / 1000, // uni-app 单位:秒
|
||||
sampleRate: this._sampleRate,
|
||||
numberOfChannels: this._channels,
|
||||
encodeBitRate: this._bitRate,
|
||||
format: this._format,
|
||||
frameSize: 4 // KB,触发 onFrameRecorded 频率
|
||||
})
|
||||
} catch (e) {
|
||||
this._uniRec = null
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_stopUni() {
|
||||
return new Promise((resolve) => {
|
||||
const rec = this._uniRec
|
||||
if (!rec) { resolve(null); return }
|
||||
this._uniResolve = resolve
|
||||
try { rec.stop() } catch (e) { resolve(null) }
|
||||
})
|
||||
}
|
||||
|
||||
_cancelUni() {
|
||||
return new Promise((resolve) => {
|
||||
const rec = this._uniRec
|
||||
if (!rec) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this._uniResolve = () => resolve()
|
||||
try { rec.stop() } catch (e) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 工具 =====================
|
||||
|
||||
_setAutoStopTimer() {
|
||||
this._clearAutoStopTimer()
|
||||
this._autoStopTimer = setTimeout(() => {
|
||||
// 自动停止 (上限到达)
|
||||
if (this._isRecording) {
|
||||
this.stop().catch(() => { /* swallow */ })
|
||||
}
|
||||
}, this._maxDuration)
|
||||
}
|
||||
|
||||
_clearAutoStopTimer() {
|
||||
if (this._autoStopTimer) {
|
||||
clearTimeout(this._autoStopTimer)
|
||||
this._autoStopTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createVoiceRecorder
|
||||
@@ -136,6 +136,18 @@ export const lang = {
|
||||
recordingTip: 'Recording, release to end',
|
||||
startRecordTip: 'Tap to start recording',
|
||||
voiceInputFailed: 'Voice input failed',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
|
||||
holdToTalk: 'Hold to talk',
|
||||
releaseToSend: 'Release to send',
|
||||
releaseToCancel: 'Release to cancel',
|
||||
slideUpToCancel: '↑ Slide up to cancel',
|
||||
slideUpToCancelHint: '↑ Release to cancel',
|
||||
voiceStartFailed: 'Cannot start recording',
|
||||
voiceStopFailed: 'Recording failed, please try again',
|
||||
voiceNotSupported: 'Voice input is not supported on this platform',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
||||
uploadFailed: 'Upload failed',
|
||||
uploadFailedWithReason: 'Upload failed: {reason}',
|
||||
|
||||
// ====== index.vue - Menu ======
|
||||
menuClearChat: 'Clear Chat',
|
||||
|
||||
@@ -136,6 +136,18 @@ export const lang = {
|
||||
recordingTip: '正在录音,松开结束',
|
||||
startRecordTip: '点击开始录音',
|
||||
voiceInputFailed: '语音输入失败',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
|
||||
holdToTalk: '按住说话',
|
||||
releaseToSend: '松开发送',
|
||||
releaseToCancel: '松开取消',
|
||||
slideUpToCancel: '↑ 上滑取消',
|
||||
slideUpToCancelHint: '↑ 松开取消',
|
||||
voiceStartFailed: '无法开始录音',
|
||||
voiceStopFailed: '录音失败,请重试',
|
||||
voiceNotSupported: '当前平台不支持语音输入',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||
uploadFailed: '上传失败',
|
||||
uploadFailedWithReason: '上传失败:{reason}',
|
||||
|
||||
// ====== index.vue - 菜单 ======
|
||||
menuClearChat: '清空聊天',
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"scope.writePhotosAlbum" : {
|
||||
"desc" : "用于从相册选择和保存头像"
|
||||
},
|
||||
"scope.record" : {
|
||||
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||
|
||||
@@ -418,17 +418,20 @@
|
||||
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
|
||||
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
|
||||
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
|
||||
<!-- 语音模式: 按住说话 -->
|
||||
<!-- 语音模式: 按住说话 (PR-FRONTEND-VOICE-INPUT 2026-09-24) -->
|
||||
<view
|
||||
v-if="voiceMode"
|
||||
class="voice-hold-btn"
|
||||
:class="{ 'is-holding': voiceHolding }"
|
||||
:class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling }"
|
||||
@touchstart.stop.prevent="onVoiceHoldStart"
|
||||
@touchmove.stop.prevent="onVoiceHoldMove"
|
||||
@touchend.stop.prevent="onVoiceHoldEnd"
|
||||
@touchcancel.stop.prevent="onVoiceHoldCancel"
|
||||
@click.stop="onVoiceHoldEnd">
|
||||
<text class="voice-hold-text">{{ voiceHolding ? '松开发送' : '按住说话' }}</text>
|
||||
<text class="voice-hold-text">{{ voiceHoldText }}</text>
|
||||
</view>
|
||||
<!-- 按住时的「上滑取消」提示 (H5/MP 共用) -->
|
||||
<view v-if="voiceHolding" class="voice-cancel-hint">{{ voiceHoldCancelling ? $lang('ai-chat.index.slideUpToCancelHint') : $lang('ai-chat.index.slideUpToCancel') }}</view>
|
||||
|
||||
<view v-else class="input-row-top">
|
||||
<!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
|
||||
@@ -578,6 +581,7 @@
|
||||
<ai-chat-voice-panel
|
||||
:visible.sync="enableVoicePanelShow"
|
||||
@close="onVoicePanelClose"
|
||||
@audio-ready="_handleRecordedAudio"
|
||||
/>
|
||||
|
||||
<!-- 头像选择弹窗 -->
|
||||
@@ -597,7 +601,8 @@
|
||||
</template>
|
||||
<script>
|
||||
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
|
||||
import { uploadImage, fileToBase64 } from '@/common/js/media-upload.js'
|
||||
import { uploadImage, uploadMedia, fileToBase64 } from '@/common/js/media-upload.js'
|
||||
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||
import IMAGE_CONFIG from '@/common/js/image-config.js'
|
||||
import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
|
||||
import AiChatSearch from './components/ai-chat-search.vue'
|
||||
@@ -690,7 +695,14 @@ export default {
|
||||
// voiceMode 语音模式(按住说话) / voiceHolding 按住中 / inputFocused 输入框是否聚焦
|
||||
voiceMode: false,
|
||||
voiceHolding: false,
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上滑取消手势标志
|
||||
voiceHoldCancelling: false,
|
||||
inputFocused: false,
|
||||
// 录音器句柄 + 振幅 (供可视化/调试; UI 现在没用到 voiceLevel, 留作后续接入动画)
|
||||
_voiceRecorder: null,
|
||||
_voiceHoldStartY: 0,
|
||||
_voiceHoldLastLevel: 0,
|
||||
voiceLevel: 0,
|
||||
// 失焦定时器: 用于延迟收起文字输入态, 避免 H5 布局变化触发的瞬时 blur 误收起
|
||||
_blurTimer: null,
|
||||
// 平台相关: H5 让浏览器自动把输入框顶起(adjust-position=true), 小程序用键盘垫高自行处理
|
||||
@@ -843,6 +855,12 @@ export default {
|
||||
// 发送按钮可用态 (无内容时按钮保留但置灰)
|
||||
canSend() {
|
||||
return this.hasInputText || this.canSendPendingImage || this.canSendPendingFile
|
||||
},
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话按钮文案 (三态)
|
||||
voiceHoldText() {
|
||||
if (this.voiceHoldCancelling) return this.$lang('ai-chat.index.releaseToCancel')
|
||||
if (this.voiceHolding) return this.$lang('ai-chat.index.releaseToSend')
|
||||
return this.$lang('ai-chat.index.holdToTalk')
|
||||
}
|
||||
},
|
||||
// 新增:组件销毁时清除所有定时器
|
||||
@@ -872,6 +890,8 @@ export default {
|
||||
try { this.innerAudioCtx.stop(); this.innerAudioCtx.destroy(); } catch (e) {}
|
||||
this.innerAudioCtx = null;
|
||||
}
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 销毁前丢弃语音录音器 (Stream/MediaRecorder/RecorderManager)
|
||||
this._discardVoiceRecorder && this._discardVoiceRecorder()
|
||||
},
|
||||
created() {
|
||||
this.messages = [...this.initialMessages]
|
||||
@@ -1495,17 +1515,36 @@ async loadChatHistoryIfExist() {
|
||||
})
|
||||
|
||||
// 待发送文件/视频/音频附件 (与图片一并带出给后端)
|
||||
const fileAttachments = pendingFilesList
|
||||
.filter(p => p.uploaded)
|
||||
.map(p => ({
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
|
||||
// p.uploaded.kind 是后端响应 (image/file/audio/video); p.type 是前端分类
|
||||
type: p.uploaded.kind || p.type || 'file',
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size
|
||||
}))
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): audio 也补 base64 内联, 走与图片同样的
|
||||
// backend MediaGuard 旁路 (≤5MB 时直接送 data+encoding, 不依赖 URL 公网可达)。
|
||||
// 这与图片的 enrichedPendingAttachments 行为对齐, 后端 WbAgentOsClient::buildMediaBlocks
|
||||
// 已经按 docs §3.1.5 处理 audio + data 内联。
|
||||
const fileAttachments = await Promise.all(
|
||||
pendingFilesList
|
||||
.filter(p => p.uploaded)
|
||||
.map(async (p) => {
|
||||
const base = {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
|
||||
// p.uploaded.kind 是后端响应 (image/file/audio/video); p.type 是前端分类
|
||||
type: p.uploaded.kind || p.type || 'file',
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size
|
||||
}
|
||||
// audio 走 inline-base64 路径 (≤5MB) → 避免 LAN URL 被 MediaGuard SSRF 闸门拦截
|
||||
if (base.type === 'audio' && p.tempPath) {
|
||||
try {
|
||||
const b64 = await fileToBase64(p.tempPath, { mimeHint: p.uploaded.mime })
|
||||
if (b64 && b64.data) {
|
||||
base.data = b64.data
|
||||
base.encoding = 'base64'
|
||||
}
|
||||
} catch (e) { /* skip data 字段, 退回 URL 路径 */ }
|
||||
}
|
||||
return base
|
||||
})
|
||||
)
|
||||
|
||||
// 输入框待发送图片: 全部 push, 第一张作为 userMessage.imageUrl 显示
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 同时给附件补 data: <base64> 字段,
|
||||
@@ -1563,6 +1602,8 @@ async loadChatHistoryIfExist() {
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
kind: a.type || 'file',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): audio 内联 base64 一并塞到消息附件, 模板播放不会用, 仅给后端 Agent 复用
|
||||
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
|
||||
})),
|
||||
]
|
||||
const userMessage = firstPendingImage
|
||||
@@ -3209,6 +3250,7 @@ _handleStreamError(msg) {
|
||||
try { uni.hideKeyboard() } catch (e) { /* noop */ }
|
||||
this.voiceMode = true
|
||||
this.voiceHolding = false
|
||||
this.voiceHoldCancelling = false
|
||||
this.inputFocused = false
|
||||
},
|
||||
|
||||
@@ -3216,24 +3258,199 @@ _handleStreamError(msg) {
|
||||
exitVoiceMode() {
|
||||
this.voiceMode = false
|
||||
this.voiceHolding = false
|
||||
this.voiceHoldCancelling = false
|
||||
// 退出时若还在录音,主动作废
|
||||
this._discardVoiceRecorder()
|
||||
},
|
||||
|
||||
// 按住说话: 按下 (文案切"松开发送" + 高亮)
|
||||
onVoiceHoldStart() {
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话
|
||||
// - touchstart 启动录音器
|
||||
// - touchmove 上滑 80rpx+ 视为取消手势
|
||||
// - touchend 提交产物 (取消则丢弃)
|
||||
// - H5 鼠标不会触发 touch, click 也走 onVoiceHoldEnd 兜底 (无录音产物就当没按住)
|
||||
onVoiceHoldStart(e) {
|
||||
if (this._voiceRecorder) return
|
||||
this.voiceHolding = true
|
||||
this.voiceHoldCancelling = false
|
||||
try {
|
||||
const touch = (e && e.touches && e.touches[0]) || null
|
||||
this._voiceHoldStartY = touch ? touch.clientY : 0
|
||||
} catch (err) { this._voiceHoldStartY = 0 }
|
||||
|
||||
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
||||
this._voiceRecorder = rec
|
||||
this._onHoldProgress = ({ level }) => {
|
||||
// 平滑 (用于后续波形/振幅 UI)
|
||||
this._voiceHoldLastLevel = this._voiceHoldLastLevel * 0.4 + level * 0.6
|
||||
this.voiceLevel = this._voiceHoldLastLevel
|
||||
}
|
||||
this._onHoldError = (err) => {
|
||||
console.warn('[ai-chat] voice hold error:', err)
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
this._discardVoiceRecorder()
|
||||
}
|
||||
rec.on('progress', this._onHoldProgress)
|
||||
rec.on('error', this._onHoldError)
|
||||
|
||||
rec.start()
|
||||
.catch((err) => {
|
||||
// 启动失败 (无权限/无设备/被用户拒绝)
|
||||
this._discardVoiceRecorder()
|
||||
this.voiceHolding = false
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
// 按住说话: 松开
|
||||
// 项目当前没有录音 + 语音识别实现, 松开后复用原有「语音输入」面板入口, 保证功能不丢。
|
||||
// H5 鼠标(桌面预览)不会触发 touch 事件, 所以 click 也绑到同一个方法; showVoicePanel 幂等, 重复调用无副作用。
|
||||
onVoiceHoldEnd() {
|
||||
onVoiceHoldMove(e) {
|
||||
if (!this.voiceHolding) return
|
||||
try {
|
||||
const touch = (e && e.touches && e.touches[0]) || null
|
||||
if (!touch) return
|
||||
// 手指上移 = 远离用户, deltaY 为正
|
||||
const deltaY = this._voiceHoldStartY - touch.clientY
|
||||
// 阈值 80px (≈80rpx on H5);超过则进入取消态
|
||||
if (deltaY > 80 && !this.voiceHoldCancelling) this.voiceHoldCancelling = true
|
||||
else if (deltaY <= 80 && this.voiceHoldCancelling) this.voiceHoldCancelling = false
|
||||
} catch (err) { /* noop */ }
|
||||
},
|
||||
|
||||
async onVoiceHoldEnd() {
|
||||
if (!this.voiceHolding) {
|
||||
// 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发
|
||||
return
|
||||
}
|
||||
const wasCancelling = this.voiceHoldCancelling
|
||||
this.voiceHolding = false
|
||||
this.showVoicePanel()
|
||||
this.voiceHoldCancelling = false
|
||||
|
||||
if (!this._voiceRecorder) {
|
||||
// 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢
|
||||
this.showVoicePanel()
|
||||
return
|
||||
}
|
||||
if (wasCancelling) {
|
||||
// 取消手势: 主动作废, 不发 audio-ready
|
||||
this._discardVoiceRecorder()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await this._voiceRecorder.stop()
|
||||
this._teardownVoiceRecorder()
|
||||
if (!result || !result.filePath) {
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
await this._handleRecordedAudio(result)
|
||||
} catch (e) {
|
||||
this._teardownVoiceRecorder()
|
||||
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 按住说话: 手势被系统打断 (来电/滑动取消)
|
||||
onVoiceHoldCancel() {
|
||||
if (!this.voiceHolding) return
|
||||
this.voiceHolding = false
|
||||
this.voiceHoldCancelling = false
|
||||
this._discardVoiceRecorder()
|
||||
},
|
||||
|
||||
// ============== PR-FRONTEND-VOICE-INPUT: 录音产物处理 ==============
|
||||
|
||||
/**
|
||||
* 通用入口: 弹窗 / 按住说话 都把产物塞进来
|
||||
* result = { filePath, blob, fileName, duration, mime, size }
|
||||
* 负责: push 进 pendingFiles → uploadMedia 上传 → 显示在输入区
|
||||
*/
|
||||
async _handleRecordedAudio(result) {
|
||||
if (!result || !result.filePath) return
|
||||
// H5 下 filePath 是 blob URL, 用 blob URL 当预览 (uni-app 在 H5 上传走 uni.uploadFile 时会转)
|
||||
// MP/APP/鸿蒙下 filePath 是 tempFilePath
|
||||
const pending = {
|
||||
localId: 'voice_' + Date.now() + '_' + Math.floor(Math.random() * 1e6),
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'audio',
|
||||
content: '',
|
||||
fileName: result.fileName || ('voice_' + Date.now() + (result.mime ? '.' + (result.mime.split('/')[1] || 'mp3') : '.mp3')),
|
||||
fileSize: result.size || 0,
|
||||
fileType: result.mime || '',
|
||||
// H5: blob URL (供 audio 标签预览); MP: tempFilePath (InnerAudioContext 也支持)
|
||||
url: result.filePath,
|
||||
// PR-FRONTEND-VOICE-INPUT: 录音产物需要被 fileToBase64 读取 → 保留 record 原始路径 (blob URL 在 H5 也能 fetch)
|
||||
tempPath: result.filePath,
|
||||
// 录音原始 Blob (H5),用于 fileToBase64 直接 fetch blob: URL;
|
||||
// 非 H5 平台为 null, fileToBase64 走 fsm.readFileSync(filePath, 'base64')
|
||||
_recorderBlob: result.blob || null,
|
||||
duration: result.duration || 0,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
imageUrl: result.filePath,
|
||||
uploading: true,
|
||||
uploadError: null,
|
||||
uploaded: null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.pendingFiles.push(pending)
|
||||
// 录完自动退出语音模式, 让发送按钮显出来 (用户可加文字或直接发)
|
||||
if (this.voiceMode) this.exitVoiceMode()
|
||||
try {
|
||||
// H5 优先传 blob (uploadMedia 内部会处理 blob URL → fetch 上传),
|
||||
// 非 H5 传 filePath (uni.uploadFile)
|
||||
const inputArg = result.blob || result.filePath
|
||||
const uploaded = await uploadMedia(inputArg, 'audio', {
|
||||
name: pending.fileName,
|
||||
mime: result.mime,
|
||||
size: pending.fileSize
|
||||
})
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded && uploaded.url) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || this.$lang('ai-chat.index.uploadFailed'))
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.uploadFailedWithReason', { reason: e.message || '' }), icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
_discardVoiceRecorder() {
|
||||
if (!this._voiceRecorder) return
|
||||
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||
this._teardownVoiceRecorder()
|
||||
},
|
||||
|
||||
_teardownVoiceRecorder() {
|
||||
if (this._voiceRecorder) {
|
||||
if (this._onHoldProgress) this._voiceRecorder.off('progress', this._onHoldProgress)
|
||||
if (this._onHoldError) this._voiceRecorder.off('error', this._onHoldError)
|
||||
}
|
||||
this._voiceRecorder = null
|
||||
this._onHoldProgress = null
|
||||
this._onHoldError = null
|
||||
this._voiceHoldLastLevel = 0
|
||||
this.voiceLevel = 0
|
||||
},
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
||||
// voice-recorder.js 自身不带 Vue 上下文, 通过 err.code 映射到 $lang() key
|
||||
_voiceErrMsg(err) {
|
||||
if (!err) return ''
|
||||
switch (err.code) {
|
||||
case 'NOT_SUPPORTED':
|
||||
case 'NO_RECORDER_MANAGER':
|
||||
return this.$lang('ai-chat.index.voiceNotSupported')
|
||||
case 'H5_NO_MIC_API':
|
||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||
case 'ALREADY_STARTED':
|
||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||
case 'UNI_RECORD_ERROR':
|
||||
return this.$lang('ai-chat.index.voiceStopFailed')
|
||||
default:
|
||||
return err.message || ''
|
||||
}
|
||||
},
|
||||
|
||||
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
|
||||
@@ -4678,11 +4895,21 @@ _handleStreamError(msg) {
|
||||
margin: 4rpx 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f2f2f3;
|
||||
position: relative; // PR-FRONTEND-VOICE-INPUT: 让 .voice-cancel-hint 锚定到这里
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.is-holding {
|
||||
background: #e2e2e4;
|
||||
}
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上滑到 80px+ 视为取消手势
|
||||
&.is-cancelling {
|
||||
background: #fdecec;
|
||||
.voice-hold-text {
|
||||
color: #d23f3f;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-hold-text {
|
||||
font-size: 30rpx;
|
||||
color: #1f1f1f;
|
||||
@@ -4690,6 +4917,21 @@ _handleStreamError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话时上滑提示气泡
|
||||
.voice-cancel-hint {
|
||||
position: absolute;
|
||||
right: 20rpx;
|
||||
bottom: 100%;
|
||||
margin-bottom: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
border-radius: 12rpx;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 底部工具栏行: 语音模式 / 文字输入态 */
|
||||
.input-toolbar-row {
|
||||
display: flex;
|
||||
|
||||
@@ -392,6 +392,8 @@ export default {
|
||||
},
|
||||
|
||||
startRecord() {
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
|
||||
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
|
||||
this.$emit('voice-start')
|
||||
this.hideToolsPanel()
|
||||
},
|
||||
|
||||
@@ -1,239 +1,402 @@
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* ai-chat-voice-panel - 语音输入面板组件
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
voiceInputing: false
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (!val) {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
this.voiceInputing = false
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
this.voiceInputing = !this.voiceInputing
|
||||
if (this.voiceInputing) {
|
||||
this.startVoiceInput()
|
||||
} else {
|
||||
this.stopVoiceInput()
|
||||
}
|
||||
},
|
||||
|
||||
startVoiceInput() {
|
||||
// #ifdef H5
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
// 录音中,可扩展实时转文字
|
||||
})
|
||||
.catch(error => {
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
this.voiceInputing = false
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
|
||||
stopVoiceInput() {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
animation: none;
|
||||
|
||||
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
|
||||
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
|
||||
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
|
||||
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
|
||||
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
|
||||
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
|
||||
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
|
||||
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="waveBarStyle(i)"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ currentTip }}</text>
|
||||
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||
/**
|
||||
* ai-chat-voice-panel - 语音输入面板组件
|
||||
*
|
||||
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
|
||||
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
|
||||
* - 波形振幅由 progress 事件 (level 0..1) 驱动
|
||||
* - 录音上限 60s,自动停止
|
||||
* - 关闭面板时如果还在录音,先 cancel 释放资源
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
voiceInputing: false,
|
||||
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
|
||||
voiceLevel: 0,
|
||||
// 录音累计时长 (秒,整数)
|
||||
voiceDuration: 0,
|
||||
_durationTimer: null,
|
||||
_voiceRecorder: null,
|
||||
_onProgress: null,
|
||||
_onError: null,
|
||||
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
|
||||
_resolved: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentTip() {
|
||||
if (this.voiceInputing) {
|
||||
return this.$lang('ai-chat.index.recordingTip')
|
||||
}
|
||||
return this.$lang('ai-chat.index.startRecordTip')
|
||||
},
|
||||
formattedDuration() {
|
||||
const s = Math.max(0, Math.floor(this.voiceDuration))
|
||||
return s + '″'
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
// 每次重新打开都重置
|
||||
this.voiceInputing = false
|
||||
this.voiceLevel = 0
|
||||
this.voiceDuration = 0
|
||||
this._resolved = false
|
||||
} else {
|
||||
// 关闭时如果还在录音,丢弃
|
||||
this._discardRecorderIfAny()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this._discardRecorderIfAny()
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
||||
this._discardRecorderIfAny()
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
if (this.voiceInputing) {
|
||||
this._finishRecording()
|
||||
} else {
|
||||
this._beginRecording()
|
||||
}
|
||||
},
|
||||
|
||||
cancelVoiceInput() {
|
||||
this._discardRecorderIfAny()
|
||||
this.hideVoicePanel()
|
||||
},
|
||||
|
||||
waveBarStyle(i) {
|
||||
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
|
||||
const base = 24
|
||||
const offset = ((i * 17) % 32) + 12 // 12..44
|
||||
const levelPx = Math.round(this.voiceLevel * 80)
|
||||
const h = Math.max(16, base + offset + levelPx) + 'rpx'
|
||||
const delay = (i * 0.08) + 's'
|
||||
return { height: h, animationDelay: delay }
|
||||
},
|
||||
|
||||
_beginRecording() {
|
||||
if (this._voiceRecorder) return
|
||||
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
||||
this._voiceRecorder = rec
|
||||
this._onProgress = ({ level }) => {
|
||||
// 平滑一下,避免波形抖动太狠
|
||||
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
|
||||
}
|
||||
this._onError = (err) => {
|
||||
console.warn('[voice-panel] recorder error:', err)
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
this._discardRecorderIfAny()
|
||||
}
|
||||
rec.on('progress', this._onProgress)
|
||||
rec.on('error', this._onError)
|
||||
|
||||
rec.start()
|
||||
.then(() => {
|
||||
this.voiceInputing = true
|
||||
this.voiceDuration = 0
|
||||
this._startDurationTimer()
|
||||
})
|
||||
.catch((err) => {
|
||||
this._teardownRecorder()
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
async _finishRecording() {
|
||||
if (!this._voiceRecorder || this._resolved) return
|
||||
this._resolved = true
|
||||
this._stopDurationTimer()
|
||||
try {
|
||||
const result = await this._voiceRecorder.stop()
|
||||
if (!result || !result.filePath) {
|
||||
// 录音产物为空,可能是被系统打断
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
return
|
||||
}
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
this.$emit('audio-ready', {
|
||||
filePath: result.filePath,
|
||||
blob: result.blob || null,
|
||||
fileName: result.fileName || '',
|
||||
duration: result.duration,
|
||||
mime: result.mime,
|
||||
size: result.size
|
||||
})
|
||||
this.hideVoicePanel()
|
||||
} catch (e) {
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
_discardRecorderIfAny() {
|
||||
if (!this._voiceRecorder) return
|
||||
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
},
|
||||
|
||||
_teardownRecorder() {
|
||||
this._stopDurationTimer()
|
||||
if (this._voiceRecorder) {
|
||||
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
|
||||
if (this._onError) this._voiceRecorder.off('error', this._onError)
|
||||
}
|
||||
this._voiceRecorder = null
|
||||
this._onProgress = null
|
||||
this._onError = null
|
||||
this._resolved = false
|
||||
this.voiceLevel = 0
|
||||
},
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
||||
_voiceErrMsg(err) {
|
||||
if (!err) return ''
|
||||
switch (err.code) {
|
||||
case 'NOT_SUPPORTED':
|
||||
case 'NO_RECORDER_MANAGER':
|
||||
return this.$lang('ai-chat.index.voiceNotSupported')
|
||||
case 'H5_NO_MIC_API':
|
||||
case 'ALREADY_STARTED':
|
||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||
case 'UNI_RECORD_ERROR':
|
||||
return this.$lang('ai-chat.index.voiceStopFailed')
|
||||
default:
|
||||
return err.message || ''
|
||||
}
|
||||
},
|
||||
|
||||
_startDurationTimer() {
|
||||
this._stopDurationTimer()
|
||||
this._durationTimer = setInterval(() => {
|
||||
this.voiceDuration += 1
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
_stopDurationTimer() {
|
||||
if (this._durationTimer) {
|
||||
clearInterval(this._durationTimer)
|
||||
this._durationTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.0s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
transition: height 0.12s ease-out;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||
|
||||
&.is-recording {
|
||||
background-color: #e94e4e;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-link {
|
||||
margin-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-light;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.6); }
|
||||
50% { transform: scaleY(1.15); }
|
||||
}
|
||||
</style>
|
||||
@@ -65,6 +65,16 @@
|
||||
"when": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限
|
||||
"name": "ohos.permission.MICROPHONE",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.VIBRATE"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user