feat: 合并代码

This commit is contained in:
duanshuwen 2025-08-10 12:13:28 +08:00
commit ccdc46ec7c
14 changed files with 2253 additions and 705 deletions

View File

@ -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";

View File

@ -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 };

View File

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

View File

@ -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-appAPI // uni-appAPI
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>

View File

@ -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;
// IDmessageId
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>

View File

@ -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;
}
}

View File

@ -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,
});
}, },
}); });
}; };

View File

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

View File

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

View File

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

View File

@ -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
View 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
View 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
View 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,
};