|
|
<template>
|
|
|
<view>
|
|
|
<view class="dock">
|
|
|
<scroll-view class="quick-actions horizontal" scroll-x show-scrollbar="false">
|
|
|
<view class="qa-btn minor" @tap="onSwitchModel">切换模型</view>
|
|
|
<view class="qa-btn" @tap="onQuickAsk(item.quickAskText)" v-for="item in quickAskList" :key="item.id">
|
|
|
{{ item.label }}
|
|
|
</view>
|
|
|
</scroll-view>
|
|
|
<view class="input-bar">
|
|
|
<input class="input" confirm-type="send" v-model="inputTextValue" @confirm="onSend()"
|
|
|
placeholder="你可以说…" placeholder-class="ph" />
|
|
|
<view :class="['mic', { recording: isRecording }]" @touchstart.stop="onPressMic"
|
|
|
@touchmove.stop="onMoveMic" @touchend.stop="onReleaseMic">🎙️</view>
|
|
|
<!-- <button class="send" type="primary" @tap="onSend">发送</button> -->
|
|
|
<view :class="['send', (!isReplying && inputTextValue) ? 'normal' : 'disabled']">
|
|
|
<image v-if="isReplying" src="@/static/break.png" mode="widthFix" style="width: 20px;"
|
|
|
@tap="handleBreak"></image>
|
|
|
<image v-else src="@/static/top-arrows.png" mode="widthFix" style="width: 20px;" @tap="onSend">
|
|
|
</image>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
<view v-if="isRecording" class="record-mask">
|
|
|
<view class="record-box" :class="{ cancel: willCancel }">
|
|
|
<view class="record-icon">🎙️</view>
|
|
|
<view class="record-text">{{
|
|
|
willCancel ? "松开手指,取消发送" : "手指上滑,取消发送"
|
|
|
}}</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent> </view>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import {
|
|
|
recognizeAudio
|
|
|
} from "@/utils/uploadVoice.js";
|
|
|
export default {
|
|
|
props: {
|
|
|
inputText: {
|
|
|
type: String,
|
|
|
default: ''
|
|
|
},
|
|
|
isReplying: {
|
|
|
type: Boolean,
|
|
|
default: false
|
|
|
}
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
quickAskList: [{
|
|
|
id: 1,
|
|
|
label: "自我介绍",
|
|
|
quickAskText: "你是谁?",
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
label: "快捷提问",
|
|
|
quickAskText: "今日任务有哪些?",
|
|
|
},
|
|
|
{
|
|
|
id: 3,
|
|
|
label: "快捷提问",
|
|
|
quickAskText: "展示一份报表示例",
|
|
|
},
|
|
|
{
|
|
|
id: 4,
|
|
|
label: "快捷提问",
|
|
|
quickAskText: "生成日报模版",
|
|
|
}
|
|
|
],
|
|
|
searchHeight: 0,
|
|
|
inputTextValue: '',
|
|
|
isRecording: false,
|
|
|
willCancel: false,
|
|
|
recorder: null,
|
|
|
recordStartY: 0,
|
|
|
recordStartTs: 0,
|
|
|
recordSimTimer: null
|
|
|
}
|
|
|
},
|
|
|
mounted() {
|
|
|
let self = this;
|
|
|
uni.createSelectorQuery().select(".dock").boundingClientRect((rect) => {
|
|
|
self.searchHeight = Math.ceil(rect.height)
|
|
|
}).exec();
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
if (this.recordSimTimer) {
|
|
|
clearTimeout(this.recordSimTimer);
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
inputText(newValue) {
|
|
|
this.inputTextValue = newValue;
|
|
|
},
|
|
|
inputTextValue(newValue) {
|
|
|
this.$emit('changeInputText', newValue)
|
|
|
}
|
|
|
},
|
|
|
methods: {
|
|
|
handleBreak() {
|
|
|
this.$emit('handleBreak')
|
|
|
},
|
|
|
getHeight() {
|
|
|
return this.searchHeight
|
|
|
},
|
|
|
onSwitchModel() {
|
|
|
uni.showToast({
|
|
|
title: "已切换为通用模型",
|
|
|
icon: "none",
|
|
|
});
|
|
|
},
|
|
|
// 快速提问
|
|
|
onQuickAsk(text) {
|
|
|
this.$emit('onQuickAsk', text);
|
|
|
},
|
|
|
onSend() {
|
|
|
if (this.isReplying) return;
|
|
|
this.$emit('onSend')
|
|
|
// 清空输入框并同步父组件
|
|
|
this.inputTextValue = ''
|
|
|
this.$emit('changeInputText', '')
|
|
|
},
|
|
|
ensureRecorder() {
|
|
|
if (this.recorder) return;
|
|
|
try {
|
|
|
this.recorder = uni.getRecorderManager && uni.getRecorderManager();
|
|
|
} catch (e) {
|
|
|
this.recorder = null;
|
|
|
}
|
|
|
if (this.recorder) {
|
|
|
this.recorder.onStart();
|
|
|
this.recorder.onStop(async (res) => {
|
|
|
const duration = Date.now() - this.recordStartTs;
|
|
|
if (this.willCancel || duration < 700) {
|
|
|
uni.showToast({
|
|
|
title: duration < 700 ? "说话时间太短" : "已取消",
|
|
|
icon: "none",
|
|
|
});
|
|
|
return;
|
|
|
}
|
|
|
uni.showLoading({
|
|
|
title: "识别中...",
|
|
|
mask : true
|
|
|
});
|
|
|
const text = await recognizeAudio(res.tempFilePath);
|
|
|
if (!text?.trim()) {
|
|
|
uni.showToast({
|
|
|
title: '未识别到文字',
|
|
|
icon: 'none'
|
|
|
})
|
|
|
return;
|
|
|
}
|
|
|
this.$emit('changeInputText', text)
|
|
|
uni.hideLoading();
|
|
|
// TODO: 上传 res.tempFilePath 做识别;现用 mock
|
|
|
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
|
|
|
// duration / 100) / 10)
|
|
|
this.onSend();
|
|
|
});
|
|
|
}
|
|
|
},
|
|
|
onPressMic(e) {
|
|
|
if (process.env.UNI_PLATFORM !== 'APP-PLUS' && process.env.UNI_PLATFORM !== 'app-plus') {
|
|
|
uni.showToast({
|
|
|
title: '当前模式暂时只在APP支持',
|
|
|
icon: 'none'
|
|
|
})
|
|
|
return;
|
|
|
}
|
|
|
if (this.isLoading)
|
|
|
return uni.showToast({
|
|
|
title: "AI正在回复中",
|
|
|
icon: "none",
|
|
|
});
|
|
|
const appAuthorizeSetting = uni.getAppAuthorizeSetting();
|
|
|
if (appAuthorizeSetting.microphoneAuthorized !== 'authorized') {
|
|
|
uni.showModal({
|
|
|
title: '权限设置',
|
|
|
content: '应用缺乏必要的权限,是否前往手动授予该权限?',
|
|
|
complete: res => {
|
|
|
if (res.confirm) {
|
|
|
uni.openAppAuthorizeSetting()
|
|
|
}
|
|
|
}
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
this.ensureRecorder();
|
|
|
this.isRecording = true;
|
|
|
this.willCancel = false;
|
|
|
this.recordStartTs = Date.now();
|
|
|
this.recordStartY =
|
|
|
e.changedTouches && e.changedTouches[0] ?
|
|
|
e.changedTouches[0].clientY :
|
|
|
0;
|
|
|
if (this.recorder) {
|
|
|
try {
|
|
|
this.recorder.start({
|
|
|
format: "amr",
|
|
|
sampleRate: 16000,
|
|
|
numberOfChannels: 1,
|
|
|
duration: 60000,
|
|
|
});
|
|
|
} catch (err) {}
|
|
|
} else {
|
|
|
if (this.recordSimTimer) clearTimeout(this.recordSimTimer);
|
|
|
this.recordSimTimer = setTimeout(() => {}, 60000);
|
|
|
}
|
|
|
},
|
|
|
// 录音时移动
|
|
|
onMoveMic(e) {
|
|
|
if (!this.isRecording) return;
|
|
|
const y =
|
|
|
e.changedTouches && e.changedTouches[0] ?
|
|
|
e.changedTouches[0].clientY :
|
|
|
0;
|
|
|
this.willCancel = this.recordStartY - y > 60;
|
|
|
},
|
|
|
// 松开录音
|
|
|
onReleaseMic() {
|
|
|
console.log('onReleaseMic');
|
|
|
if (!this.isRecording) return;
|
|
|
this.isRecording = false;
|
|
|
this.show = false;
|
|
|
if (this.recorder) {
|
|
|
try {
|
|
|
this.recorder.stop();
|
|
|
} catch (err) {
|
|
|
console.log("err", err);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
.dock {
|
|
|
position: fixed;
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
bottom: 0;
|
|
|
background: #f7f8fc;
|
|
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
|
|
|
padding-bottom: constant(safe-area-inset-bottom);
|
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
|
}
|
|
|
|
|
|
.quick-actions {
|
|
|
padding: 6px 10px 4px;
|
|
|
}
|
|
|
|
|
|
.quick-actions.horizontal {
|
|
|
white-space: nowrap;
|
|
|
width: 95%;
|
|
|
}
|
|
|
|
|
|
.qa-btn {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
min-width: 96px;
|
|
|
text-align: center;
|
|
|
background: #fff;
|
|
|
border-radius: 10px;
|
|
|
padding: 8px 10px;
|
|
|
font-size: 12px;
|
|
|
color: #3b3f45;
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
|
margin-right: 10px;
|
|
|
}
|
|
|
|
|
|
.qa-btn.minor {
|
|
|
background: #eff1ff;
|
|
|
color: #4e7bff;
|
|
|
}
|
|
|
|
|
|
.qa-btn:last-child {
|
|
|
margin-right: 0;
|
|
|
}
|
|
|
|
|
|
.input-bar {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
padding: 8px 10px 12px;
|
|
|
gap: 8px;
|
|
|
background: #f7f8fc;
|
|
|
}
|
|
|
|
|
|
.input {
|
|
|
flex: 1;
|
|
|
background: #fff;
|
|
|
border-radius: 24px;
|
|
|
padding: 10px 14px;
|
|
|
font-size: 14px;
|
|
|
}
|
|
|
|
|
|
.ph {
|
|
|
color: #9aa3b2;
|
|
|
}
|
|
|
|
|
|
.mic {
|
|
|
width: 36px;
|
|
|
height: 36px;
|
|
|
border-radius: 18px;
|
|
|
background: #fff;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
|
}
|
|
|
|
|
|
.mic.recording {
|
|
|
background: #fffbf0;
|
|
|
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.25) inset;
|
|
|
}
|
|
|
|
|
|
.send {
|
|
|
height: 36px;
|
|
|
width: 36px;
|
|
|
border-radius: 50%;
|
|
|
background: #4e7bff;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
}
|
|
|
|
|
|
.disabled {
|
|
|
background-color: #ddd;
|
|
|
}
|
|
|
|
|
|
.normal {
|
|
|
background-color: #4e7bff;
|
|
|
}
|
|
|
|
|
|
.record-mask {
|
|
|
position: fixed;
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
top: 0;
|
|
|
bottom: 0;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
z-index: 9999;
|
|
|
}
|
|
|
|
|
|
.record-box {
|
|
|
background: rgba(0, 0, 0, 0.75);
|
|
|
color: #fff;
|
|
|
padding: 16px 18px;
|
|
|
border-radius: 12px;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 10px;
|
|
|
min-width: 220rpx;
|
|
|
}
|
|
|
|
|
|
.record-text {
|
|
|
font-size: 14px;
|
|
|
}
|
|
|
|
|
|
.mask-layer {
|
|
|
position: fixed;
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
top: 0;
|
|
|
bottom: 0;
|
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
|
}
|
|
|
|
|
|
.cancel {
|
|
|
color: red;
|
|
|
}
|
|
|
</style> |