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.

380 lines
8.2 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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