You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

442 lines
10 KiB
Vue

<template>
<view>
<view class="dock">
<view style="display: flex;">
<scroll-view class="quick-actions horizontal" scroll-x show-scrollbar="false"
style="width: calc(100% - 50px);">
<!-- <view class="qa-btn minor" @tap="onSwitchModel"></view> -->
<view class="qa-btn" @tap="onQuickAsk(item.quickAskText)" v-for="item in quickAskList"
:key="item.quickAskText" @longpress.prevent="deleteQucikAsk(item)">
{{ item.quickAskText }}
</view>
</scroll-view>
<view class="quick-add" @tap="inputDialogToggle">
<image style="width: 40px;" src="@/static/plus-circle-fill.png" mode="widthFix"></image>
</view>
</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">
<image src="../../../static/mic.png" mode="widthFix"></image>
</view>
<!-- <button class="send" type="primary" @tap="onSend">发送</button> -->
<view
:class="['send', (!isReplying && inputTextValue && inputTextValue.trim()) ? '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-text">{{
willCancel ? "松开手指,取消发送" : "手指上滑,取消发送"
}}</view>
</view>
</view>
<uni-popup ref="inputDialog" type="dialog" style="z-index: 10003;" >
<uni-popup-dialog ref="inputClose" mode="input" title="添加快捷提问" v-model="dialogText" placeholder="请输入内容"
@confirm="dialogInputConfirm" :maxlength="15" @close='dialogInputClose'></uni-popup-dialog>
</uni-popup>
<view v-if="isRecording" class="mask-layer"> </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: [
{
quickAskText : '设备运行情况'
},
{
quickAskText : '今日出入库数据'
}
],
searchHeight: 0,
inputTextValue: '',
isRecording: false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null,
dialogText: ''
}
},
mounted() {
let self = this;
uni.createSelectorQuery().select(".dock").boundingClientRect((rect) => {
self.searchHeight = Math.ceil(rect.height)
}).exec();
uni.createSelectorQuery().select(".record-box").boundingClientRect((rect) => {
console.log('rect',rect);
}).exec();
},
beforeDestroy() {
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer);
}
},
watch: {
inputText(newValue) {
this.inputTextValue = newValue;
},
inputTextValue(newValue) {
this.$emit('changeInputText', newValue)
}
},
methods: {
deleteQucikAsk(item){
uni.showModal({
title: "提示",
content: "确定要删除这条快捷提问?",
success : res => {
if(res.confirm){
this.quickAskList = this.quickAskList.filter(ele => ele.quickAskText !== item.quickAskText);
uni.showToast({
title: '删除成功',
icon: 'none'
})
}
}
})
},
dialogInputClose(){
this.$emit('changeShow',false)
},
inputDialogToggle() {
this.dialogText = '';
this.$refs.inputDialog.open();
this.$emit('changeShow',true)
},
dialogInputConfirm() {
this.$emit('changeShow',false)
if(!this.dialogText || !this.dialogText.trim()){
// uni.showToast({
// title: '内容不能为空',
// icon: 'none'
// })
return;
}
let index = this.quickAskList.findIndex(item => item.quickAskText.trim() === this.dialogText.trim());
if(index > -1){
uni.showToast({
title: '不能重复添加内容',
icon: 'none'
})
return;
}
this.quickAskList.unshift({
quickAskText : this.dialogText,
});
},
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) => {
this.$emit('changeShow',false)
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.$emit('changeShow',true);
this.$emit('startRecord')
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);
z-index: 999;
}
.quick-actions {
padding: 6px 10px 4px;
}
.quick-actions.horizontal {
white-space: nowrap;
width: 100%;
padding-right: 10px;
box-sizing: border-box;
}
.quick-add {
display: flex;
align-items: center;
justify-content: center;
}
.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: #4F46E5; */
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: 100005;
}
.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, .4);
z-index: 100004;
}
.cancel {
color: red;
}
</style>