feat: 重构首页对话

This commit is contained in:
duanshuwen
2025-08-10 12:07:58 +08:00
parent 5174e75662
commit c231a72acb
9 changed files with 2104 additions and 636 deletions

View File

@@ -1,7 +1,10 @@
<template>
<view class="container">
<view class="chat-ai">
<ChatMarkdown :text="text"></ChatMarkdown>
<ChatMarkdown
:key="textKey"
:text="processedText"
></ChatMarkdown>
<slot name="content"></slot>
</view>
<slot name="footer"></slot>
@@ -9,15 +12,42 @@
</template>
<script setup>
import { defineProps } from "vue";
import { defineProps, computed, ref, watch } from "vue";
import ChatMarkdown from "./ChatMarkdown.vue";
defineProps({
const props = defineProps({
text: {
type: String,
default: ''
}
})
});
// 用于强制重新渲染的key
const textKey = ref(0);
// 处理文本内容
const processedText = computed(() => {
if (!props.text) {
return '';
}
// 确保文本是字符串类型
const textStr = String(props.text);
// 处理加载状态的文本
if (textStr.includes('加载中') || textStr.includes('...')) {
return textStr;
}
return textStr;
});
// 监听text变化强制重新渲染
watch(() => props.text, (newText, oldText) => {
if (newText !== oldText) {
textKey.value++;
}
}, { immediate: true });
</script>

View File

@@ -1,246 +1,252 @@
<template>
<view class="area-input">
<!-- 语音/键盘切换 -->
<view class="input-container-voice" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" src='/static/input_voice_icon.png'></image>
<image v-else src='/static/input_keyboard_icon.png'></image>
</view>
<view class="input-area-wrapper">
<view class="area-input">
<!-- 语音/键盘切换 -->
<view class="input-container-voice" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" src="/static/input_voice_icon.png"></image>
<image v-else src="/static/input_keyboard_icon.png"></image>
</view>
<!-- 输入框/语音按钮容器 -->
<view class="input-button-container">
<textarea ref="textareaRef" class="textarea" type="text" :placeholder="placeholder" cursor-spacing="65"
confirm-type='done' v-model="inputMessage" @confirm="sendMessage" @focus="handleFocus" @blur="handleBlur"
@touchstart="handleTouchStart" @touchend="handleTouchEnd" :confirm-hold="true" auto-height
:show-confirm-bar='false' :hold-keyboard="holdKeyboard" :adjust-position="true" maxlength="300" />
<!-- 输入框/语音按钮容器 -->
<view class="input-button-container">
<textarea
ref="textareaRef"
:class="['textarea', ios ? 'ios' : 'android']"
type="text"
cursor-spacing="65"
confirm-type="done"
v-model="inputMessage"
auto-height
:confirm-hold="true"
:placeholder="placeholder"
:show-confirm-bar="false"
:hold-keyboard="holdKeyboard"
:adjust-position="true"
maxlength="300"
@confirm="sendMessage"
@focus="handleFocus"
@blur="handleBlur"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
/>
<!-- <view
v-if="isVoiceMode"
class="hold-to-talk-button"
@touchstart.stop="startRecording"
@touchend.stop="stopRecording"
@touchmove.stop="handleTouchMove"
>
按住说话
</view> -->
<view v-if="isVoiceMode" class="hold-to-talk-button" @click.stop="startRecording">
按住说话
<view
v-if="isVoiceMode"
class="hold-to-talk-button"
@click.stop="startRecording"
>
按住说话
</view>
</view>
<view class="input-container-send">
<view class="input-container-send-btn" @click="sendMessage">
<image
v-if="props.isSessionActive"
src="/static/input_stop_icon.png"
></image>
<image v-else src="/static/input_send_icon.png"></image>
</view>
</view>
</view>
<view class="input-container-send">
<view class="input-container-send-btn" @click="sendMessage">
<image v-if="props.isSessionActive" src='/static/input_stop_icon.png'></image>
<image v-else src='/static/input_send_icon.png'></image>
</view>
</view>
<!-- 使用封装的弹窗组件 -->
<RecordingPopup
ref="recordingPopupRef"
:is-slide-to-text="isSlideToText"
@cancel="handleRecordingCancel"
/>
<VoiceResultPopup
ref="voiceResultPopupRef"
:voice-text="voiceText"
@cancel="cancelVoice"
@sendVoice="handleSendVoice"
@sendText="handleSendText"
/>
</view>
<!-- 使用封装的弹窗组件 -->
<RecordingPopup ref="recordingPopupRef" :is-slide-to-text="isSlideToText" @cancel="handleRecordingCancel" />
<VoiceResultPopup ref="voiceResultPopupRef" :voice-text="voiceText" @cancel="cancelVoice" @sendVoice="handleSendVoice"
@sendText="handleSendText" />
</template>
<script setup>
import { ref, watch, nextTick, onMounted } from 'vue'
import RecordingPopup from '@/components/Speech/RecordingPopup.vue'
import VoiceResultPopup from '@/components/Speech/VoiceResultPopup.vue'
import { ref, watch, nextTick, onMounted, computed } from "vue";
import RecordingPopup from "@/components/Speech/RecordingPopup.vue";
import VoiceResultPopup from "@/components/Speech/VoiceResultPopup.vue";
const props = defineProps({
modelValue: String,
holdKeyboard: Boolean,
isSessionActive: Boolean,
stopRequest: Function
})
const emit = defineEmits(['update:modelValue', 'send', 'noHideKeyboard', 'keyboardShow', 'keyboardHide', 'sendVoice'])
stopRequest: Function,
});
const emit = defineEmits([
"update:modelValue",
"send",
"noHideKeyboard",
"keyboardShow",
"keyboardHide",
"sendVoice",
]);
const textareaRef = ref(null)
const placeholder = ref('快告诉朵朵您在想什么~')
const inputMessage = ref(props.modelValue || '')
const isFocused = ref(false)
const keyboardHeight = ref(0)
const isVoiceMode = ref(false)
const isRecording = ref(false)
const recordingTime = ref(0)
const recordingTimer = ref(null)
const voiceText = ref('')
const showVoiceResult = ref(false)
const isSlideToText = ref(false)
const recordingPopupRef = ref(null)
const voiceResultPopupRef = ref(null)
const textareaRef = ref(null);
const placeholder = ref("快告诉朵朵您在想什么~");
const inputMessage = ref(props.modelValue || "");
const isFocused = ref(false);
const keyboardHeight = ref(0);
const isVoiceMode = ref(false);
const isRecording = ref(false);
const recordingTime = ref(0);
const recordingTimer = ref(null);
const voiceText = ref("");
const showVoiceResult = ref(false);
const isSlideToText = ref(false);
const recordingPopupRef = ref(null);
const voiceResultPopupRef = ref(null);
// 判断当前平台是否为iOS
const ios = computed(() => {
return uni.getSystemInfoSync().platform === "ios";
});
// 保持和父组件同步
watch(() => props.modelValue, (val) => {
inputMessage.value = val
})
watch(
() => props.modelValue,
(val) => {
inputMessage.value = val;
}
);
// 当子组件的 inputMessage 变化时,通知父组件(但要避免循环更新)
watch(inputMessage, (val) => {
// 只有当值真正不同时才emit避免循环更新
if (val !== props.modelValue) {
emit('update:modelValue', val)
emit("update:modelValue", val);
}
})
});
// 切换语音/文本模式
const toggleVoiceMode = () => {
isVoiceMode.value = !isVoiceMode.value
}
isVoiceMode.value = !isVoiceMode.value;
};
// 开始录音
const startRecording = () => {
console.log('startRecording')
isRecording.value = true
recordingTime.value = 0
console.log("startRecording");
isRecording.value = true;
recordingTime.value = 0;
// 启动录音计时器
recordingTimer.value = setInterval(() => {
recordingTime.value += 1
}, 1000)
recordingTime.value += 1;
}, 1000);
// 打开录音弹窗
if (recordingPopupRef.value) {
recordingPopupRef.value.open()
recordingPopupRef.value.open();
}
// 调用uni-app录音API
uni.startRecord({
success: (res) => {
// 录音成功,处理录音文件
const tempFilePath = res.tempFilePath
const tempFilePath = res.tempFilePath;
// 这里可以添加语音转文字的逻辑
// 模拟语音转文字
setTimeout(() => {
voiceText.value = '这是语音转文字的结果'
showVoiceResult.value = true
voiceText.value = "这是语音转文字的结果";
showVoiceResult.value = true;
// 打开语音结果弹窗
if (voiceResultPopupRef.value) {
voiceResultPopupRef.value.open()
voiceResultPopupRef.value.open();
}
}, 1000)
}, 1000);
},
fail: (err) => {
console.error('录音失败:', err)
isRecording.value = false
clearInterval(recordingTimer.value)
console.error("录音失败:", err);
isRecording.value = false;
clearInterval(recordingTimer.value);
if (recordingPopupRef.value) {
recordingPopupRef.value.close()
recordingPopupRef.value.close();
}
}
})
}
},
});
};
// 处理录音弹窗取消
const handleRecordingCancel = () => {
isRecording.value = false
clearInterval(recordingTimer.value)
uni.stopRecord()
}
// 结束录音
const stopRecording = () => {
isRecording.value = false
clearInterval(recordingTimer.value)
// 关闭录音弹窗
if (recordingPopupRef.value) {
recordingPopupRef.value.close()
}
// 如果录音时间小于1秒取消录音
if (recordingTime.value < 1) {
uni.stopRecord()
return
}
// 停止录音
uni.stopRecord()
}
isRecording.value = false;
clearInterval(recordingTimer.value);
uni.stopRecord();
};
// 处理发送原语音
const handleSendVoice = (data) => {
// 发送语音逻辑
emit('sendVoice', {
emit("sendVoice", {
text: data.text,
// 可以添加语音文件路径等信息
})
showVoiceResult.value = false
isVoiceMode.value = false
}
});
showVoiceResult.value = false;
isVoiceMode.value = false;
};
// 处理发送文本
const handleSendText = (data) => {
// 发送文本逻辑
emit('sendVoice', {
emit("sendVoice", {
text: data.text,
// 可以添加语音文件路径等信息
})
showVoiceResult.value = false
isVoiceMode.value = false
}
// 处理滑动事件
const handleTouchMove = (e) => {
// 检测滑动位置,判断是否需要转文字
// 这里只是示例逻辑需要根据实际UI调整
const touchY = e.touches[0].clientY
// 假设滑动到某个位置以下转为文字
isSlideToText.value = touchY < 200
}
});
showVoiceResult.value = false;
isVoiceMode.value = false;
};
// 取消语音
const cancelVoice = () => {
showVoiceResult.value = false
isVoiceMode.value = false
}
showVoiceResult.value = false;
isVoiceMode.value = false;
};
// 测试弹窗方法
const testPopup = () => {
// 模拟开始录音,打开录音弹窗
isRecording.value = true
console.log("===========1")
isRecording.value = true;
console.log("===========1");
if (recordingPopupRef.value) {
console.log("===========2")
console.log("===========2");
recordingPopupRef.value.open()
recordingPopupRef.value.open();
}
// 2秒后关闭录音弹窗打开语音结果弹窗
setTimeout(() => {
if (recordingPopupRef.value) {
recordingPopupRef.value.close()
recordingPopupRef.value.close();
}
voiceText.value = '测试语音转文字结果'
showVoiceResult.value = true
voiceText.value = "测试语音转文字结果";
showVoiceResult.value = true;
if (voiceResultPopupRef.value) {
voiceResultPopupRef.value.open()
voiceResultPopupRef.value.open();
}
}, 3000)
}
}, 3000);
};
// 监听键盘高度变化
onMounted(() => {
// 监听键盘弹起
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height
keyboardHeight.value = res.height;
if (res.height > 0) {
emit('keyboardShow', res.height)
emit("keyboardShow", res.height);
} else {
emit('keyboardHide')
emit("keyboardHide");
}
})
})
});
});
const sendMessage = () => {
if (isVoiceMode.value) {
testPopup()
return
testPopup();
return;
}
if (props.isSessionActive) {
@@ -251,56 +257,55 @@ const sendMessage = () => {
} else {
// 否则发送新消息
if (!inputMessage.value.trim()) return;
emit('send', inputMessage.value)
emit("send", inputMessage.value);
// 发送后保持焦点(可选)
if (props.holdKeyboard && textareaRef.value) {
nextTick(() => {
textareaRef.value.focus()
})
textareaRef.value.focus();
});
}
}
}
};
const handleFocus = () => {
isFocused.value = true
emit('noHideKeyboard')
}
isFocused.value = true;
emit("noHideKeyboard");
};
const handleBlur = () => {
isFocused.value = false
}
isFocused.value = false;
};
const handleTouchStart = () => {
emit('noHideKeyboard')
}
emit("noHideKeyboard");
};
const handleTouchEnd = () => {
emit('noHideKeyboard')
}
emit("noHideKeyboard");
};
// 手动聚焦输入框
const focusInput = () => {
if (textareaRef.value) {
textareaRef.value.focus()
textareaRef.value.focus();
}
}
};
// 手动失焦输入框
const blurInput = () => {
if (textareaRef.value) {
textareaRef.value.blur()
textareaRef.value.blur();
}
}
};
// 暴露方法给父组件
defineExpose({
focusInput,
blurInput,
isFocused,
toggleVoiceMode
})
toggleVoiceMode,
});
</script>
<style scoped lang="scss">
@@ -308,10 +313,9 @@ defineExpose({
display: flex;
align-items: center;
border-radius: 22px;
background-color: #FFFFFF;
background-color: #ffffff;
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
margin: 0 12px;
/* 确保输入框在安全区域内 */
margin-bottom: 8px;
.input-container-voice {
@@ -320,9 +324,6 @@ defineExpose({
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
align-self: flex-end;
cursor: pointer;
image {
width: 22px;
@@ -337,9 +338,6 @@ defineExpose({
}
.hold-to-talk-button {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: #333333;
@@ -347,7 +345,7 @@ defineExpose({
display: flex;
justify-content: center;
align-items: center;
background-color: #FFFFFF;
background-color: #ffffff;
}
.input-container-send {
@@ -356,8 +354,6 @@ defineExpose({
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
align-self: flex-end;
.input-container-send-btn {
display: flex;
@@ -375,27 +371,29 @@ defineExpose({
.textarea {
flex: 1;
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
max-height: 92px;
min-height: 44px;
font-size: 16px;
line-height: 22px;
padding: 3px 0 0;
align-items: center;
justify-content: center;
}
line-height: normal;
/* 确保textarea在iOS上的样式正常 */
.textarea::-webkit-input-placeholder {
color: #CCCCCC;
}
&.android {
padding: 11px 0;
}
.textarea:focus {
outline: none;
&.ios {
padding: 4px 0;
}
&::placeholder {
color: #cccccc;
line-height: normal;
}
&:focus {
outline: none;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -148,4 +148,23 @@
-webkit-appearance: none;
background: transparent;
color: transparent;
}
// 打字机光标闪烁动画
.typing-cursor {
display: inline-block;
color: #42ADF9;
font-weight: bold;
font-size: 1.2em;
animation: blink 1s infinite;
margin-left: 2px;
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}

View File

@@ -196,6 +196,11 @@ const handleConfirmOrder = async (orderData) => {
title: "支付成功",
icon: "success",
duration: 2000,
success: () => {
uni.navigateTo({
url: "/pages/order/list",
});
},
});
},
fail: (err) => {