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:
2026-09-24 17:47:37 +08:00
parent 74468e2c9d
commit 9d990e6aac
8 changed files with 1227 additions and 261 deletions

522
common/js/voice-recorder.js Normal file
View 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

View File

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

View File

@@ -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: '清空聊天',

View File

@@ -79,6 +79,9 @@
},
"scope.writePhotosAlbum" : {
"desc" : "用于从相册选择和保存头像"
},
"scope.record" : {
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],

View File

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

View File

@@ -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()
},

View File

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

View File

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