feat: 合并代码
This commit is contained in:
commit
ccdc46ec7c
@ -1,3 +1,6 @@
|
|||||||
const isProd = true
|
const isProd = true;
|
||||||
|
|
||||||
export const BASE_URL = 'https://onefeel.brother7.cn/ingress' //'http://8.138.234.141/ingress'
|
export const BASE_URL = "https://onefeel.brother7.cn/ingress"; //'http://8.138.234.141/ingress'
|
||||||
|
|
||||||
|
// socket地址
|
||||||
|
export const WSS_URL = "wss://onefeel.brother7.cn/ingress/agent/ws/chat";
|
||||||
|
|||||||
@ -1,45 +1,49 @@
|
|||||||
import { wxLogin, bindUserPhone, checkUserPhone } from "../request/api/LoginApi";
|
import {
|
||||||
|
wxLogin,
|
||||||
|
bindUserPhone,
|
||||||
|
checkUserPhone,
|
||||||
|
} from "../request/api/LoginApi";
|
||||||
import { getWeChatAuthCode } from "./AuthManager";
|
import { getWeChatAuthCode } from "./AuthManager";
|
||||||
|
|
||||||
const loginAuth = async () => {
|
const loginAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const openIdCode = await getWeChatAuthCode();
|
const openIdCode = await getWeChatAuthCode();
|
||||||
console.log('获取到的微信授权code:', openIdCode);
|
console.log("获取到的微信授权code:", openIdCode);
|
||||||
const response = await wxLogin({
|
const response = await wxLogin({
|
||||||
openIdCode: [openIdCode],
|
openIdCode: [openIdCode],
|
||||||
grant_type: 'wechat',
|
grant_type: "wechat",
|
||||||
scope: 'server',
|
scope: "server",
|
||||||
clientId: '2'
|
clientId: "2",
|
||||||
});
|
});
|
||||||
console.log('获取到的微信授权response:', response);
|
console.log("获取到的微信授权response:", response);
|
||||||
|
|
||||||
if (response.access_token) {
|
if (response.access_token) {
|
||||||
uni.setStorageSync('token', response.access_token)
|
uni.setStorageSync("token", response.access_token);
|
||||||
return response;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || '登录失败');
|
throw new Error(response.message || "登录失败");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const bindPhone = async (params) => {
|
const bindPhone = async (params) => {
|
||||||
try {
|
try {
|
||||||
const response = await bindUserPhone(params)
|
const response = await bindUserPhone(params);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const checkPhone = async (phone) => {
|
const checkPhone = async (phone) => {
|
||||||
try {
|
try {
|
||||||
const response = await checkUserPhone(phone)
|
const response = await checkUserPhone(phone);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export { loginAuth, bindPhone, checkPhone }
|
export { loginAuth, bindPhone, checkPhone };
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="chat-ai">
|
<view class="chat-ai">
|
||||||
<ChatMarkdown :text="text"></ChatMarkdown>
|
<ChatMarkdown
|
||||||
|
:key="textKey"
|
||||||
|
:text="processedText"
|
||||||
|
></ChatMarkdown>
|
||||||
<slot name="content"></slot>
|
<slot name="content"></slot>
|
||||||
</view>
|
</view>
|
||||||
<slot name="footer"></slot>
|
<slot name="footer"></slot>
|
||||||
@ -9,15 +12,42 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from "vue";
|
import { defineProps, computed, ref, watch } from "vue";
|
||||||
import ChatMarkdown from "./ChatMarkdown.vue";
|
import ChatMarkdown from "./ChatMarkdown.vue";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
text: {
|
text: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,246 +1,252 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<view class="input-area-wrapper">
|
||||||
<view class="area-input">
|
<view class="area-input">
|
||||||
<!-- 语音/键盘切换 -->
|
<!-- 语音/键盘切换 -->
|
||||||
<view class="input-container-voice" @click="toggleVoiceMode">
|
<view class="input-container-voice" @click="toggleVoiceMode">
|
||||||
<image v-if="!isVoiceMode" src='/static/input_voice_icon.png'></image>
|
<image v-if="!isVoiceMode" src="/static/input_voice_icon.png"></image>
|
||||||
<image v-else src='/static/input_keyboard_icon.png'></image>
|
<image v-else src="/static/input_keyboard_icon.png"></image>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 输入框/语音按钮容器 -->
|
<!-- 输入框/语音按钮容器 -->
|
||||||
<view class="input-button-container">
|
<view class="input-button-container">
|
||||||
<textarea ref="textareaRef" class="textarea" type="text" :placeholder="placeholder" cursor-spacing="65"
|
<textarea
|
||||||
confirm-type='done' v-model="inputMessage" @confirm="sendMessage" @focus="handleFocus" @blur="handleBlur"
|
ref="textareaRef"
|
||||||
@touchstart="handleTouchStart" @touchend="handleTouchEnd" :confirm-hold="true" auto-height
|
:class="['textarea', ios ? 'ios' : 'android']"
|
||||||
:show-confirm-bar='false' :hold-keyboard="holdKeyboard" :adjust-position="true" maxlength="300" />
|
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
|
<view
|
||||||
v-if="isVoiceMode"
|
v-if="isVoiceMode"
|
||||||
class="hold-to-talk-button"
|
class="hold-to-talk-button"
|
||||||
@touchstart.stop="startRecording"
|
@click.stop="startRecording"
|
||||||
@touchend.stop="stopRecording"
|
|
||||||
@touchmove.stop="handleTouchMove"
|
|
||||||
>
|
>
|
||||||
按住说话
|
按住说话
|
||||||
</view> -->
|
|
||||||
<view v-if="isVoiceMode" class="hold-to-talk-button" @click.stop="startRecording">
|
|
||||||
按住说话
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="input-container-send">
|
<view class="input-container-send">
|
||||||
<view class="input-container-send-btn" @click="sendMessage">
|
<view class="input-container-send-btn" @click="sendMessage">
|
||||||
<image v-if="props.isSessionActive" src='/static/input_stop_icon.png'></image>
|
<image
|
||||||
<image v-else src='/static/input_send_icon.png'></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>
|
||||||
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 使用封装的弹窗组件 -->
|
<!-- 使用封装的弹窗组件 -->
|
||||||
<RecordingPopup ref="recordingPopupRef" :is-slide-to-text="isSlideToText" @cancel="handleRecordingCancel" />
|
<RecordingPopup
|
||||||
|
ref="recordingPopupRef"
|
||||||
<VoiceResultPopup ref="voiceResultPopupRef" :voice-text="voiceText" @cancel="cancelVoice" @sendVoice="handleSendVoice"
|
:is-slide-to-text="isSlideToText"
|
||||||
@sendText="handleSendText" />
|
@cancel="handleRecordingCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VoiceResultPopup
|
||||||
|
ref="voiceResultPopupRef"
|
||||||
|
:voice-text="voiceText"
|
||||||
|
@cancel="cancelVoice"
|
||||||
|
@sendVoice="handleSendVoice"
|
||||||
|
@sendText="handleSendText"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
import { ref, watch, nextTick, onMounted, computed } from "vue";
|
||||||
import RecordingPopup from '@/components/Speech/RecordingPopup.vue'
|
import RecordingPopup from "@/components/Speech/RecordingPopup.vue";
|
||||||
import VoiceResultPopup from '@/components/Speech/VoiceResultPopup.vue'
|
import VoiceResultPopup from "@/components/Speech/VoiceResultPopup.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: String,
|
modelValue: String,
|
||||||
holdKeyboard: Boolean,
|
holdKeyboard: Boolean,
|
||||||
isSessionActive: Boolean,
|
isSessionActive: Boolean,
|
||||||
stopRequest: Function
|
stopRequest: Function,
|
||||||
})
|
});
|
||||||
const emit = defineEmits(['update:modelValue', 'send', 'noHideKeyboard', 'keyboardShow', 'keyboardHide', 'sendVoice'])
|
const emit = defineEmits([
|
||||||
|
"update:modelValue",
|
||||||
|
"send",
|
||||||
|
"noHideKeyboard",
|
||||||
|
"keyboardShow",
|
||||||
|
"keyboardHide",
|
||||||
|
"sendVoice",
|
||||||
|
]);
|
||||||
|
|
||||||
const textareaRef = ref(null)
|
const textareaRef = ref(null);
|
||||||
const placeholder = ref('快告诉朵朵您在想什么~')
|
const placeholder = ref("快告诉朵朵您在想什么~");
|
||||||
const inputMessage = ref(props.modelValue || '')
|
const inputMessage = ref(props.modelValue || "");
|
||||||
const isFocused = ref(false)
|
const isFocused = ref(false);
|
||||||
const keyboardHeight = ref(0)
|
const keyboardHeight = ref(0);
|
||||||
const isVoiceMode = ref(false)
|
const isVoiceMode = ref(false);
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false);
|
||||||
const recordingTime = ref(0)
|
const recordingTime = ref(0);
|
||||||
const recordingTimer = ref(null)
|
const recordingTimer = ref(null);
|
||||||
const voiceText = ref('')
|
const voiceText = ref("");
|
||||||
const showVoiceResult = ref(false)
|
const showVoiceResult = ref(false);
|
||||||
const isSlideToText = ref(false)
|
const isSlideToText = ref(false);
|
||||||
const recordingPopupRef = ref(null)
|
const recordingPopupRef = ref(null);
|
||||||
const voiceResultPopupRef = ref(null)
|
const voiceResultPopupRef = ref(null);
|
||||||
|
|
||||||
|
// 判断当前平台是否为iOS
|
||||||
|
const ios = computed(() => {
|
||||||
|
return uni.getSystemInfoSync().platform === "ios";
|
||||||
|
});
|
||||||
|
|
||||||
// 保持和父组件同步
|
// 保持和父组件同步
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(
|
||||||
inputMessage.value = val
|
() => props.modelValue,
|
||||||
})
|
(val) => {
|
||||||
|
inputMessage.value = val;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 当子组件的 inputMessage 变化时,通知父组件(但要避免循环更新)
|
// 当子组件的 inputMessage 变化时,通知父组件(但要避免循环更新)
|
||||||
watch(inputMessage, (val) => {
|
watch(inputMessage, (val) => {
|
||||||
// 只有当值真正不同时才emit,避免循环更新
|
// 只有当值真正不同时才emit,避免循环更新
|
||||||
if (val !== props.modelValue) {
|
if (val !== props.modelValue) {
|
||||||
emit('update:modelValue', val)
|
emit("update:modelValue", val);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 切换语音/文本模式
|
// 切换语音/文本模式
|
||||||
const toggleVoiceMode = () => {
|
const toggleVoiceMode = () => {
|
||||||
isVoiceMode.value = !isVoiceMode.value
|
isVoiceMode.value = !isVoiceMode.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 开始录音
|
// 开始录音
|
||||||
const startRecording = () => {
|
const startRecording = () => {
|
||||||
console.log('startRecording')
|
console.log("startRecording");
|
||||||
isRecording.value = true
|
isRecording.value = true;
|
||||||
recordingTime.value = 0
|
recordingTime.value = 0;
|
||||||
// 启动录音计时器
|
// 启动录音计时器
|
||||||
recordingTimer.value = setInterval(() => {
|
recordingTimer.value = setInterval(() => {
|
||||||
recordingTime.value += 1
|
recordingTime.value += 1;
|
||||||
}, 1000)
|
}, 1000);
|
||||||
|
|
||||||
// 打开录音弹窗
|
// 打开录音弹窗
|
||||||
if (recordingPopupRef.value) {
|
if (recordingPopupRef.value) {
|
||||||
recordingPopupRef.value.open()
|
recordingPopupRef.value.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用uni-app录音API
|
// 调用uni-app录音API
|
||||||
uni.startRecord({
|
uni.startRecord({
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
// 录音成功,处理录音文件
|
// 录音成功,处理录音文件
|
||||||
const tempFilePath = res.tempFilePath
|
const tempFilePath = res.tempFilePath;
|
||||||
// 这里可以添加语音转文字的逻辑
|
// 这里可以添加语音转文字的逻辑
|
||||||
// 模拟语音转文字
|
// 模拟语音转文字
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
voiceText.value = '这是语音转文字的结果'
|
voiceText.value = "这是语音转文字的结果";
|
||||||
showVoiceResult.value = true
|
showVoiceResult.value = true;
|
||||||
// 打开语音结果弹窗
|
// 打开语音结果弹窗
|
||||||
if (voiceResultPopupRef.value) {
|
if (voiceResultPopupRef.value) {
|
||||||
voiceResultPopupRef.value.open()
|
voiceResultPopupRef.value.open();
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000);
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error('录音失败:', err)
|
console.error("录音失败:", err);
|
||||||
isRecording.value = false
|
isRecording.value = false;
|
||||||
clearInterval(recordingTimer.value)
|
clearInterval(recordingTimer.value);
|
||||||
if (recordingPopupRef.value) {
|
if (recordingPopupRef.value) {
|
||||||
recordingPopupRef.value.close()
|
recordingPopupRef.value.close();
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 处理录音弹窗取消
|
// 处理录音弹窗取消
|
||||||
const handleRecordingCancel = () => {
|
const handleRecordingCancel = () => {
|
||||||
isRecording.value = false
|
isRecording.value = false;
|
||||||
clearInterval(recordingTimer.value)
|
clearInterval(recordingTimer.value);
|
||||||
uni.stopRecord()
|
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理发送原语音
|
// 处理发送原语音
|
||||||
const handleSendVoice = (data) => {
|
const handleSendVoice = (data) => {
|
||||||
// 发送语音逻辑
|
// 发送语音逻辑
|
||||||
emit('sendVoice', {
|
emit("sendVoice", {
|
||||||
text: data.text,
|
text: data.text,
|
||||||
// 可以添加语音文件路径等信息
|
// 可以添加语音文件路径等信息
|
||||||
})
|
});
|
||||||
showVoiceResult.value = false
|
showVoiceResult.value = false;
|
||||||
isVoiceMode.value = false
|
isVoiceMode.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 处理发送文本
|
// 处理发送文本
|
||||||
const handleSendText = (data) => {
|
const handleSendText = (data) => {
|
||||||
// 发送文本逻辑
|
// 发送文本逻辑
|
||||||
emit('sendVoice', {
|
emit("sendVoice", {
|
||||||
text: data.text,
|
text: data.text,
|
||||||
// 可以添加语音文件路径等信息
|
// 可以添加语音文件路径等信息
|
||||||
})
|
});
|
||||||
showVoiceResult.value = false
|
showVoiceResult.value = false;
|
||||||
isVoiceMode.value = false
|
isVoiceMode.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 处理滑动事件
|
|
||||||
const handleTouchMove = (e) => {
|
|
||||||
// 检测滑动位置,判断是否需要转文字
|
|
||||||
// 这里只是示例逻辑,需要根据实际UI调整
|
|
||||||
const touchY = e.touches[0].clientY
|
|
||||||
// 假设滑动到某个位置以下转为文字
|
|
||||||
isSlideToText.value = touchY < 200
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消语音
|
// 取消语音
|
||||||
const cancelVoice = () => {
|
const cancelVoice = () => {
|
||||||
showVoiceResult.value = false
|
showVoiceResult.value = false;
|
||||||
isVoiceMode.value = false
|
isVoiceMode.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 测试弹窗方法
|
// 测试弹窗方法
|
||||||
const testPopup = () => {
|
const testPopup = () => {
|
||||||
// 模拟开始录音,打开录音弹窗
|
// 模拟开始录音,打开录音弹窗
|
||||||
isRecording.value = true
|
isRecording.value = true;
|
||||||
console.log("===========1")
|
console.log("===========1");
|
||||||
|
|
||||||
if (recordingPopupRef.value) {
|
if (recordingPopupRef.value) {
|
||||||
console.log("===========2")
|
console.log("===========2");
|
||||||
|
|
||||||
recordingPopupRef.value.open()
|
recordingPopupRef.value.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2秒后关闭录音弹窗,打开语音结果弹窗
|
// 2秒后关闭录音弹窗,打开语音结果弹窗
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (recordingPopupRef.value) {
|
if (recordingPopupRef.value) {
|
||||||
recordingPopupRef.value.close()
|
recordingPopupRef.value.close();
|
||||||
}
|
}
|
||||||
voiceText.value = '测试语音转文字结果'
|
voiceText.value = "测试语音转文字结果";
|
||||||
showVoiceResult.value = true
|
showVoiceResult.value = true;
|
||||||
if (voiceResultPopupRef.value) {
|
if (voiceResultPopupRef.value) {
|
||||||
voiceResultPopupRef.value.open()
|
voiceResultPopupRef.value.open();
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 3000);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 监听键盘高度变化
|
// 监听键盘高度变化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 监听键盘弹起
|
// 监听键盘弹起
|
||||||
uni.onKeyboardHeightChange((res) => {
|
uni.onKeyboardHeightChange((res) => {
|
||||||
keyboardHeight.value = res.height
|
keyboardHeight.value = res.height;
|
||||||
if (res.height > 0) {
|
if (res.height > 0) {
|
||||||
emit('keyboardShow', res.height)
|
emit("keyboardShow", res.height);
|
||||||
} else {
|
} else {
|
||||||
emit('keyboardHide')
|
emit("keyboardHide");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const sendMessage = () => {
|
const sendMessage = () => {
|
||||||
if (isVoiceMode.value) {
|
if (isVoiceMode.value) {
|
||||||
testPopup()
|
testPopup();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.isSessionActive) {
|
if (props.isSessionActive) {
|
||||||
@ -251,56 +257,55 @@ const sendMessage = () => {
|
|||||||
} else {
|
} else {
|
||||||
// 否则发送新消息
|
// 否则发送新消息
|
||||||
if (!inputMessage.value.trim()) return;
|
if (!inputMessage.value.trim()) return;
|
||||||
emit('send', inputMessage.value)
|
emit("send", inputMessage.value);
|
||||||
|
|
||||||
// 发送后保持焦点(可选)
|
// 发送后保持焦点(可选)
|
||||||
if (props.holdKeyboard && textareaRef.value) {
|
if (props.holdKeyboard && textareaRef.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
textareaRef.value.focus()
|
textareaRef.value.focus();
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
isFocused.value = true
|
isFocused.value = true;
|
||||||
emit('noHideKeyboard')
|
emit("noHideKeyboard");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
isFocused.value = false
|
isFocused.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouchStart = () => {
|
const handleTouchStart = () => {
|
||||||
emit('noHideKeyboard')
|
emit("noHideKeyboard");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
emit('noHideKeyboard')
|
emit("noHideKeyboard");
|
||||||
}
|
};
|
||||||
|
|
||||||
// 手动聚焦输入框
|
// 手动聚焦输入框
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
textareaRef.value.focus()
|
textareaRef.value.focus();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 手动失焦输入框
|
// 手动失焦输入框
|
||||||
const blurInput = () => {
|
const blurInput = () => {
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
textareaRef.value.blur()
|
textareaRef.value.blur();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusInput,
|
focusInput,
|
||||||
blurInput,
|
blurInput,
|
||||||
isFocused,
|
isFocused,
|
||||||
toggleVoiceMode
|
toggleVoiceMode,
|
||||||
})
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@ -308,10 +313,9 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background-color: #FFFFFF;
|
background-color: #ffffff;
|
||||||
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
|
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
|
||||||
margin: 0 12px;
|
margin: 0 12px;
|
||||||
/* 确保输入框在安全区域内 */
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
.input-container-voice {
|
.input-container-voice {
|
||||||
@ -320,9 +324,6 @@ defineExpose({
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
image {
|
image {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@ -337,9 +338,6 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hold-to-talk-button {
|
.hold-to-talk-button {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@ -347,7 +345,7 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: #FFFFFF;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-container-send {
|
.input-container-send {
|
||||||
@ -356,8 +354,6 @@ defineExpose({
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
|
|
||||||
.input-container-send-btn {
|
.input-container-send-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -375,27 +371,29 @@ defineExpose({
|
|||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: absolute;
|
box-sizing: border-box;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
max-height: 92px;
|
max-height: 92px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 22px;
|
line-height: normal;
|
||||||
padding: 3px 0 0;
|
|
||||||
align-items: center;
|
&.android {
|
||||||
justify-content: center;
|
padding: 11px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保textarea在iOS上的样式正常 */
|
&.ios {
|
||||||
.textarea::-webkit-input-placeholder {
|
padding: 4px 0;
|
||||||
color: #CCCCCC;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea:focus {
|
&::placeholder {
|
||||||
|
color: #cccccc;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
|
|
||||||
<view class="chat-content">
|
<view class="chat-content">
|
||||||
<!-- 顶部自定义导航栏 -->
|
<!-- 顶部自定义导航栏 -->
|
||||||
<view class="nav-bar-container" :style="{
|
<view
|
||||||
|
class="nav-bar-container"
|
||||||
|
:style="{
|
||||||
paddingTop: statusBarHeight + 'px',
|
paddingTop: statusBarHeight + 'px',
|
||||||
}">
|
}"
|
||||||
|
>
|
||||||
<ChatTopNavBar @openDrawer="openDrawer"></ChatTopNavBar>
|
<ChatTopNavBar @openDrawer="openDrawer"></ChatTopNavBar>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -19,22 +22,50 @@
|
|||||||
class="area-msg-list"
|
class="area-msg-list"
|
||||||
>
|
>
|
||||||
<!-- welcome栏 -->
|
<!-- welcome栏 -->
|
||||||
<ChatTopWelcome class="chat-container-top-bannar"
|
<ChatTopWelcome
|
||||||
|
class="chat-container-top-bannar"
|
||||||
:initPageImages="mainPageDataModel.initPageImages"
|
:initPageImages="mainPageDataModel.initPageImages"
|
||||||
:welcomeContent="mainPageDataModel.welcomeContent">
|
:welcomeContent="mainPageDataModel.welcomeContent"
|
||||||
|
>
|
||||||
</ChatTopWelcome>
|
</ChatTopWelcome>
|
||||||
|
|
||||||
<view class="area-msg-list-content" v-for="item in chatMsgList" :key="item.msgId" :id="item.msgId">
|
<view
|
||||||
|
class="area-msg-list-content"
|
||||||
|
v-for="item in chatMsgList"
|
||||||
|
:key="item.msgId"
|
||||||
|
:id="item.msgId"
|
||||||
|
>
|
||||||
<template v-if="item.msgType === MessageRole.AI">
|
<template v-if="item.msgType === MessageRole.AI">
|
||||||
<ChatCardAI class="message-item-ai" :text="item.msg">
|
<ChatCardAI
|
||||||
|
class="message-item-ai"
|
||||||
|
:key="`ai-${item.msgId}-${item.msg ? item.msg.length : 0}`"
|
||||||
|
:text="item.msg || ''"
|
||||||
|
>
|
||||||
<template #content v-if="item.toolCall">
|
<template #content v-if="item.toolCall">
|
||||||
<QuickBookingComponent v-if="item.toolCall.componentName === CompName.quickBookingCard"/>
|
<QuickBookingComponent
|
||||||
<DiscoveryCardComponent v-else-if="item.toolCall.componentName === CompName.discoveryCard"/>
|
v-if="
|
||||||
<CreateServiceOrder v-else-if="item.toolCall.componentName === CompName.createWorkOrderCard"/>
|
item.toolCall.componentName === CompName.quickBookingCard
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<DiscoveryCardComponent
|
||||||
|
v-else-if="
|
||||||
|
item.toolCall.componentName === CompName.discoveryCard
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<CreateServiceOrder
|
||||||
|
v-else-if="
|
||||||
|
item.toolCall.componentName === CompName.createWorkOrderCard
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<!-- 这个是底部 -->
|
<!-- 这个是底部 -->
|
||||||
<AttachListComponent v-if="item.question" :question="item.question" @replySent="handleReply"/>
|
<AttachListComponent
|
||||||
|
v-if="item.question"
|
||||||
|
:question="item.question"
|
||||||
|
@replySent="handleReply"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ChatCardAI>
|
</ChatCardAI>
|
||||||
</template>
|
</template>
|
||||||
@ -46,15 +77,23 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ChatCardOther class="message-item-other" :text="item.msg">
|
<ChatCardOther class="message-item-other" :text="item.msg">
|
||||||
<ChatMoreTips @replySent="handleReply" :itemList="mainPageDataModel.guideWords"/>
|
<ChatMoreTips
|
||||||
|
@replySent="handleReply"
|
||||||
|
:itemList="mainPageDataModel.guideWords"
|
||||||
|
/>
|
||||||
|
|
||||||
<ActivityListComponent v-if="mainPageDataModel.activityList.length > 0" :activityList="mainPageDataModel.activityList"/>
|
<ActivityListComponent
|
||||||
|
v-if="mainPageDataModel.activityList.length > 0"
|
||||||
|
:activityList="mainPageDataModel.activityList"
|
||||||
|
/>
|
||||||
|
|
||||||
<RecommendPostsComponent v-if="mainPageDataModel.recommendTheme.length > 0" :recommendThemeList="mainPageDataModel.recommendTheme" />
|
<RecommendPostsComponent
|
||||||
|
v-if="mainPageDataModel.recommendTheme.length > 0"
|
||||||
|
:recommendThemeList="mainPageDataModel.recommendTheme"
|
||||||
|
/>
|
||||||
</ChatCardOther>
|
</ChatCardOther>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 输入框区域 -->
|
<!-- 输入框区域 -->
|
||||||
@ -77,253 +116,433 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup >
|
<script setup >
|
||||||
import { onMounted, nextTick, computed } from 'vue'
|
import { onMounted, nextTick, onUnmounted, ref, defineEmits } from "vue";
|
||||||
import { ref } from 'vue'
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import { defineEmits } from 'vue'
|
import {
|
||||||
import { onLoad } from '@dcloudio/uni-app';
|
SCROLL_TO_BOTTOM,
|
||||||
import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE, SEND_COMMAND_TEXT } from '@/constant/constant'
|
RECOMMEND_POSTS_TITLE,
|
||||||
import { MessageRole, MessageType, CompName } from '../../model/ChatModel';
|
SEND_COMMAND_TEXT,
|
||||||
|
} from "@/constant/constant";
|
||||||
import ChatTopWelcome from './ChatTopWelcome.vue';
|
import { WSS_URL } from "@/constant/base";
|
||||||
import ChatTopBgImg from './ChatTopBgImg.vue';
|
import { MessageRole, MessageType, CompName } from "../../model/ChatModel";
|
||||||
import ChatTopNavBar from './ChatTopNavBar.vue';
|
import ChatTopWelcome from "./ChatTopWelcome.vue";
|
||||||
import ChatCardAI from './ChatCardAI.vue';
|
import ChatTopBgImg from "./ChatTopBgImg.vue";
|
||||||
import ChatCardMine from './ChatCardMine.vue';
|
import ChatTopNavBar from "./ChatTopNavBar.vue";
|
||||||
import ChatCardOther from './ChatCardOther.vue';
|
import ChatCardAI from "./ChatCardAI.vue";
|
||||||
import ChatQuickAccess from './ChatQuickAccess.vue';
|
import ChatCardMine from "./ChatCardMine.vue";
|
||||||
import ChatMoreTips from './ChatMoreTips.vue';
|
import ChatCardOther from "./ChatCardOther.vue";
|
||||||
import ChatInputArea from './ChatInputArea.vue'
|
import ChatQuickAccess from "./ChatQuickAccess.vue";
|
||||||
import CommandWrapper from '@/components/CommandWrapper/index.vue'
|
import ChatMoreTips from "./ChatMoreTips.vue";
|
||||||
|
import ChatInputArea from "./ChatInputArea.vue";
|
||||||
import QuickBookingComponent from '../module/booking/QuickBookingComponent.vue'
|
import QuickBookingComponent from "../module/booking/QuickBookingComponent.vue";
|
||||||
import DiscoveryCardComponent from '../module/discovery/DiscoveryCardComponent.vue';
|
import DiscoveryCardComponent from "../module/discovery/DiscoveryCardComponent.vue";
|
||||||
import ActivityListComponent from '../module/banner/ActivityListComponent.vue';
|
import ActivityListComponent from "../module/banner/ActivityListComponent.vue";
|
||||||
import RecommendPostsComponent from '../module/recommend/RecommendPostsComponent.vue';
|
import RecommendPostsComponent from "../module/recommend/RecommendPostsComponent.vue";
|
||||||
import AttachListComponent from '../module/attach/AttachListComponent.vue';
|
import AttachListComponent from "../module/attach/AttachListComponent.vue";
|
||||||
|
import CreateServiceOrder from "@/components/CreateServiceOrder/index.vue";
|
||||||
import CreateServiceOrder from '@/components/CreateServiceOrder/index.vue'
|
import { mainPageData } from "@/request/api/MainPageDataApi";
|
||||||
|
import {
|
||||||
import { agentChatStream, stopAbortTask } from '@/request/api/AgentChatStream';
|
conversationMsgList,
|
||||||
import { mainPageData } from '@/request/api/MainPageDataApi';
|
recentConversation,
|
||||||
import { conversationMsgList, recentConversation } from '@/request/api/ConversationApi';
|
} from "@/request/api/ConversationApi";
|
||||||
|
import WebSocketManager from "@/utils/WebSocketManager";
|
||||||
|
import TypewriterManager from "@/utils/TypewriterManager";
|
||||||
|
import { IdUtils } from "@/utils";
|
||||||
|
|
||||||
/// 导航栏相关
|
/// 导航栏相关
|
||||||
const statusBarHeight = ref(20);
|
const statusBarHeight = ref(20);
|
||||||
/// 输入框组件引用
|
/// 输入框组件引用
|
||||||
const inputAreaRef = ref(null);
|
const inputAreaRef = ref(null);
|
||||||
|
|
||||||
const timer = ref(null)
|
const timer = ref(null);
|
||||||
/// focus时,点击页面的时候不收起键盘
|
/// focus时,点击页面的时候不收起键盘
|
||||||
const holdKeyboard = ref(false)
|
const holdKeyboard = ref(false);
|
||||||
/// 是否在键盘弹出,点击界面时关闭键盘
|
/// 是否在键盘弹出,点击界面时关闭键盘
|
||||||
const holdKeyboardFlag = ref(true)
|
const holdKeyboardFlag = ref(true);
|
||||||
/// 键盘高度
|
|
||||||
const keyboardHeight = ref(0)
|
|
||||||
/// 是否显示键盘
|
/// 是否显示键盘
|
||||||
const isKeyboardShow = ref(false)
|
const isKeyboardShow = ref(false);
|
||||||
|
|
||||||
///(控制滚动位置)
|
///(控制滚动位置)
|
||||||
const scrollTop = ref(99999);
|
const scrollTop = ref(99999);
|
||||||
|
|
||||||
/// 会话列表
|
/// 会话列表
|
||||||
const chatMsgList = ref([])
|
const chatMsgList = ref([]);
|
||||||
/// 输入口的输入消息
|
/// 输入口的输入消息
|
||||||
const inputMessage = ref('')
|
const inputMessage = ref("");
|
||||||
/// 加载中
|
|
||||||
let currentAIMsgIndex = 0
|
|
||||||
|
|
||||||
/// 从个渠道获取如二维,没有的时候就返回首页的数据
|
/// 从个渠道获取如二维,没有的时候就返回首页的数据
|
||||||
const sceneId = ref('')
|
const sceneId = ref("");
|
||||||
/// agentId 首页接口中获取
|
/// agentId 首页接口中获取
|
||||||
const agentId = ref('1')
|
const agentId = ref("1");
|
||||||
/// 会话ID 历史数据接口中获取
|
/// 会话ID 历史数据接口中获取
|
||||||
const conversationId = ref('')
|
const conversationId = ref("");
|
||||||
/// 首页的数据
|
/// 首页的数据
|
||||||
const mainPageDataModel = ref({})
|
const mainPageDataModel = ref({});
|
||||||
|
|
||||||
// 会话进行中标志
|
// 会话进行中标志
|
||||||
const isSessionActive = ref(false);
|
const isSessionActive = ref(false);
|
||||||
/// 指令
|
/// 指令
|
||||||
let commonType = ''
|
let commonType = "";
|
||||||
|
|
||||||
|
|
||||||
|
// WebSocket 相关
|
||||||
|
let webSocketManager = null;
|
||||||
|
// 打字机管理器
|
||||||
|
let typewriterManager = null;
|
||||||
|
// 当前会话的消息ID,用于保持发送和终止的messageId一致
|
||||||
|
let currentSessionMessageId = null;
|
||||||
|
|
||||||
|
let loadingTimer = null;
|
||||||
|
|
||||||
// 打开抽屉
|
// 打开抽屉
|
||||||
const emits = defineEmits(['openDrawer'])
|
const emits = defineEmits(["openDrawer"]);
|
||||||
const openDrawer = () => {
|
const openDrawer = () => emits("openDrawer");
|
||||||
emits('openDrawer')
|
|
||||||
console.log('=============打开抽屉')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
clearTimeout(timer.value)
|
clearTimeout(timer.value);
|
||||||
timer.value = setTimeout(() => {
|
timer.value = setTimeout(() => {
|
||||||
// 键盘弹出时点击界面则关闭键盘
|
// 键盘弹出时点击界面则关闭键盘
|
||||||
if (holdKeyboardFlag.value && isKeyboardShow.value) {
|
if (holdKeyboardFlag.value && isKeyboardShow.value) {
|
||||||
uni.hideKeyboard()
|
uni.hideKeyboard();
|
||||||
}
|
|
||||||
holdKeyboardFlag.value = true
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
|
holdKeyboardFlag.value = true;
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
// 点击输入框、发送按钮时,不收键盘
|
// 点击输入框、发送按钮时,不收键盘
|
||||||
const handleNoHideKeyboard = () => {
|
const handleNoHideKeyboard = () => (holdKeyboardFlag.value = false);
|
||||||
holdKeyboardFlag.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 键盘弹起事件
|
// 键盘弹起事件
|
||||||
const handleKeyboardShow = (height) => {
|
const handleKeyboardShow = () => {
|
||||||
keyboardHeight.value = height
|
isKeyboardShow.value = true;
|
||||||
isKeyboardShow.value = true
|
holdKeyboard.value = true;
|
||||||
holdKeyboard.value = true
|
|
||||||
// 键盘弹起时调整聊天内容的底部边距并滚动到底部
|
// 键盘弹起时调整聊天内容的底部边距并滚动到底部
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottom()
|
scrollToBottom();
|
||||||
}, 150)
|
}, 150);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 键盘收起事件
|
// 键盘收起事件
|
||||||
const handleKeyboardHide = () => {
|
const handleKeyboardHide = () => {
|
||||||
keyboardHeight.value = 0
|
isKeyboardShow.value = false;
|
||||||
isKeyboardShow.value = false
|
holdKeyboard.value = false;
|
||||||
holdKeyboard.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 滚动到底部
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
scrollTop.value += 9999;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/// 延迟在滚到底
|
|
||||||
const setTimeoutScrollToBottom = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 发送普通消息
|
|
||||||
const handleReply = (text) => {
|
|
||||||
sendMessage(text)
|
|
||||||
setTimeoutScrollToBottom()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 是发送指令
|
// 滚动到底部 - 优化版本,确保打字机效果始终可见
|
||||||
const handleReplyInstruct = (item) => {
|
const scrollToBottom = () => {
|
||||||
if(item.type === 'MyOrder') {
|
nextTick(() => {
|
||||||
/// 订单
|
scrollTop.value = 99999;
|
||||||
uni.navigateTo({
|
// 强制触发滚动更新
|
||||||
url: '/pages/order/list'
|
setTimeout(() => {
|
||||||
})
|
scrollTop.value = scrollTop.value + 1;
|
||||||
return
|
}, 10);
|
||||||
}
|
});
|
||||||
commonType = item.type
|
};
|
||||||
sendMessage(item.title, true)
|
|
||||||
setTimeoutScrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 输入区的发送消息事件
|
// 延时滚动
|
||||||
|
const setTimeoutScrollToBottom = () => setTimeout(() => scrollToBottom(), 100);
|
||||||
|
|
||||||
|
// 发送普通消息
|
||||||
|
const handleReply = (text) => {
|
||||||
|
// 重置消息状态,准备接收新的AI回复
|
||||||
|
resetMessageState();
|
||||||
|
sendMessage(text);
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 是发送指令
|
||||||
|
const handleReplyInstruct = (item) => {
|
||||||
|
if (item.type === "MyOrder") {
|
||||||
|
// 订单
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/order/list",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commonType = item.type;
|
||||||
|
// 重置消息状态,准备接收新的AI回复
|
||||||
|
resetMessageState();
|
||||||
|
sendMessage(item.title, true);
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 输入区的发送消息事件
|
||||||
const sendMessageAction = (inputText) => {
|
const sendMessageAction = (inputText) => {
|
||||||
console.log("输入消息:", inputText)
|
console.log("输入消息:", inputText);
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim()) return;
|
||||||
handleNoHideKeyboard()
|
handleNoHideKeyboard();
|
||||||
sendMessage(inputText)
|
|
||||||
|
currentSessionMessageId = IdUtils.generateMessageId();
|
||||||
|
// 重置消息状态,准备接收新的AI回复
|
||||||
|
resetMessageState();
|
||||||
|
|
||||||
|
sendMessage(inputText);
|
||||||
// 发送消息后保持键盘状态
|
// 发送消息后保持键盘状态
|
||||||
if (holdKeyboard.value && inputAreaRef.value) {
|
if (holdKeyboard.value && inputAreaRef.value) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
inputAreaRef.value.focusInput()
|
inputAreaRef.value.focusInput();
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
|
||||||
setTimeoutScrollToBottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
uni.getSystemInfo({
|
uni.getSystemInfo({
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
statusBarHeight.value = res.statusBarHeight || 20;
|
statusBarHeight.value = res.statusBarHeight || 20;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
getMainPageData()
|
try {
|
||||||
await loadRecentConversation()
|
getMainPageData();
|
||||||
loadConversationMsgList()
|
await loadRecentConversation();
|
||||||
addNoticeListener()
|
loadConversationMsgList();
|
||||||
})
|
addNoticeListener();
|
||||||
|
initTypewriterManager();
|
||||||
|
initWebSocket();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("页面初始化错误:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化WebSocket
|
||||||
|
const initWebSocket = () => {
|
||||||
|
// 清理旧实例
|
||||||
|
if (webSocketManager) {
|
||||||
|
webSocketManager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置的WebSocket服务器地址
|
||||||
|
const token = uni.getStorageSync("token");
|
||||||
|
const wsUrl = `${WSS_URL}?access_token=${token}`;
|
||||||
|
|
||||||
|
// 初始化WebSocket管理器
|
||||||
|
webSocketManager = new WebSocketManager({
|
||||||
|
wsUrl: wsUrl,
|
||||||
|
reconnectInterval: 3000, // 重连间隔
|
||||||
|
maxReconnectAttempts: 5, // 最大重连次数
|
||||||
|
heartbeatInterval: 30000, // 心跳间隔
|
||||||
|
|
||||||
|
// 连接成功回调
|
||||||
|
onOpen: (event) => {
|
||||||
|
// 重置会话状态
|
||||||
|
isSessionActive.value = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 连接断开回调
|
||||||
|
onClose: (event) => {
|
||||||
|
console.error("WebSocket连接断开:", event);
|
||||||
|
// 停止当前会话
|
||||||
|
isSessionActive.value = false;
|
||||||
|
// 停止打字机效果
|
||||||
|
if (typewriterManager) {
|
||||||
|
typewriterManager.stopTypewriter();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 错误回调
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("WebSocket错误:", error);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 消息回调
|
||||||
|
onMessage: (data) => {
|
||||||
|
console.log("onMessage:", data);
|
||||||
|
handleWebSocketMessage(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取会话ID回调 (用于心跳检测)
|
||||||
|
getConversationId: () => conversationId.value,
|
||||||
|
|
||||||
|
// 获取代理ID回调 (用于心跳检测)
|
||||||
|
getAgentId: () => agentId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化连接
|
||||||
|
webSocketManager.connect().catch((error) => {
|
||||||
|
console.error("WebSocket连接失败:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理WebSocket消息
|
||||||
|
const handleWebSocketMessage = (data) => {
|
||||||
|
const aiMsgIndex = chatMsgList.value.length - 1;
|
||||||
|
if (!chatMsgList.value[aiMsgIndex] || aiMsgIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("处理WebSocket消息:", data);
|
||||||
|
|
||||||
|
// 确保消息内容是字符串类型
|
||||||
|
if (data.content && typeof data.content !== "string") {
|
||||||
|
data.content = String(data.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理content分片内容
|
||||||
|
if (data.content) {
|
||||||
|
// 使用打字机管理器添加内容
|
||||||
|
if (typewriterManager) {
|
||||||
|
typewriterManager.addContent(data.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保新内容到达时页面保持在底部
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理完成状态
|
||||||
|
if (data.finish) {
|
||||||
|
clearInterval(loadingTimer);
|
||||||
|
loadingTimer = null;
|
||||||
|
// 等待打字机完成后处理其他数据
|
||||||
|
const waitForTypingComplete = () => {
|
||||||
|
const status = typewriterManager
|
||||||
|
? typewriterManager.getStatus()
|
||||||
|
: { isTyping: false };
|
||||||
|
if (status.isTyping) {
|
||||||
|
setTimeout(waitForTypingComplete, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理toolCall
|
||||||
|
if (data.toolCall) {
|
||||||
|
chatMsgList.value[aiMsgIndex].toolCall = data.toolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理question
|
||||||
|
if (data.question && data.question.length > 0) {
|
||||||
|
chatMsgList.value[aiMsgIndex].question = data.question;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置会话状态
|
||||||
|
isSessionActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
waitForTypingComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化打字机管理器
|
||||||
|
const initTypewriterManager = () => {
|
||||||
|
if (typewriterManager) {
|
||||||
|
typewriterManager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
typewriterManager = new TypewriterManager({
|
||||||
|
typingSpeed: 50,
|
||||||
|
cursorText: '<text class="typing-cursor">|</text>',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置回调函数
|
||||||
|
typewriterManager.setCallbacks({
|
||||||
|
// 每个字符打字时的回调
|
||||||
|
onCharacterTyped: (displayedContent) => {
|
||||||
|
// 立即滚动到底部,确保打字过程中始终可见
|
||||||
|
nextTick(() => {
|
||||||
|
scrollTop.value = 99999;
|
||||||
|
// 双重保险,确保滚动生效
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollTop.value = 99999 + Math.random();
|
||||||
|
}, 5);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 内容更新时的回调
|
||||||
|
onContentUpdate: (content) => {
|
||||||
|
const aiMsgIndex = chatMsgList.value.length - 1;
|
||||||
|
if (aiMsgIndex >= 0 && chatMsgList.value[aiMsgIndex]) {
|
||||||
|
chatMsgList.value[aiMsgIndex].msg = content;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 打字完成时的回调
|
||||||
|
onTypingComplete: (finalContent) => {
|
||||||
|
const aiMsgIndex = chatMsgList.value.length - 1;
|
||||||
|
if (aiMsgIndex >= 0 && chatMsgList.value[aiMsgIndex]) {
|
||||||
|
chatMsgList.value[aiMsgIndex].msg = finalContent;
|
||||||
|
}
|
||||||
|
// 打字完成后最后一次滚动到底部
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置消息状态
|
||||||
|
const resetMessageState = () => {
|
||||||
|
if (typewriterManager) {
|
||||||
|
typewriterManager.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addNoticeListener = () => {
|
const addNoticeListener = () => {
|
||||||
uni.$on(SCROLL_TO_BOTTOM, (value) => {
|
uni.$on(SCROLL_TO_BOTTOM, () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottom()
|
scrollToBottom();
|
||||||
}, 200)
|
}, 200);
|
||||||
})
|
});
|
||||||
|
|
||||||
uni.$on(RECOMMEND_POSTS_TITLE, (value) => {
|
uni.$on(RECOMMEND_POSTS_TITLE, (value) => {
|
||||||
console.log('RECOMMEND_POSTS_TITLE:', value)
|
console.log("RECOMMEND_POSTS_TITLE:", value);
|
||||||
if (value && value.length > 0) {
|
if (value && value.length > 0) {
|
||||||
handleReply(value)
|
handleReply(value);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
uni.$on(SEND_COMMAND_TEXT, (value) => {
|
uni.$on(SEND_COMMAND_TEXT, (value) => {
|
||||||
console.log('SEND_COMMAND_TEXT:', value)
|
console.log("SEND_COMMAND_TEXT:", value);
|
||||||
if (value && value.length > 0) {
|
if (value && value.length > 0) {
|
||||||
commonType = 'Command.quickBooking'
|
commonType = "Command.quickBooking";
|
||||||
sendMessage(value, true)
|
sendMessage(value, true);
|
||||||
setTimeoutScrollToBottom()
|
scrollToBottom();
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/// 获取最近一次的会话id
|
// 获取最近一次的会话id
|
||||||
const loadRecentConversation = async () => {
|
const loadRecentConversation = async () => {
|
||||||
const res = await recentConversation()
|
const res = await recentConversation();
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
conversationId.value = res.data.conversationId
|
conversationId.value = res.data.conversationId;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// 加载历史消息的数据
|
// 加载历史消息的数据
|
||||||
let historyCurrentPageNum = 1
|
let historyCurrentPageNum = 1;
|
||||||
const loadConversationMsgList = async () => {
|
const loadConversationMsgList = async () => {
|
||||||
const args = { pageNum: historyCurrentPageNum++, pageSize : 10, conversationId: conversationId.value }
|
const args = {
|
||||||
const res = await conversationMsgList(args)
|
pageNum: historyCurrentPageNum++,
|
||||||
}
|
pageSize: 10,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
};
|
||||||
|
const res = await conversationMsgList(args);
|
||||||
|
};
|
||||||
|
|
||||||
/// 获取首页数据
|
// 获取首页数据
|
||||||
const getMainPageData = async () => {
|
const getMainPageData = async () => {
|
||||||
const res = await mainPageData(sceneId.value)
|
const res = await mainPageData(sceneId.value);
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
mainPageDataModel.value = res.data
|
mainPageDataModel.value = res.data;
|
||||||
agentId.value = res.data.agentId
|
agentId.value = res.data.agentId;
|
||||||
initData()
|
initData();
|
||||||
scrollToBottom()
|
setTimeoutScrollToBottom();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// 初始化数据 首次数据加载的时候
|
// 初始化数据 首次数据加载的时候
|
||||||
const initData = () => {
|
const initData = () => {
|
||||||
const msg = {
|
const msg = {
|
||||||
msgId: `msg_${0}`,
|
msgId: `msg_${0}`,
|
||||||
msgType: MessageRole.OTHER,
|
msgType: MessageRole.OTHER,
|
||||||
msg: '',
|
msg: "",
|
||||||
}
|
};
|
||||||
chatMsgList.value.push(msg)
|
chatMsgList.value.push(msg);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// 发送消息的参数拼接
|
||||||
/// 发送消息的参数拼接
|
|
||||||
const sendMessage = (message, isInstruct = false) => {
|
const sendMessage = (message, isInstruct = false) => {
|
||||||
if (isSessionActive.value) {
|
if (isSessionActive.value) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '请等待当前回复完成',
|
title: "请等待当前回复完成",
|
||||||
icon: 'none'
|
icon: "none",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -334,177 +553,147 @@
|
|||||||
msg: message,
|
msg: message,
|
||||||
msgContent: {
|
msgContent: {
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
text: message
|
text: message,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
chatMsgList.value.push(newMsg)
|
chatMsgList.value.push(newMsg);
|
||||||
inputMessage.value = '';
|
inputMessage.value = "";
|
||||||
sendChat(message, isInstruct)
|
sendChat(message, isInstruct);
|
||||||
console.log("发送的新消息:",JSON.stringify(newMsg))
|
console.log("发送的新消息:", JSON.stringify(newMsg));
|
||||||
}
|
};
|
||||||
|
|
||||||
/// 打字机效果实现的变量
|
// 通用WebSocket消息发送函数
|
||||||
let loadingTimer = null;
|
const sendWebSocketMessage = (messageType, messageContent, options = {}) => {
|
||||||
let typeWriterTimer = null;
|
console.error("WebSocket未连接");
|
||||||
let aiMsgBuffer = ''; // 全局缓冲区
|
|
||||||
let isTyping = false; // 是否正在打字
|
|
||||||
let currentMessageId = ''
|
|
||||||
|
|
||||||
/// 发送获取AI聊天消息
|
|
||||||
const sendChat = (message, isInstruct = false) => {
|
|
||||||
currentMessageId = 'mid' + new Date().getTime()
|
|
||||||
const args = {
|
const args = {
|
||||||
conversationId: conversationId.value,
|
conversationId: conversationId.value,
|
||||||
agentId: agentId.value,
|
agentId: agentId.value,
|
||||||
messageType: isInstruct ? 1 : 0,
|
messageType: String(messageType), // 消息类型 0-对话 1-指令 2-中断停止 3-心跳检测
|
||||||
messageContent: isInstruct ? commonType : message,
|
messageContent: messageContent,
|
||||||
messageId: currentMessageId
|
messageId: currentSessionMessageId,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
webSocketManager.sendMessage(args);
|
||||||
|
console.log(`WebSocket消息已发送 [类型:${messageType}]:`, args);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("发送WebSocket消息失败:", error);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送获取AI聊天消息
|
||||||
|
const sendChat = (message, isInstruct = false) => {
|
||||||
|
if (!webSocketManager || !webSocketManager.isConnected()) {
|
||||||
|
console.error("WebSocket未连接");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageType = isInstruct ? 1 : 0;
|
||||||
|
const messageContent = isInstruct ? commonType : message;
|
||||||
|
|
||||||
|
// 重置消息状态,为新消息做准备
|
||||||
|
resetMessageState();
|
||||||
|
|
||||||
// 插入AI消息
|
// 插入AI消息
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
msgId: `msg_${chatMsgList.value.length}`,
|
msgId: `msg_${chatMsgList.value.length}`,
|
||||||
msgType: MessageRole.AI,
|
msgType: MessageRole.AI,
|
||||||
msg: '加载中.',
|
msg: "加载中.",
|
||||||
|
isLoading: true,
|
||||||
msgContent: {
|
msgContent: {
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
url: ''
|
url: "",
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
chatMsgList.value.push(aiMsg)
|
chatMsgList.value.push(aiMsg);
|
||||||
const aiMsgIndex = chatMsgList.value.length - 1
|
const aiMsgIndex = chatMsgList.value.length - 1;
|
||||||
currentAIMsgIndex = aiMsgIndex
|
|
||||||
|
|
||||||
// 动态加载中动画
|
// 动态加载中动画
|
||||||
let dotCount = 1;
|
let dotCount = 1;
|
||||||
loadingTimer && clearInterval(loadingTimer);
|
loadingTimer && clearInterval(loadingTimer);
|
||||||
loadingTimer = setInterval(() => {
|
loadingTimer = setInterval(() => {
|
||||||
dotCount = dotCount % 3 + 1;
|
dotCount = (dotCount % 3) + 1;
|
||||||
chatMsgList.value[aiMsgIndex].msg = '加载中' + '.'.repeat(dotCount);
|
chatMsgList.value[aiMsgIndex].msg = "加载中" + ".".repeat(dotCount);
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
aiMsgBuffer = '';
|
// 发送消息
|
||||||
isTyping = false;
|
const success = sendWebSocketMessage(messageType, messageContent);
|
||||||
if (typeWriterTimer) {
|
if (!success) {
|
||||||
clearTimeout(typeWriterTimer);
|
chatMsgList.value[aiMsgIndex].msg = "发送消息失败,请重试";
|
||||||
typeWriterTimer = null;
|
chatMsgList.value[aiMsgIndex].isLoading = false;
|
||||||
}
|
|
||||||
|
|
||||||
// 流式接收内容
|
|
||||||
const promise = agentChatStream(args, (chunk) => {
|
|
||||||
// console.log('分段内容:', chunk)
|
|
||||||
if (chunk && chunk.error) {
|
|
||||||
chatMsgList.value[aiMsgIndex].msg = '请求错误,请重试';
|
|
||||||
clearInterval(loadingTimer);
|
|
||||||
loadingTimer = null;
|
|
||||||
isTyping = false;
|
|
||||||
typeWriterTimer = null;
|
|
||||||
isSessionActive.value = false; // 出错也允许再次发送
|
|
||||||
console.error('流式错误:', chunk.message, chunk.detail);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk && chunk.content) {
|
|
||||||
// 收到内容,停止动画
|
|
||||||
if (loadingTimer) {
|
|
||||||
clearInterval(loadingTimer);
|
|
||||||
loadingTimer = null;
|
|
||||||
}
|
|
||||||
// 把新内容追加到缓冲区
|
|
||||||
aiMsgBuffer += chunk.content;
|
|
||||||
|
|
||||||
// 启动打字机(只启动一次)
|
|
||||||
if (!isTyping) {
|
|
||||||
isTyping = true;
|
|
||||||
chatMsgList.value[aiMsgIndex].msg = '';
|
|
||||||
typeWriter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chunk && chunk.finish) {
|
|
||||||
// 结尾处理:确保剩余内容全部输出
|
|
||||||
const finishInterval = setInterval(() => {
|
|
||||||
if (aiMsgBuffer.length === 0) {
|
|
||||||
clearInterval(finishInterval);
|
|
||||||
clearInterval(loadingTimer);
|
|
||||||
loadingTimer = null;
|
|
||||||
isTyping = false;
|
|
||||||
typeWriterTimer = null;
|
|
||||||
|
|
||||||
// 补全:如果消息内容还停留在'加载中.'或为空,则给出友好提示
|
|
||||||
const msg = chatMsgList.value[aiMsgIndex].msg;
|
|
||||||
console.log('msg:', msg)
|
|
||||||
if (!msg || msg === '加载中.' || msg.startsWith('加载中')) {
|
|
||||||
chatMsgList.value[aiMsgIndex].msg = '未获取到内容,请重试';
|
|
||||||
if(chunk.toolCall) {
|
|
||||||
chatMsgList.value[aiMsgIndex].msg = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果有组件
|
|
||||||
if(chunk.toolCall) {
|
|
||||||
console.log('chunk.toolCall:', chunk.toolCall)
|
|
||||||
chatMsgList.value[aiMsgIndex].toolCall = chunk.toolCall
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有问题,则设置问题
|
|
||||||
if(chunk.question && chunk.question.length > 0) {
|
|
||||||
chatMsgList.value[aiMsgIndex].question = chunk.question
|
|
||||||
}
|
|
||||||
|
|
||||||
isSessionActive.value = false;
|
isSessionActive.value = false;
|
||||||
scrollToBottom();
|
resetMessageState();
|
||||||
}
|
}
|
||||||
}, 50);
|
};
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// 可选:处理Promise完成/失败, 已经在回调中处理数据,此处无需再处理
|
|
||||||
promise.then(data => {
|
|
||||||
console.log('请求完成');
|
|
||||||
}).catch(err => {
|
|
||||||
isSessionActive.value = false; // 出错也允许再次发送
|
|
||||||
console.log('error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 打字机函数
|
|
||||||
function typeWriter() {
|
|
||||||
if (aiMsgBuffer.length > 0) {
|
|
||||||
chatMsgList.value[aiMsgIndex].msg += aiMsgBuffer[0];
|
|
||||||
aiMsgBuffer = aiMsgBuffer.slice(1);
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
});
|
|
||||||
typeWriterTimer = setTimeout(typeWriter, 30);
|
|
||||||
} else {
|
|
||||||
// 等待新内容到来,不结束
|
|
||||||
typeWriterTimer = setTimeout(typeWriter, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 停止请求函数
|
// 停止请求函数
|
||||||
const stopRequest = () => {
|
const stopRequest = () => {
|
||||||
stopAbortTask()
|
console.log("停止请求");
|
||||||
// 重置状态
|
|
||||||
isSessionActive.value = false;
|
// 发送中断消息给服务器 (messageType=2)
|
||||||
const msg = chatMsgList.value[currentAIMsgIndex].msg;
|
sendWebSocketMessage(2, "stop_request", { silent: true });
|
||||||
if (!msg || msg === '加载中.' || msg.startsWith('加载中')) {
|
|
||||||
chatMsgList.value[currentAIMsgIndex].msg = '已终止请求,请重试';
|
// 停止打字机效果
|
||||||
}
|
if (typewriterManager) {
|
||||||
// 清除计时器
|
typewriterManager.stopTypewriter();
|
||||||
if (loadingTimer) {
|
|
||||||
clearInterval(loadingTimer);
|
|
||||||
loadingTimer = null;
|
|
||||||
}
|
|
||||||
if (typeWriterTimer) {
|
|
||||||
clearTimeout(typeWriterTimer);
|
|
||||||
typeWriterTimer = null;
|
|
||||||
}
|
|
||||||
setTimeoutScrollToBottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置会话状态和消息状态
|
||||||
|
isSessionActive.value = false;
|
||||||
|
resetMessageState();
|
||||||
|
|
||||||
|
// 更新最后一条AI消息的状态
|
||||||
|
const aiMsgIndex = chatMsgList.value.length - 1;
|
||||||
|
if (
|
||||||
|
chatMsgList.value[aiMsgIndex] &&
|
||||||
|
chatMsgList.value[aiMsgIndex].msgType === MessageRole.AI
|
||||||
|
) {
|
||||||
|
chatMsgList.value[aiMsgIndex].isLoading = false;
|
||||||
|
if (
|
||||||
|
!chatMsgList.value[aiMsgIndex].msg ||
|
||||||
|
chatMsgList.value[aiMsgIndex].msg.startsWith("加载中")
|
||||||
|
) {
|
||||||
|
chatMsgList.value[aiMsgIndex].msg = "请求已停止";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("请求已停止,状态已重置");
|
||||||
|
|
||||||
|
setTimeoutScrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件销毁时清理资源
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.$off(SCROLL_TO_BOTTOM);
|
||||||
|
uni.$off(RECOMMEND_POSTS_TITLE);
|
||||||
|
uni.$off(SEND_COMMAND_TEXT);
|
||||||
|
|
||||||
|
// 清理WebSocket连接
|
||||||
|
if (webSocketManager) {
|
||||||
|
webSocketManager.destroy();
|
||||||
|
webSocketManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理打字机管理器
|
||||||
|
if (typewriterManager) {
|
||||||
|
typewriterManager.destroy();
|
||||||
|
typewriterManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置消息状态
|
||||||
|
resetMessageState();
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
timer.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@ -149,3 +149,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -152,20 +152,64 @@ const handleConfirmOrder = async (orderData) => {
|
|||||||
const res = await orderPay(params);
|
const res = await orderPay(params);
|
||||||
console.log("确认订单---2:", res);
|
console.log("确认订单---2:", res);
|
||||||
|
|
||||||
// 仅作为示例,非真实参数信息。
|
// 检查接口返回数据
|
||||||
|
if (!res || !res.data) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "订单创建失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = res;
|
||||||
|
const { nonceStr, packageVal, paySign, signType, timeStamp } = data;
|
||||||
|
|
||||||
|
// 验证支付参数是否完整
|
||||||
|
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
|
||||||
|
console.error("支付参数不完整:", {
|
||||||
|
nonceStr: !!nonceStr,
|
||||||
|
packageVal: !!packageVal,
|
||||||
|
paySign: !!paySign,
|
||||||
|
signType: !!signType,
|
||||||
|
timeStamp: !!timeStamp,
|
||||||
|
});
|
||||||
|
uni.showToast({
|
||||||
|
title: "支付参数错误,请重试",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信支付
|
||||||
uni.requestPayment({
|
uni.requestPayment({
|
||||||
provider: "wxpay",
|
provider: "wxpay",
|
||||||
...res.data,
|
timeStamp: String(timeStamp), // 确保为字符串类型
|
||||||
|
nonceStr: String(nonceStr),
|
||||||
|
package: String(packageVal), // 确保为字符串类型
|
||||||
|
signType: String(signType),
|
||||||
|
paySign: String(paySign),
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
console.log("success:" + JSON.stringify(res));
|
console.log("支付成功:" + JSON.stringify(res));
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "支付成功",
|
title: "支付成功",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
success: () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/order/list",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.log("fail:" + JSON.stringify(err));
|
console.error("支付失败:" + JSON.stringify(err));
|
||||||
|
uni.showToast({
|
||||||
|
title: "支付失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,15 +12,7 @@
|
|||||||
<!-- 弹窗内容 -->
|
<!-- 弹窗内容 -->
|
||||||
<view class="popup-content">
|
<view class="popup-content">
|
||||||
<view class="content-text">
|
<view class="content-text">
|
||||||
<text class="main-text">
|
<zero-markdown-view :markdown="agreement" />
|
||||||
您在使用朵花温泉服务前,请仔细阅读用户隐私条款及用户注册须知,当您点击同意,即表示您已经理解并同意该条款,该条款将构成对您具有法律约束力的文件。
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="notice-text">
|
|
||||||
<text>
|
|
||||||
请您注意:如果您不同意上述用户注册须知、隐私政策或其中任何约定,请您停止注册。如您阅读并点击同意即表示您已充分阅读理解并接受其全部内容,并表明您也同意朵花温泉可以依据以上隐私政策来处理您的个人信息。
|
|
||||||
</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -45,6 +37,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "温馨提示",
|
default: "温馨提示",
|
||||||
},
|
},
|
||||||
|
agreement: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events定义
|
// Events定义
|
||||||
|
|||||||
@ -39,32 +39,20 @@
|
|||||||
|
|
||||||
// 弹窗内容
|
// 弹窗内容
|
||||||
.popup-content {
|
.popup-content {
|
||||||
padding: 20px;
|
padding: 12px;
|
||||||
|
max-height: 400px; // 设置最大高度
|
||||||
|
overflow-y: auto; // 启用垂直滚动
|
||||||
|
|
||||||
.content-text {
|
// 自定义滚动条样式
|
||||||
margin-bottom: 16px;
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
.main-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
line-height: 22px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice-text {
|
|
||||||
text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #333;
|
|
||||||
line-height: 20px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按钮区域
|
// 按钮区域
|
||||||
.button-area {
|
.button-area {
|
||||||
padding: 0 20px 20px 20px;
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -55,20 +55,33 @@
|
|||||||
</CheckBox>
|
</CheckBox>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<AgreePopup ref="agreePopup" :visible="visible" @close="visible = false" />
|
<AgreePopup
|
||||||
|
ref="agreePopup"
|
||||||
|
:visible="visible"
|
||||||
|
:agreement="computedAgreement"
|
||||||
|
@close="visible = false"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import CheckBox from "@/components/CheckBox/index.vue";
|
|
||||||
import AgreePopup from "./components/AgreePopup/index.vue";
|
|
||||||
import { loginAuth, bindPhone, checkPhone } from "@/manager/LoginManager";
|
import { loginAuth, bindPhone, checkPhone } from "@/manager/LoginManager";
|
||||||
|
import {
|
||||||
|
getServiceAgreement,
|
||||||
|
getPrivacyAgreement,
|
||||||
|
} from "@/request/api/LoginApi";
|
||||||
import { goHome } from "@/hooks/useGoHome";
|
import { goHome } from "@/hooks/useGoHome";
|
||||||
import loginBg from "./images/bg.png";
|
import loginBg from "./images/bg.png";
|
||||||
|
import CheckBox from "@/components/CheckBox/index.vue";
|
||||||
|
import AgreePopup from "./components/AgreePopup/index.vue";
|
||||||
|
|
||||||
const isAgree = ref(false);
|
const isAgree = ref(false);
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
|
const serviceAgreement = ref("");
|
||||||
|
const privacyAgreement = ref("");
|
||||||
|
// 协议类型
|
||||||
|
const AgreeType = ref("service");
|
||||||
|
|
||||||
// 同意隐私协议并获取手机号
|
// 同意隐私协议并获取手机号
|
||||||
const handleAgreeAndGetPhone = () => {
|
const handleAgreeAndGetPhone = () => {
|
||||||
@ -111,7 +124,33 @@ const onLogin = (e) => {
|
|||||||
// 处理同意协议点击事件
|
// 处理同意协议点击事件
|
||||||
const handleAgreeClick = (type) => {
|
const handleAgreeClick = (type) => {
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
|
AgreeType.value = type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 计算协议类型
|
||||||
|
const computedAgreement = computed(() => {
|
||||||
|
if (AgreeType.value === "service") {
|
||||||
|
return serviceAgreement.value;
|
||||||
|
} else {
|
||||||
|
return privacyAgreement.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取服务协议数据
|
||||||
|
const getServiceAgreementData = async () => {
|
||||||
|
const { data } = await getServiceAgreement();
|
||||||
|
serviceAgreement.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
getServiceAgreementData();
|
||||||
|
|
||||||
|
// 获取隐私协议数据
|
||||||
|
const getPrivacyAgreementData = async () => {
|
||||||
|
const { data } = await getPrivacyAgreement();
|
||||||
|
privacyAgreement.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
getPrivacyAgreementData();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@ -3,24 +3,40 @@ import request from "../base/request";
|
|||||||
const wxLogin = (args) => {
|
const wxLogin = (args) => {
|
||||||
const config = {
|
const config = {
|
||||||
header: {
|
header: {
|
||||||
'Authorization': 'Basic Y3VzdG9tOmN1c3RvbQ==', // 可在此动态设置 token
|
Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==", // 可在此动态设置 token
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
uni.setStorageSync('token', '')
|
uni.setStorageSync("token", "");
|
||||||
|
|
||||||
return request.post('/auth/oauth2/token', args, config);
|
return request.post("/auth/oauth2/token", args, config);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 绑定用户手机号
|
// 绑定用户手机号
|
||||||
const bindUserPhone = (args) => {
|
const bindUserPhone = (args) => {
|
||||||
return request.post('/hotelBiz/user/bindUserPhone', args);
|
return request.post("/hotelBiz/user/bindUserPhone", args);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 检测用户是否绑定手机号
|
// 检测用户是否绑定手机号
|
||||||
const checkUserPhone = (args) => {
|
const checkUserPhone = (args) => {
|
||||||
return request.get('/hotelBiz/user/checkUserHasBindPhone', args);
|
return request.get("/hotelBiz/user/checkUserHasBindPhone", args);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { wxLogin, bindUserPhone, checkUserPhone }
|
// 获取服务协议
|
||||||
|
const getServiceAgreement = (args) => {
|
||||||
|
return request.get("/hotelBiz/mainScene/serviceAgreement", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取隐私协议
|
||||||
|
const getPrivacyAgreement = (args) => {
|
||||||
|
return request.get("/hotelBiz/mainScene/privacyPolicy", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
wxLogin,
|
||||||
|
bindUserPhone,
|
||||||
|
checkUserPhone,
|
||||||
|
getServiceAgreement,
|
||||||
|
getPrivacyAgreement,
|
||||||
|
};
|
||||||
|
|||||||
172
utils/TypewriterManager.js
Normal file
172
utils/TypewriterManager.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 打字机效果管理器
|
||||||
|
* 负责管理打字机效果的启动、停止、重置等功能
|
||||||
|
*/
|
||||||
|
class TypewriterManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// 配置选项
|
||||||
|
this.options = {
|
||||||
|
typingSpeed: 50, // 打字速度(毫秒)
|
||||||
|
cursorText: '<text class="typing-cursor">|</text>', // 光标样式
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态变量
|
||||||
|
this.currentMessageContent = ""; // 当前消息的完整内容
|
||||||
|
this.displayedContent = ""; // 已显示的内容
|
||||||
|
this.typewriterTimer = null; // 打字机定时器
|
||||||
|
this.isTyping = false; // 是否正在打字
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
this.onCharacterTyped = null; // 每个字符打字时的回调
|
||||||
|
this.onTypingComplete = null; // 打字完成时的回调
|
||||||
|
this.onContentUpdate = null; // 内容更新时的回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置回调函数
|
||||||
|
* @param {Object} callbacks - 回调函数对象
|
||||||
|
* @param {Function} callbacks.onCharacterTyped - 每个字符打字时的回调
|
||||||
|
* @param {Function} callbacks.onTypingComplete - 打字完成时的回调
|
||||||
|
* @param {Function} callbacks.onContentUpdate - 内容更新时的回调
|
||||||
|
*/
|
||||||
|
setCallbacks(callbacks) {
|
||||||
|
this.onCharacterTyped = callbacks.onCharacterTyped || null;
|
||||||
|
this.onTypingComplete = callbacks.onTypingComplete || null;
|
||||||
|
this.onContentUpdate = callbacks.onContentUpdate || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加内容到当前消息
|
||||||
|
* @param {string} content - 要添加的内容
|
||||||
|
*/
|
||||||
|
addContent(content) {
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
content = String(content);
|
||||||
|
}
|
||||||
|
this.currentMessageContent += content;
|
||||||
|
|
||||||
|
// 如果没有在打字,启动打字机效果
|
||||||
|
if (!this.isTyping) {
|
||||||
|
this.startTypewriter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动打字机效果
|
||||||
|
*/
|
||||||
|
startTypewriter() {
|
||||||
|
if (this.isTyping) return;
|
||||||
|
|
||||||
|
this.isTyping = true;
|
||||||
|
this.displayedContent = "";
|
||||||
|
|
||||||
|
this._typeNextChar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打字下一个字符(私有方法)
|
||||||
|
*/
|
||||||
|
_typeNextChar() {
|
||||||
|
// 如果已显示内容长度小于完整内容长度,继续打字
|
||||||
|
if (this.displayedContent.length < this.currentMessageContent.length) {
|
||||||
|
this.displayedContent = this.currentMessageContent.substring(
|
||||||
|
0,
|
||||||
|
this.displayedContent.length + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayContent = this.displayedContent + this.options.cursorText;
|
||||||
|
|
||||||
|
// 调用内容更新回调
|
||||||
|
if (this.onContentUpdate) {
|
||||||
|
this.onContentUpdate(displayContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用字符打字回调
|
||||||
|
if (this.onCharacterTyped) {
|
||||||
|
this.onCharacterTyped(this.displayedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续下一个字符
|
||||||
|
this.typewriterTimer = setTimeout(() => {
|
||||||
|
this._typeNextChar();
|
||||||
|
}, this.options.typingSpeed);
|
||||||
|
} else {
|
||||||
|
// 打字完成,移除光标
|
||||||
|
if (this.onContentUpdate) {
|
||||||
|
this.onContentUpdate(this.currentMessageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用打字完成回调
|
||||||
|
if (this.onTypingComplete) {
|
||||||
|
this.onTypingComplete(this.currentMessageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopTypewriter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止打字机效果
|
||||||
|
*/
|
||||||
|
stopTypewriter() {
|
||||||
|
if (this.typewriterTimer) {
|
||||||
|
clearTimeout(this.typewriterTimer);
|
||||||
|
this.typewriterTimer = null;
|
||||||
|
}
|
||||||
|
this.isTyping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置打字机状态
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.currentMessageContent = "";
|
||||||
|
this.displayedContent = "";
|
||||||
|
this.stopTypewriter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前状态
|
||||||
|
* @returns {Object} 当前状态对象
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isTyping: this.isTyping,
|
||||||
|
currentContent: this.currentMessageContent,
|
||||||
|
displayedContent: this.displayedContent,
|
||||||
|
progress: this.currentMessageContent.length > 0
|
||||||
|
? this.displayedContent.length / this.currentMessageContent.length
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即完成打字(跳过动画)
|
||||||
|
*/
|
||||||
|
completeImmediately() {
|
||||||
|
this.stopTypewriter();
|
||||||
|
this.displayedContent = this.currentMessageContent;
|
||||||
|
|
||||||
|
if (this.onContentUpdate) {
|
||||||
|
this.onContentUpdate(this.currentMessageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onTypingComplete) {
|
||||||
|
this.onTypingComplete(this.currentMessageContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁打字机管理器
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.stopTypewriter();
|
||||||
|
this.reset();
|
||||||
|
this.onCharacterTyped = null;
|
||||||
|
this.onTypingComplete = null;
|
||||||
|
this.onContentUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TypewriterManager;
|
||||||
621
utils/WebSocketManager.js
Normal file
621
utils/WebSocketManager.js
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
/**
|
||||||
|
* 简化的WebSocket管理器
|
||||||
|
* 专门负责WebSocket连接和消息传输,不包含打字机逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IdUtils, CallbackUtils, MessageUtils, TimerUtils } from "./index.js";
|
||||||
|
|
||||||
|
export class WebSocketManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// 基础配置
|
||||||
|
console.log("WebSocketManager构造函数接收到的options:", options);
|
||||||
|
this.wsUrl = options.wsUrl || "";
|
||||||
|
console.log("WebSocketManager设置的wsUrl:", this.wsUrl);
|
||||||
|
this.protocols = options.protocols || [];
|
||||||
|
this.reconnectInterval = options.reconnectInterval || 3000;
|
||||||
|
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
|
||||||
|
this.heartbeatInterval = options.heartbeatInterval || 30000;
|
||||||
|
|
||||||
|
// WebSocket 实例
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = false;
|
||||||
|
|
||||||
|
// 计时器
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
this.callbacks = {
|
||||||
|
onConnect: options.onConnect || (() => {}),
|
||||||
|
onDisconnect: options.onDisconnect || (() => {}),
|
||||||
|
onError: options.onError || (() => {}),
|
||||||
|
onMessage: options.onMessage || (() => {}),
|
||||||
|
getConversationId: options.getConversationId || (() => ""),
|
||||||
|
getAgentId: options.getAgentId || (() => ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 消息队列
|
||||||
|
this.messageQueue = [];
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
this.stats = {
|
||||||
|
messagesReceived: 0,
|
||||||
|
messagesSent: 0,
|
||||||
|
messagesDropped: 0,
|
||||||
|
reconnectCount: 0,
|
||||||
|
connectionStartTime: null,
|
||||||
|
lastMessageTime: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全调用回调函数
|
||||||
|
*/
|
||||||
|
_safeCallCallback(callbackName, ...args) {
|
||||||
|
CallbackUtils.safeCall(this.callbacks, callbackName, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化连接
|
||||||
|
*/
|
||||||
|
async init(wsUrl) {
|
||||||
|
if (wsUrl) {
|
||||||
|
this.wsUrl = wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.wsUrl) {
|
||||||
|
throw new Error("WebSocket URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立WebSocket连接
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.isConnecting || this.connectionState) {
|
||||||
|
console.log("WebSocket已在连接中或已连接,跳过连接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("开始建立WebSocket连接:", this.wsUrl);
|
||||||
|
this.isConnecting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 在小程序环境中使用 uni.connectSocket
|
||||||
|
if (typeof uni !== "undefined" && uni.connectSocket) {
|
||||||
|
try {
|
||||||
|
this.ws = uni.connectSocket({
|
||||||
|
url: this.wsUrl,
|
||||||
|
protocols: this.protocols,
|
||||||
|
success: () => {
|
||||||
|
console.log("uni.connectSocket调用成功");
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
console.error("uni.connectSocket调用失败:", error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = false;
|
||||||
|
this._safeCallCallback("onError", {
|
||||||
|
type: "CONNECTION_ERROR",
|
||||||
|
message: "uni.connectSocket调用失败",
|
||||||
|
originalError: error,
|
||||||
|
});
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保ws对象存在且有相应方法
|
||||||
|
if (this.ws && typeof this.ws.onOpen === "function") {
|
||||||
|
this.ws.onOpen(this.handleOpen.bind(this));
|
||||||
|
this.ws.onMessage(this.handleMessage.bind(this));
|
||||||
|
this.ws.onClose(this.handleClose.bind(this));
|
||||||
|
this.ws.onError(this.handleError.bind(this));
|
||||||
|
} else {
|
||||||
|
console.error("uni.connectSocket返回的对象无效");
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = false;
|
||||||
|
this._safeCallCallback("onError", {
|
||||||
|
type: "CONNECTION_ERROR",
|
||||||
|
message:
|
||||||
|
"uni.connectSocket返回的SocketTask对象无效",
|
||||||
|
originalError: null,
|
||||||
|
});
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("创建uni.connectSocket时发生异常:", error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = false;
|
||||||
|
this._safeCallCallback("onError", {
|
||||||
|
type: "CONNECTION_ERROR",
|
||||||
|
message: "创建WebSocket连接时发生异常",
|
||||||
|
originalError: error,
|
||||||
|
});
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 在其他环境中使用标准 WebSocket
|
||||||
|
console.log("使用标准WebSocket创建连接");
|
||||||
|
this.ws = new WebSocket(this.wsUrl, this.protocols);
|
||||||
|
|
||||||
|
this.ws.onopen = this.handleOpen.bind(this);
|
||||||
|
this.ws.onmessage = this.handleMessage.bind(this);
|
||||||
|
this.ws.onclose = this.handleClose.bind(this);
|
||||||
|
this.ws.onerror = this.handleError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("WebSocket实例创建成功,等待连接...");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WebSocket连接创建失败:", error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = false;
|
||||||
|
this._safeCallCallback("onError", {
|
||||||
|
type: "CONNECTION_CREATE_ERROR",
|
||||||
|
message: "WebSocket连接创建失败",
|
||||||
|
originalError: error,
|
||||||
|
});
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接成功处理
|
||||||
|
*/
|
||||||
|
handleOpen(event) {
|
||||||
|
console.log("WebSocket连接已建立");
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionState = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.stats.connectionStartTime = Date.now();
|
||||||
|
|
||||||
|
this.startHeartbeat();
|
||||||
|
|
||||||
|
// 安全调用 onConnect 回调
|
||||||
|
this._safeCallCallback("onConnect", event);
|
||||||
|
|
||||||
|
// 处理队列中的消息
|
||||||
|
this._processMessageQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消息队列
|
||||||
|
*/
|
||||||
|
_processMessageQueue() {
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
const queuedMessage = this.messageQueue.shift();
|
||||||
|
|
||||||
|
// 检查重试次数
|
||||||
|
if (queuedMessage.retryCount >= 3) {
|
||||||
|
console.warn("消息重试次数超限,丢弃消息:", queuedMessage);
|
||||||
|
this.stats.messagesDropped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedMessage.retryCount = (queuedMessage.retryCount || 0) + 1;
|
||||||
|
this.sendMessage(queuedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息接收处理
|
||||||
|
*/
|
||||||
|
handleMessage(event) {
|
||||||
|
try {
|
||||||
|
// 在小程序环境中,消息数据可能在 event.data 中
|
||||||
|
const messageData = event.data || event;
|
||||||
|
|
||||||
|
// 处理心跳响应和其他非JSON消息 - 在JSON解析前检查
|
||||||
|
if (typeof messageData === "string") {
|
||||||
|
// 处理心跳响应
|
||||||
|
if (MessageUtils.isPongMessage(messageData)) {
|
||||||
|
console.log("收到心跳响应:", messageData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析JSON,如果失败则忽略该消息
|
||||||
|
const data = MessageUtils.safeParseJSON(messageData);
|
||||||
|
if (data) {
|
||||||
|
this.stats.messagesReceived++;
|
||||||
|
this.stats.lastMessageTime = Date.now();
|
||||||
|
console.log("收到WebSocket消息:", data);
|
||||||
|
|
||||||
|
// 心跳响应 - 兼容对象格式
|
||||||
|
if (MessageUtils.isPongMessage(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接传递消息给回调处理
|
||||||
|
this._safeCallCallback("onMessage", data);
|
||||||
|
} else {
|
||||||
|
console.warn("收到非JSON格式消息,已忽略:", messageData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理对象格式的消息
|
||||||
|
const data = messageData;
|
||||||
|
this.stats.messagesReceived++;
|
||||||
|
this.stats.lastMessageTime = Date.now();
|
||||||
|
console.log("收到WebSocket消息:", data);
|
||||||
|
|
||||||
|
// 心跳响应 - 兼容对象格式
|
||||||
|
if (MessageUtils.isPongMessage(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接传递消息给回调处理
|
||||||
|
this._safeCallCallback("onMessage", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("消息处理错误:", error, "原始消息:", event);
|
||||||
|
// 不抛出错误,避免影响其他消息处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接关闭处理
|
||||||
|
*/
|
||||||
|
handleClose(event) {
|
||||||
|
console.log("WebSocket连接已关闭:", event.code, event.reason);
|
||||||
|
this.connectionState = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
// 安全调用 onDisconnect 回调
|
||||||
|
this._safeCallCallback("onDisconnect", event);
|
||||||
|
|
||||||
|
// 非正常关闭时尝试重连
|
||||||
|
if (
|
||||||
|
event.code !== 1000 &&
|
||||||
|
this.reconnectAttempts < this.maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误处理
|
||||||
|
*/
|
||||||
|
handleError(error) {
|
||||||
|
const errorDetails = {
|
||||||
|
error,
|
||||||
|
wsUrl: this.wsUrl,
|
||||||
|
readyState: this.ws ? this.ws.readyState : "WebSocket实例不存在",
|
||||||
|
isConnecting: this.isConnecting,
|
||||||
|
connectionState: this.connectionState,
|
||||||
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error("WebSocket错误详情:", errorDetails);
|
||||||
|
|
||||||
|
// 重置连接状态
|
||||||
|
this.connectionState = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
|
||||||
|
// 构造结构化错误信息
|
||||||
|
const structuredError = {
|
||||||
|
type: "WEBSOCKET_ERROR",
|
||||||
|
message: error?.message || "未知WebSocket错误",
|
||||||
|
originalError: error,
|
||||||
|
details: errorDetails,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 安全调用 onError 回调
|
||||||
|
this._safeCallCallback("onError", structuredError);
|
||||||
|
|
||||||
|
// 如果不是正在重连中,尝试重连
|
||||||
|
if (
|
||||||
|
!this.reconnectTimer &&
|
||||||
|
this.reconnectAttempts < this.maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
console.log("错误后尝试重连");
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
*/
|
||||||
|
sendMessage(message) {
|
||||||
|
const messageData = {
|
||||||
|
...message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retryCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.connectionState) {
|
||||||
|
try {
|
||||||
|
const messageStr = JSON.stringify(messageData);
|
||||||
|
|
||||||
|
// 在小程序环境中使用不同的发送方法
|
||||||
|
if (typeof uni !== "undefined" && this.ws && this.ws.send) {
|
||||||
|
// uni-app 小程序环境
|
||||||
|
this.ws.send({
|
||||||
|
data: messageStr,
|
||||||
|
success: () => {
|
||||||
|
this.stats.messagesSent++;
|
||||||
|
console.log("消息发送成功:", messageData);
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
console.error("发送消息失败:", error);
|
||||||
|
this._safeCallCallback("onError", error);
|
||||||
|
|
||||||
|
// 发送失败时加入重试队列
|
||||||
|
this.messageQueue.push(messageData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (this.ws && this.ws.send) {
|
||||||
|
// 标准 WebSocket 环境
|
||||||
|
this.ws.send(messageStr);
|
||||||
|
this.stats.messagesSent++;
|
||||||
|
console.log("消息发送成功:", messageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("发送消息失败:", error);
|
||||||
|
this._safeCallCallback("onError", error);
|
||||||
|
|
||||||
|
// 发送失败时加入重试队列
|
||||||
|
this.messageQueue.push(messageData);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 连接未建立时加入队列
|
||||||
|
this.messageQueue.push(messageData);
|
||||||
|
this.connect(); // 尝试连接
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始心跳
|
||||||
|
*/
|
||||||
|
startHeartbeat() {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.connectionState && this.ws) {
|
||||||
|
try {
|
||||||
|
// 使用标准消息格式发送心跳 (messageType=3)
|
||||||
|
const heartbeatMessage = {
|
||||||
|
conversationId: this.callbacks.getConversationId
|
||||||
|
? this.callbacks.getConversationId()
|
||||||
|
: "",
|
||||||
|
agentId: this.callbacks.getAgentId
|
||||||
|
? this.callbacks.getAgentId()
|
||||||
|
: "",
|
||||||
|
messageType: 3, // 心跳检测
|
||||||
|
messageContent: "heartbeat",
|
||||||
|
messageId: IdUtils.generateMessageId(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sendMessage(heartbeatMessage);
|
||||||
|
console.log("心跳消息已发送:", heartbeatMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("发送心跳失败:", error);
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳
|
||||||
|
*/
|
||||||
|
stopHeartbeat() {
|
||||||
|
TimerUtils.clearTimer(this.heartbeatTimer, "interval");
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排重连
|
||||||
|
*/
|
||||||
|
scheduleReconnect() {
|
||||||
|
// 清理之前的重连定时器
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
TimerUtils.clearTimer(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error(
|
||||||
|
`达到最大重连次数(${this.maxReconnectAttempts}),停止重连`
|
||||||
|
);
|
||||||
|
this.connectionState = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
|
||||||
|
// 使用更温和的错误处理,避免抛出异常
|
||||||
|
const errorInfo = {
|
||||||
|
type: "CONNECTION_FAILED",
|
||||||
|
message: `连接失败,已达到最大重连次数(${this.maxReconnectAttempts})`,
|
||||||
|
attempts: this.reconnectAttempts,
|
||||||
|
maxAttempts: this.maxReconnectAttempts,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn("WebSocket连接最终失败:", errorInfo);
|
||||||
|
this._safeCallCallback("onError", errorInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
this.stats.reconnectCount++;
|
||||||
|
|
||||||
|
// 使用指数退避策略
|
||||||
|
const backoffDelay = Math.min(
|
||||||
|
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||||
|
30000 // 最大延迟30秒
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${backoffDelay}ms后进行第${this.reconnectAttempts}/${this.maxReconnectAttempts}次重连`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connect().catch((error) => {
|
||||||
|
console.error("重连过程中发生错误:", error);
|
||||||
|
// 继续尝试重连
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
}, backoffDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置连接状态
|
||||||
|
*/
|
||||||
|
resetConnectionState() {
|
||||||
|
this.connectionState = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
this.stopHeartbeat();
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
TimerUtils.clearTimer(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("连接状态已重置");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已连接
|
||||||
|
*/
|
||||||
|
isConnected() {
|
||||||
|
return (
|
||||||
|
this.connectionState &&
|
||||||
|
this.ws &&
|
||||||
|
(this.ws.readyState === 1 ||
|
||||||
|
(typeof WebSocket !== "undefined" &&
|
||||||
|
this.ws.readyState === WebSocket.OPEN))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动重连
|
||||||
|
*/
|
||||||
|
async reconnect() {
|
||||||
|
console.log("手动触发重连");
|
||||||
|
|
||||||
|
// 先关闭现有连接
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// 重置连接状态
|
||||||
|
this.resetConnectionState();
|
||||||
|
|
||||||
|
// 重新连接
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("手动重连失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
*/
|
||||||
|
getConnectionState() {
|
||||||
|
return {
|
||||||
|
isConnected: this.isConnected(),
|
||||||
|
readyState: this.ws ? this.ws.readyState : -1,
|
||||||
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
|
maxReconnectAttempts: this.maxReconnectAttempts,
|
||||||
|
isConnecting: this.isConnecting,
|
||||||
|
hasReconnectTimer: !!this.reconnectTimer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计信息
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
const now = Date.now();
|
||||||
|
const connectionTime = this.stats.connectionStartTime
|
||||||
|
? now - this.stats.connectionStartTime
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
connectionTime,
|
||||||
|
queueLength: this.messageQueue.length,
|
||||||
|
isConnected: this.connectionState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置统计信息
|
||||||
|
*/
|
||||||
|
resetStats() {
|
||||||
|
this.stats = {
|
||||||
|
messagesReceived: 0,
|
||||||
|
messagesSent: 0,
|
||||||
|
messagesDropped: 0,
|
||||||
|
reconnectCount: 0,
|
||||||
|
connectionStartTime: Date.now(),
|
||||||
|
lastMessageTime: null,
|
||||||
|
};
|
||||||
|
console.log("统计信息已重置");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭连接
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
// 清理重连定时器
|
||||||
|
TimerUtils.clearTimer(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
// 在小程序环境中使用不同的关闭方法
|
||||||
|
if (typeof uni !== "undefined" && this.ws.close) {
|
||||||
|
// uni-app 小程序环境
|
||||||
|
this.ws.close({
|
||||||
|
code: 1000,
|
||||||
|
reason: "正常关闭",
|
||||||
|
success: () => {
|
||||||
|
console.log("WebSocket连接已关闭");
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
console.error("关闭WebSocket连接失败:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (this.ws.close) {
|
||||||
|
// 标准 WebSocket 环境
|
||||||
|
this.ws.close(1000, "正常关闭");
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionState = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.messageQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁实例
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// 输出最终统计信息
|
||||||
|
console.log("WebSocketManager销毁前统计:", this.getStats());
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
this.callbacks = {};
|
||||||
|
|
||||||
|
console.log("WebSocketManager实例已销毁");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSocketManager;
|
||||||
429
utils/index.js
Normal file
429
utils/index.js
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
/**
|
||||||
|
* 工具函数集合
|
||||||
|
* 包含打字机效果、ID生成、回调安全调用等通用工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打字机工具类
|
||||||
|
* 提供打字机效果相关的工具函数
|
||||||
|
*/
|
||||||
|
export class TypewriterUtils {
|
||||||
|
/**
|
||||||
|
* 计算动态打字速度
|
||||||
|
* @param {string} char - 当前字符
|
||||||
|
* @param {number} baseSpeed - 基础速度(ms)
|
||||||
|
* @returns {number} 动态调整后的速度
|
||||||
|
*/
|
||||||
|
static calculateDynamicSpeed(char, baseSpeed = 100) {
|
||||||
|
const punctuationMarks = [
|
||||||
|
"。",
|
||||||
|
"!",
|
||||||
|
"?",
|
||||||
|
".",
|
||||||
|
"!",
|
||||||
|
"?",
|
||||||
|
",",
|
||||||
|
",",
|
||||||
|
";",
|
||||||
|
";",
|
||||||
|
":",
|
||||||
|
":",
|
||||||
|
];
|
||||||
|
const isSpace = char === " ";
|
||||||
|
const hasPunctuation = punctuationMarks.includes(char);
|
||||||
|
|
||||||
|
if (hasPunctuation) {
|
||||||
|
// 标点符号后停顿更久,模拟思考时间
|
||||||
|
return baseSpeed * 2.5;
|
||||||
|
} else if (isSpace) {
|
||||||
|
// 空格稍快一些
|
||||||
|
return baseSpeed * 0.6;
|
||||||
|
} else {
|
||||||
|
// 普通字符添加一些随机性,模拟真实打字的不均匀性
|
||||||
|
const randomFactor = 0.7 + Math.random() * 0.6; // 0.7-1.3倍速度
|
||||||
|
return baseSpeed * randomFactor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符是否为标点符号
|
||||||
|
* @param {string} char - 要检查的字符
|
||||||
|
* @returns {boolean} 是否为标点符号
|
||||||
|
*/
|
||||||
|
static isPunctuation(char) {
|
||||||
|
const punctuationMarks = [
|
||||||
|
"。",
|
||||||
|
"!",
|
||||||
|
"?",
|
||||||
|
".",
|
||||||
|
"!",
|
||||||
|
"?",
|
||||||
|
",",
|
||||||
|
",",
|
||||||
|
";",
|
||||||
|
";",
|
||||||
|
":",
|
||||||
|
":",
|
||||||
|
];
|
||||||
|
return punctuationMarks.includes(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符是否为空格
|
||||||
|
* @param {string} char - 要检查的字符
|
||||||
|
* @returns {boolean} 是否为空格
|
||||||
|
*/
|
||||||
|
static isSpace(char) {
|
||||||
|
return char === " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成加载动画文本
|
||||||
|
* @param {number} dotCount - 点的数量(1-3)
|
||||||
|
* @param {string} baseText - 基础文本
|
||||||
|
* @returns {string} 加载动画文本
|
||||||
|
*/
|
||||||
|
static generateLoadingText(dotCount = 1, baseText = "加载中") {
|
||||||
|
const normalizedDotCount = ((dotCount - 1) % 3) + 1;
|
||||||
|
return baseText + ".".repeat(normalizedDotCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动加载动画
|
||||||
|
*/
|
||||||
|
static startLoadingAnimation(
|
||||||
|
onProgress,
|
||||||
|
options = {},
|
||||||
|
onTimerCreated = null
|
||||||
|
) {
|
||||||
|
const { speed = 500, text = "加载中" } = options;
|
||||||
|
let dotCount = 1;
|
||||||
|
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
dotCount = (dotCount % 3) + 1;
|
||||||
|
const loadingText = text + ".".repeat(dotCount);
|
||||||
|
onProgress(loadingText);
|
||||||
|
}, speed);
|
||||||
|
|
||||||
|
if (onTimerCreated) {
|
||||||
|
onTimerCreated(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return timerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动打字机效果
|
||||||
|
* @param {string} text - 要显示的文本
|
||||||
|
* @param {Element} targetElement - 目标元素(可选)
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {Function} onTimerCreated - 定时器创建回调(可选)
|
||||||
|
*/
|
||||||
|
static startTypewriter(
|
||||||
|
text,
|
||||||
|
targetElement = null,
|
||||||
|
options = {},
|
||||||
|
onTimerCreated = null
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
speed = 100,
|
||||||
|
onProgress = () => {},
|
||||||
|
onComplete = () => {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const typeNextChar = () => {
|
||||||
|
if (index < text.length) {
|
||||||
|
const char = text[index];
|
||||||
|
const currentText = text.substring(0, index + 1);
|
||||||
|
|
||||||
|
// 调用进度回调
|
||||||
|
onProgress(currentText, index);
|
||||||
|
|
||||||
|
// 如果有目标元素,更新其内容
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.textContent = currentText;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
|
||||||
|
// 计算下一个字符的延时
|
||||||
|
const delay = TypewriterUtils.calculateDynamicSpeed(
|
||||||
|
char,
|
||||||
|
speed
|
||||||
|
);
|
||||||
|
|
||||||
|
const timerId = setTimeout(typeNextChar, delay);
|
||||||
|
|
||||||
|
if (onTimerCreated && index === 1) {
|
||||||
|
onTimerCreated(timerId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 打字完成
|
||||||
|
onComplete(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始打字
|
||||||
|
const initialTimerId = setTimeout(typeNextChar, 0);
|
||||||
|
|
||||||
|
if (onTimerCreated) {
|
||||||
|
onTimerCreated(initialTimerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialTimerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID生成工具类
|
||||||
|
* 提供各种ID生成功能
|
||||||
|
*/
|
||||||
|
export class IdUtils {
|
||||||
|
/**
|
||||||
|
* 生成消息ID
|
||||||
|
* @returns {string} 消息ID
|
||||||
|
*/
|
||||||
|
static generateMessageId() {
|
||||||
|
return "mid" + new Date().getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调函数安全调用工具类
|
||||||
|
* 提供安全的回调函数调用机制
|
||||||
|
*/
|
||||||
|
export class CallbackUtils {
|
||||||
|
/**
|
||||||
|
* 安全调用回调函数
|
||||||
|
* @param {Object} callbacks - 回调函数对象
|
||||||
|
* @param {string} callbackName - 回调函数名称
|
||||||
|
* @param {...any} args - 传递给回调函数的参数
|
||||||
|
*/
|
||||||
|
static safeCall(callbacks, callbackName, ...args) {
|
||||||
|
if (callbacks && typeof callbacks[callbackName] === "function") {
|
||||||
|
try {
|
||||||
|
callbacks[callbackName](...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`回调函数 ${callbackName} 执行出错:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`回调函数 ${callbackName} 不可用`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量安全调用回调函数
|
||||||
|
* @param {Object} callbacks - 回调函数对象
|
||||||
|
* @param {Array} callbackConfigs - 回调配置数组 [{name, args}, ...]
|
||||||
|
*/
|
||||||
|
static safeBatchCall(callbacks, callbackConfigs) {
|
||||||
|
callbackConfigs.forEach((config) => {
|
||||||
|
const { name, args = [] } = config;
|
||||||
|
this.safeCall(callbacks, name, ...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息处理工具类
|
||||||
|
* 提供消息相关的工具函数
|
||||||
|
*/
|
||||||
|
export class MessageUtils {
|
||||||
|
/**
|
||||||
|
* 验证消息格式
|
||||||
|
* @param {any} message - 消息对象
|
||||||
|
* @returns {boolean} 是否为有效消息格式
|
||||||
|
*/
|
||||||
|
static validateMessage(message) {
|
||||||
|
return message && typeof message === "object" && message.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化消息
|
||||||
|
* @param {string} type - 消息类型
|
||||||
|
* @param {any} content - 消息内容
|
||||||
|
* @param {Object} options - 额外选项
|
||||||
|
* @returns {Object} 格式化后的消息对象
|
||||||
|
*/
|
||||||
|
static formatMessage(type, content, options = {}) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为完整消息
|
||||||
|
* @param {any} message - 消息对象
|
||||||
|
* @returns {boolean} 是否为完整消息
|
||||||
|
*/
|
||||||
|
static isCompleteMessage(message) {
|
||||||
|
return message && message.isComplete === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查消息是否为心跳响应
|
||||||
|
* @param {any} messageData - 消息数据
|
||||||
|
* @returns {boolean} 是否为心跳响应
|
||||||
|
*/
|
||||||
|
static isPongMessage(messageData) {
|
||||||
|
if (typeof messageData === "string") {
|
||||||
|
return (
|
||||||
|
messageData === "pong" ||
|
||||||
|
messageData.toLowerCase().includes("pong")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof messageData === "object" && messageData !== null) {
|
||||||
|
return messageData.type === "pong";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全解析JSON消息
|
||||||
|
* @param {string} messageStr - 消息字符串
|
||||||
|
* @returns {Object|null} 解析后的对象或null
|
||||||
|
*/
|
||||||
|
static safeParseJSON(messageStr) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(messageStr);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("JSON解析失败:", messageStr);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建打字机消息对象
|
||||||
|
* @param {string} content - 消息内容
|
||||||
|
* @param {boolean} isComplete - 是否完成
|
||||||
|
* @param {string} type - 消息类型
|
||||||
|
* @returns {Object} 消息对象
|
||||||
|
*/
|
||||||
|
static createTypewriterMessage(
|
||||||
|
content,
|
||||||
|
isComplete = false,
|
||||||
|
type = "typewriter"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
isComplete,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建加载消息对象
|
||||||
|
* @param {string} content - 加载内容
|
||||||
|
* @returns {Object} 加载消息对象
|
||||||
|
*/
|
||||||
|
static createLoadingMessage(content = "加载中...") {
|
||||||
|
return {
|
||||||
|
type: "loading",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误消息对象
|
||||||
|
* @param {string} error - 错误信息
|
||||||
|
* @returns {Object} 错误消息对象
|
||||||
|
*/
|
||||||
|
static createErrorMessage(error) {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
content: error.message || error || "未知错误",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时器管理工具类
|
||||||
|
* 提供定时器的统一管理
|
||||||
|
*/
|
||||||
|
export class TimerUtils {
|
||||||
|
/**
|
||||||
|
* 安全清除定时器
|
||||||
|
* @param {number|null} timerId - 定时器ID
|
||||||
|
* @param {string} type - 定时器类型 'timeout' | 'interval'
|
||||||
|
* @returns {null} 返回null便于重置变量
|
||||||
|
*/
|
||||||
|
static safeClear(timerId, type = "timeout") {
|
||||||
|
if (timerId) {
|
||||||
|
if (type === "interval") {
|
||||||
|
clearInterval(timerId);
|
||||||
|
} else {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除定时器(别名方法)
|
||||||
|
* @param {number|null} timerId - 定时器ID
|
||||||
|
* @param {string} type - 定时器类型 'timeout' | 'interval'
|
||||||
|
* @returns {null} 返回null便于重置变量
|
||||||
|
*/
|
||||||
|
static clearTimer(timerId, type = "timeout") {
|
||||||
|
return this.safeClear(timerId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可取消的延时执行
|
||||||
|
* @param {Function} callback - 回调函数
|
||||||
|
* @param {number} delay - 延时时间
|
||||||
|
* @returns {Object} 包含cancel方法的对象
|
||||||
|
*/
|
||||||
|
static createCancelableTimeout(callback, delay) {
|
||||||
|
let timerId = setTimeout(callback, delay);
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isActive() {
|
||||||
|
return timerId !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可取消的定时执行
|
||||||
|
* @param {Function} callback - 回调函数
|
||||||
|
* @param {number} interval - 间隔时间
|
||||||
|
* @returns {Object} 包含cancel方法的对象
|
||||||
|
*/
|
||||||
|
static createCancelableInterval(callback, interval) {
|
||||||
|
let timerId = setInterval(callback, interval);
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
if (timerId) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isActive() {
|
||||||
|
return timerId !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认导出所有工具类
|
||||||
|
export default {
|
||||||
|
TypewriterUtils,
|
||||||
|
IdUtils,
|
||||||
|
CallbackUtils,
|
||||||
|
MessageUtils,
|
||||||
|
TimerUtils,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user