Compare commits

...

11 Commits

Author SHA1 Message Date
zoujing
5292ede3c5 feat: 输入框的交互调整 2025-08-06 16:04:39 +08:00
zoujing
af9c3c7c28 feat: 输入框清空的问题 2025-08-06 14:16:31 +08:00
zoujing
fe0a36e446 feat: token 过期的处理 2025-08-06 13:52:53 +08:00
zoujing
6f9f129883 feat: 终止请求后的调整 2025-08-06 13:52:32 +08:00
zoujing
65653525a0 feat: 完成对话手动停止的功能 2025-08-06 11:47:07 +08:00
6e658c9967 feat: 对话解析优化 2025-08-05 22:46:17 +08:00
duanshuwen
c46392b478 Merge branch 'order-729'
合并order-729分支
2025-08-05 22:06:50 +08:00
aedc71903d feat: 样式的调整 2025-08-05 22:02:10 +08:00
680ac8fd38 feat: 商品的展示数据加载 2025-08-05 20:46:43 +08:00
6ea7cf495c feat: 添加上banner 的点击事件 2025-08-05 20:32:48 +08:00
e539c7d35d feat:修复键盘的问题 2025-08-05 20:01:30 +08:00
14 changed files with 706 additions and 163 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
tab_width = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
insert_final_newline = false

View File

@ -2,4 +2,7 @@
export const SCROLL_TO_BOTTOM = 'SCROLL_TO_BOTTOM'
// 推荐帖子
export const RECOMMEND_POSTS_TITLE = 'RECOMMEND_POSTS_TITLE'
export const RECOMMEND_POSTS_TITLE = 'RECOMMEND_POSTS_TITLE'
// 发送命令
export const SEND_COMMAND_TEXT = 'SEND_COMMAND_TEXT'

View File

@ -13,6 +13,17 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/ChatMainList",
"style": {
"navigationStyle": "custom",
"app-plus": {
"softinputMode": "adjustPan",
"bounce": "none",
"titleNView": false
}
}
},
{
"path": "pages/chat/ChatQuickAccess",
"style": {

View File

@ -1,59 +1,267 @@
<template>
<view class="area-input">
<!-- 发送语音 -->
<view class="input-container-voice">
<image src='/static/input_voice_icon.png'></image>
<!-- 语音/键盘切换 -->
<view class="input-container-voice" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" src='/static/input_voice_icon.png'></image>
<image v-else src='/static/input_keyboard_icon.png'></image>
</view>
<!-- 输入框 -->
<textarea
class="textarea"
type="text"
:placeholder="placeholder"
cursor-spacing="65"
confirm-type='done'
v-model="inputMessage"
@confirm="sendMessage"
@touchend="handleNoHideKeyboard"
:confirm-hold="true"
auto-height
:show-confirm-bar='false'
:hold-keyboard="holdKeyboard"
maxlength="300"
/>
<view class="input-container-send" @click="sendMessage">
<image src='/static/input_send_icon.png'></image>
<!-- 输入框/语音按钮容器 -->
<view class="input-button-container">
<textarea
ref="textareaRef"
class="textarea"
type="text"
:placeholder="placeholder"
cursor-spacing="65"
confirm-type='done'
v-model="inputMessage"
@confirm="sendMessage"
@focus="handleFocus"
@blur="handleBlur"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
:confirm-hold="true"
auto-height
:show-confirm-bar='false'
:hold-keyboard="holdKeyboard"
:adjust-position="true"
maxlength="300"
/>
<view
v-if="isVoiceMode"
class="hold-to-talk-button"
@touchstart.stop="startRecording"
@touchend.stop="stopRecording"
@touchmove.stop="handleTouchMove"
>
按住说话
</view>
</view>
<view class="input-container-send">
<view v-if="!isVoiceMode" class="input-container-send-btn" @click="sendMessage">
<image v-if="props.isSessionActive" src='/static/input_stop_icon.png'></image>
<image v-else src='/static/input_send_icon.png'></image>
</view>
</view>
</view>
<!-- 录音界面 -->
<uni-popup v-model:show="isRecording" position="center" :mask-click-able="false">
<view class="recording-popup">
<view class="recording-wave">
<!-- 波形动画 -->
<view class="wave-animation"></view>
</view>
<view class="recording-text">
{{ isSlideToText ? '松开发送 转文字' : '松开发送' }}
</view>
<view class="recording-cancel">
取消
</view>
</view>
</uni-popup>
<!-- 语音结果界面 -->
<uni-popup v-model:show="showVoiceResult" position="center" :mask-click-able="false">
<view class="voice-result-popup">
<view class="voice-result-bubble">
{{ voiceText }}
</view>
<view class="voice-result-actions">
<view class="action-button cancel" @click="cancelVoice">取消</view>
<view class="action-button voice">发送原语音</view>
<view class="action-button send" @click="sendVoiceMessage">发送</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
const props = defineProps({
inputMessage: String,
holdKeyboard: Boolean
holdKeyboard: Boolean,
isSessionActive: Boolean,
stopRequest: Function
})
const emit = defineEmits(['update:inputMessage', 'send', 'noHideKeyboard'])
const emit = defineEmits(['update:inputMessage', 'send', 'noHideKeyboard', 'keyboardShow', 'keyboardHide', 'sendVoice'])
const textareaRef = ref(null)
const placeholder = ref('快告诉朵朵您在想什么~')
const inputMessage = ref(props.inputMessage || '')
const isFocused = ref(false)
const keyboardHeight = ref(0)
const isVoiceMode = ref(false)
const isRecording = ref(false)
const recordingTime = ref(0)
const recordingTimer = ref(null)
const voiceText = ref('')
const showVoiceResult = ref(false)
const isSlideToText = ref(false)
//
watch(() => props.inputMessage, (val) => {
inputMessage.value = val
})
const sendMessage = () => {
if (!inputMessage.value.trim()) return;
emit('send', inputMessage.value)
inputMessage.value = ''
emit('update:inputMessage', inputMessage.value)
// /
const toggleVoiceMode = () => {
isVoiceMode.value = !isVoiceMode.value
}
const handleNoHideKeyboard = () => {
//
const startRecording = () => {
isRecording.value = true
return
recordingTime.value = 0
//
recordingTimer.value = setInterval(() => {
recordingTime.value += 1
}, 1000)
// uni-appAPI
uni.startRecord({
success: (res) => {
//
const tempFilePath = res.tempFilePath
//
//
setTimeout(() => {
voiceText.value = '这是语音转文字的结果'
showVoiceResult.value = true
}, 1000)
},
fail: (err) => {
console.error('录音失败:', err)
isRecording.value = false
clearInterval(recordingTimer.value)
}
})
}
//
const stopRecording = () => {
isRecording.value = false
return
clearInterval(recordingTimer.value)
// 1
if (recordingTime.value < 1) {
uni.stopRecord()
return
}
//
uni.stopRecord()
}
//
const handleTouchMove = (e) => {
//
// UI
const touchY = e.touches[0].clientY
//
isSlideToText.value = touchY < 200
}
//
const sendVoiceMessage = () => {
//
emit('sendVoice', {
text: voiceText.value,
//
})
showVoiceResult.value = false
isVoiceMode.value = false
}
//
const cancelVoice = () => {
showVoiceResult.value = false
isVoiceMode.value = false
}
//
onMounted(() => {
//
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height
if (res.height > 0) {
emit('keyboardShow', res.height)
} else {
emit('keyboardHide')
}
})
})
const sendMessage = () => {
if (props.isSessionActive) {
//
if (props.stopRequest) {
props.stopRequest();
}
} else {
//
if (!inputMessage.value.trim()) return;
emit('send', inputMessage.value)
//
if (props.holdKeyboard && textareaRef.value) {
nextTick(() => {
textareaRef.value.focus()
})
}
}
}
const handleFocus = () => {
isFocused.value = true
emit('noHideKeyboard')
}
const handleBlur = () => {
isFocused.value = false
}
const handleTouchStart = () => {
emit('noHideKeyboard')
}
const handleTouchEnd = () => {
emit('noHideKeyboard')
}
//
const focusInput = () => {
if (textareaRef.value) {
textareaRef.value.focus()
}
}
//
const blurInput = () => {
if (textareaRef.value) {
textareaRef.value.blur()
}
}
//
defineExpose({
focusInput,
blurInput,
isFocused,
toggleVoiceMode
})
</script>
<style scoped lang="scss">
@ -64,7 +272,9 @@ const handleNoHideKeyboard = () => {
background-color: #FFFFFF;
box-shadow: 0px 0px 20px 0px rgba(52,25,204,0.05);
margin: 0 12px;
/* 确保输入框在安全区域内 */
margin-bottom: 8px;
.input-container-voice {
display: flex;
align-items: center;
@ -73,13 +283,34 @@ const handleNoHideKeyboard = () => {
height: 44px;
flex-shrink: 0;
align-self: flex-end;
cursor: pointer;
image {
width: 22px;
height: 22px;
}
}
.input-button-container {
flex: 1;
position: relative;
height: 44px;
}
.hold-to-talk-button {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: #333333;
font-size: 16px;
display: flex;
justify-content: center;
align-items: center;
background-color: #FFFFFF;
}
.input-container-send {
display: flex;
align-items: center;
@ -88,21 +319,132 @@ const handleNoHideKeyboard = () => {
height: 44px;
flex-shrink: 0;
align-self: flex-end;
.input-container-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
image {
width: 28px;
height: 28px;
}
}
.textarea {
flex: 1;
flex: 1;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
max-height: 92px;
min-height: 22px;
min-height: 44px;
font-size: 16px;
line-height: 22px;
margin-bottom: 2px;
padding: 3px 0 0;
align-items: center;
justify-content: center;
}
/* 确保textarea在iOS上的样式正常 */
.textarea::-webkit-input-placeholder {
color: #CCCCCC;
}
.textarea:focus {
outline: none;
}
}
</style>
/* 录音弹窗样式 */
.recording-popup {
width: 280px;
height: 280px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
}
.recording-wave {
width: 160px;
height: 160px;
border-radius: 50%;
background-color: rgba(76, 217, 100, 0.3);
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.wave-animation {
width: 100px;
height: 100px;
/* 这里可以添加波形动画 */
background-image: url('/static/wave_icon.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.recording-text {
font-size: 16px;
margin-bottom: 20px;
}
.recording-cancel {
font-size: 14px;
color: #CCCCCC;
}
/* 语音结果弹窗样式 */
.voice-result-popup {
width: 300px;
background-color: white;
border-radius: 16px;
padding: 20px;
}
.voice-result-bubble {
background-color: #4CD964;
color: white;
padding: 15px;
border-radius: 18px;
border-top-left-radius: 4px;
margin-bottom: 20px;
min-height: 60px;
font-size: 16px;
}
.voice-result-actions {
display: flex;
justify-content: space-between;
}
.action-button {
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
}
.cancel {
color: #666666;
background-color: #F5F5F5;
}
.voice {
color: #007AFF;
background-color: #E8F0FE;
}
.send {
color: white;
background-color: #007AFF;
}
</style>

View File

@ -2,7 +2,7 @@
<view class="chat-container" @touchend="handleTouchEnd">
<!-- 顶部的背景 -->
<ChatTopBgImg class="chat-container-bg"></ChatTopBgImg>
<view class="chat-content">
<!-- 顶部自定义导航栏 -->
<view class="nav-bar-container" :style="{
@ -10,7 +10,7 @@
}">
<ChatTopNavBar @openDrawer="openDrawer"></ChatTopNavBar>
</view>
<!-- 消息列表可滚动区域 -->
<scroll-view
:scroll-top="scrollTop"
@ -19,15 +19,15 @@
class="area-msg-list"
>
<!-- welcome栏 -->
<ChatTopWelcome class="chat-container-top-bannar"
<ChatTopWelcome class="chat-container-top-bannar"
:initPageImages="mainPageDataModel.initPageImages"
:welcomeContent="mainPageDataModel.welcomeContent">
</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">
<ChatCardAI class="message-item-ai" :text="item.msg">
<template #content v-if="item.toolCall">
<template #content v-if="item.toolCall">
<QuickBookingComponent v-if="item.toolCall.componentName === CompName.quickBookingCard"/>
<DiscoveryCardComponent v-else-if="item.toolCall.componentName === CompName.discoveryCard"/>
<CreateServiceOrder v-else-if="item.toolCall.componentName === CompName.createWorkOrderCard"/>
@ -36,47 +36,52 @@
<!-- 这个是底部 -->
<AttachListComponent v-if="item.question" :question="item.question" @replySent="handleReply"/>
</template>
</ChatCardAI>
</ChatCardAI>
</template>
<template v-else-if="item.msgType === MessageRole.ME">
<ChatCardMine class="message-item-mine" :text="item.msg">
</ChatCardMine>
</ChatCardMine>
</template>
<template v-else>
<ChatCardOther class="message-item-other" :text="item.msg">
<ChatMoreTips @replySent="handleReply" :itemList="mainPageDataModel.guideWords"/>
<ActivityListComponent v-if="mainPageDataModel.activityList.length > 0" :activityList="mainPageDataModel.activityList"/>
<RecommendPostsComponent v-if="mainPageDataModel.recommendTheme.length > 0" :recommendThemeList="mainPageDataModel.recommendTheme" />
</ChatCardOther>
</template>
</template>
</view>
</scroll-view>
<!-- 输入框区域 -->
<view class="footer-area">
<ChatQuickAccess @replySent="handleReplyInstruct"/>
<ChatInputArea
v-model="inputMessage"
:holdKeyboard="holdKeyboard"
@send="sendMessageAction"
@noHideKeyboard="handleNoHideKeyboard"
/>
ref="inputAreaRef"
v-model="inputMessage"
:holdKeyboard="holdKeyboard"
:is-session-active="isSessionActive"
:stop-request="stopRequest"
@send="sendMessageAction"
@noHideKeyboard="handleNoHideKeyboard"
@keyboardShow="handleKeyboardShow"
@keyboardHide="handleKeyboardHide"
/>
</view>
</view>
</view>
</template>
<script setup >
import { onMounted, nextTick } from 'vue'
import { onMounted, nextTick, computed } from 'vue'
import { ref } from 'vue'
import { defineEmits } from 'vue'
import { onLoad } from '@dcloudio/uni-app';
import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE } from '@/constant/constant'
import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE, SEND_COMMAND_TEXT } from '@/constant/constant'
import { MessageRole, MessageType, CompName } from '../../model/ChatModel';
import ChatTopWelcome from './ChatTopWelcome.vue';
@ -89,38 +94,46 @@
import ChatMoreTips from './ChatMoreTips.vue';
import ChatInputArea from './ChatInputArea.vue'
import CommandWrapper from '@/components/CommandWrapper/index.vue'
import QuickBookingComponent from '../module/booking/QuickBookingComponent.vue'
import DiscoveryCardComponent from '../module/discovery/DiscoveryCardComponent.vue';
import ActivityListComponent from '../module/banner/ActivityListComponent.vue';
import RecommendPostsComponent from '../module/recommend/RecommendPostsComponent.vue';
import AttachListComponent from '../module/attach/AttachListComponent.vue';
import CreateServiceOrder from '@/components/CreateServiceOrder/index.vue'
import { agentChatStream } from '@/request/api/AgentChatStream';
import { mainPageData } from '@/request/api/MainPageDataApi';
import { conversationMsgList, recentConversation } from '@/request/api/ConversationApi';
///
const statusBarHeight = ref(20);
///
const inputAreaRef = ref(null);
const timer = ref(null)
/// 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 scrollTop = ref(99999);
///
const chatMsgList = ref([])
///
const inputMessage = ref('')
///
let currentAIMsgIndex = 0
///
const sceneId = ref('')
/// agentId
@ -131,11 +144,15 @@
const mainPageDataModel = ref({})
//
let isSessionActive = false;
const isSessionActive = ref(false);
//
const requestTaskRef = ref(null);
///
let commonType = ''
//
const emits = defineEmits(['openDrawer'])
const openDrawer = () => {
@ -144,23 +161,37 @@
}
const handleTouchEnd = () => {
// #ifdef MP-WEIXIN
clearTimeout(timer.value)
timer.value = setTimeout(() => {
//
if (handleNoHideKeyboard) {
if (holdKeyboardFlag.value && isKeyboardShow.value) {
uni.hideKeyboard()
}
holdKeyboardFlag.value = true
}, 50)
// #endif
}, 100)
}
//
const handleNoHideKeyboard = () => {
// #ifdef MP-WEIXIN
holdKeyboardFlag.value = false
// #endif
}
//
const handleKeyboardShow = (height) => {
keyboardHeight.value = height
isKeyboardShow.value = true
holdKeyboard.value = true
//
setTimeout(() => {
scrollToBottom()
}, 150)
}
//
const handleKeyboardHide = () => {
keyboardHeight.value = 0
isKeyboardShow.value = false
holdKeyboard.value = false
}
///
@ -177,7 +208,7 @@
scrollToBottom()
}, 100)
}
///
const handleReply = (text) => {
sendMessage(text)
@ -204,8 +235,11 @@
if (!inputText.trim()) return;
handleNoHideKeyboard()
sendMessage(inputText)
if(!isSessionActive) {
inputMessage.value = ''
//
if (holdKeyboard.value && inputAreaRef.value) {
setTimeout(() => {
inputAreaRef.value.focusInput()
}, 100)
}
setTimeoutScrollToBottom()
}
@ -217,14 +251,14 @@
}
});
});
onMounted( async() => {
getMainPageData()
await loadRecentConversation()
loadConversationMsgList()
addNoticeListener()
})
const addNoticeListener = () => {
uni.$on(SCROLL_TO_BOTTOM, (value) => {
setTimeout(() => {
@ -238,8 +272,17 @@
handleReply(value)
}
})
uni.$on(SEND_COMMAND_TEXT, (value) => {
console.log('SEND_COMMAND_TEXT:', value)
if(value && value.length > 0) {
commonType = 'Command.quickBooking'
sendMessage(value, true)
setTimeoutScrollToBottom()
}
})
}
/// id
const loadRecentConversation = async() => {
const res = await recentConversation()
@ -247,7 +290,7 @@
conversationId.value = res.data.conversationId
}
}
///
let historyCurrentPageNum = 1
const loadConversationMsgList = async() => {
@ -265,7 +308,7 @@
scrollToBottom()
}
}
///
const initData = () => {
const msg = {
@ -275,18 +318,18 @@
}
chatMsgList.value.push(msg)
}
///
const sendMessage = (message, isInstruct = false) => {
if (isSessionActive) {
if (isSessionActive.value) {
uni.showToast({
title: '请等待当前回复完成',
icon: 'none'
});
return;
}
isSessionActive = true;
isSessionActive.value = true;
const newMsg = {
msgId: `msg_${chatMsgList.value.length}`,
msgType: MessageRole.ME,
@ -297,10 +340,11 @@
}
}
chatMsgList.value.push(newMsg)
sendChat(message, isInstruct)
inputMessage.value = '';
sendChat(message, isInstruct)
console.log("发送的新消息:",JSON.stringify(newMsg))
}
///
let loadingTimer = null;
let typeWriterTimer = null;
@ -315,7 +359,7 @@
messageType: isInstruct ? 1 : 0,
messageContent: isInstruct ? commonType : message
}
// AI
const aiMsg = {
msgId: `msg_${chatMsgList.value.length}`,
@ -328,7 +372,8 @@
}
chatMsgList.value.push(aiMsg)
const aiMsgIndex = chatMsgList.value.length - 1
currentAIMsgIndex = aiMsgIndex
//
let dotCount = 1;
loadingTimer && clearInterval(loadingTimer);
@ -336,16 +381,16 @@
dotCount = dotCount % 3 + 1;
chatMsgList.value[aiMsgIndex].msg = '加载中' + '.'.repeat(dotCount);
}, 400);
aiMsgBuffer = '';
isTyping = false;
if (typeWriterTimer) {
clearTimeout(typeWriterTimer);
typeWriterTimer = null;
}
//
agentChatStream(args, (chunk) => {
const { promise, requestTask } = agentChatStream(args, (chunk) => {
console.log('分段内容:', chunk)
if (chunk && chunk.error) {
chatMsgList.value[aiMsgIndex].msg = '请求错误,请重试';
@ -353,7 +398,7 @@
loadingTimer = null;
isTyping = false;
typeWriterTimer = null;
isSessionActive = false; //
isSessionActive.value = false; //
console.error('流式错误:', chunk.message, chunk.detail);
return;
}
@ -366,7 +411,7 @@
}
//
aiMsgBuffer += chunk.content;
//
if (!isTyping) {
isTyping = true;
@ -383,7 +428,7 @@
loadingTimer = null;
isTyping = false;
typeWriterTimer = null;
// '.'
const msg = chatMsgList.value[aiMsgIndex].msg;
console.log('msg:', msg)
@ -393,33 +438,41 @@
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 = false;
isSessionActive.value = false;
scrollToBottom();
}
}, 50);
}
}).catch(e => {
isSessionActive = false; //
console.log('error:', e)
})
//
requestTaskRef.value = requestTask;
// 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();
});
@ -428,12 +481,40 @@
//
typeWriterTimer = setTimeout(typeWriter, 30);
}
}
}
}
//
const stopRequest = () => {
if (requestTaskRef.value && requestTaskRef.value.abort) {
//
requestTaskRef.value.isAborted = true;
//
requestTaskRef.value.abort();
//
isSessionActive.value = false;
const msg = chatMsgList.value[currentAIMsgIndex].msg;
if (!msg || msg === '加载中.' || msg.startsWith('加载中')) {
chatMsgList.value[currentAIMsgIndex].msg = '已终止请求,请重试';
}
//
if (loadingTimer) {
clearInterval(loadingTimer);
loadingTimer = null;
}
if (typeWriterTimer) {
clearTimeout(typeWriterTimer);
typeWriterTimer = null;
}
setTimeoutScrollToBottom()
//
requestTaskRef.value = null;
}
}
</script>
<style lang="scss" scoped>
@import "styles/ChatMainList.scss";
</style>
</style>

View File

@ -1,4 +1,4 @@
.chat-container {
.chat-container {
width: 100vw;
height: 100vh;
background-color: #E9F3F7;
@ -6,7 +6,9 @@
display: flex;
flex-direction: column;
overflow: hidden !important;
position: relative;
position: relative;
/* 确保在键盘弹起时布局正确 */
box-sizing: border-box;
.chat-container-bg {
position: fixed;
@ -20,14 +22,12 @@
.chat-content {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
z-index: 1;
overflow: hidden;
overflow: hidden;
}
.chat-container-top-bannar {
@ -77,13 +77,19 @@
.footer-area {
width: 100vw;
flex-shrink: 0;
padding: 4px 0 24px 0;
padding: 4px 0 20px 0; /* 直接设置20px底部安全距离 */
background-color: #E9F3F7;
touch-action: pan-x; /* 仅允许横向触摸滚动 */
overflow-x: auto; /* 允许横向滚动 */
overflow-y: hidden; /* 禁止垂直滚动 */
/* 确保高度能够正确计算 */
touch-action: pan-x;
overflow-x: auto;
overflow-y: hidden;
min-height: fit-content;
/* 安卓键盘适配 - 使用相对定位配合adjustPan */
position: relative;
z-index: 1;
transition: padding-bottom 0.3s ease;
/* 确保输入区域始终可见 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.area-input {

View File

@ -31,7 +31,7 @@
</view>
<!-- 底部退出按钮 -->
<button class="logout-btn" @click="handleLogout">退出登录</button>
<text class="logout-btn" @click="handleLogout">退出登录</text>
</view>
</template>
@ -140,8 +140,11 @@ const handleLogout = () => {
}
.logout-btn {
width: 90%;
margin: 80rpx auto;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
margin-top: 40px;
background-color: #fff;
color: #333;
border-radius: 8rpx;

View File

@ -8,7 +8,7 @@
:interval="interval"
:duration="duration">
<swiper-item v-for="item in activityList" :key="item.id">
<view class="swiper-item">
<view class="swiper-item" @click="handleClick(item)">
<image :src="item.activityCover" mode="aspectFill"></image>
<view class="corner-btn">快速预定</view>
</view>
@ -20,6 +20,7 @@
<script setup>
import { ref } from 'vue'
import { SEND_COMMAND_TEXT } from '@/constant/constant'
const autoplay = ref(true)
const interval = ref(3000)
@ -32,6 +33,10 @@
}
})
const handleClick = (item) => {
uni.$emit(SEND_COMMAND_TEXT, '快速预定')
}
</script>
<style scoped lang="scss">

View File

@ -3,18 +3,18 @@
<ModuleTitle :title="commodityDTO.title" />
<view class="container-scroll">
<view class="mk-card-item" v-for="(item) in commodityDTO.commodityList" :key="item.commodityName">
<view class="card-badge">超值推荐</view>
<image class="card-img" :src="item.commodityIcon" mode="widthFix" />
<!-- <view class="card-badge">超值推荐</view> -->
<image class="card-img" :src="item.commodityIcon" mode="aspectFill" />
<view class="card-content">
<view class="card-title-column">
<text class="card-title">{{ item.commodityName }}</text>
<view class="card-tags">
<text class="card-tag">随时可退</text>
<text class="card-tag">民俗表演</text>
<view class="card-tags" v-for="(tag) in item.commodityTradeRuleList" :key="tag">
<text class="card-tag">{{ tag }}</text>
</view>
</view>
<view class="card-desc">· 往返观光车票</view>
<view class="card-desc">· 营业时间9:00-22:00</view>
<template v-for="(serviceItem, index) in item.commodityServices" :key="serviceItem.serviceTitle">
<view v-if="index < 3" class="card-desc">· {{ serviceItem.serviceTitle }}</view>
</template>
<view class="card-bottom-row">
<view class="card-price-row">
<text class="card-price-fu"></text>
@ -72,7 +72,7 @@
flex-direction: column;
align-items: start;
width: 188px;
height: 244px;
// height: 244px;
background-color: #ffffff;
border-radius: 10px;
margin-right: 8px;
@ -94,27 +94,42 @@
width: 188px;
height: 114px;
border-radius: 10px;
object-fit: cover; /* 确保图片不变形,保持比例裁剪 */
flex-shrink: 0; /* 防止图片被压缩 */
}
.card-content {
box-sizing: border-box;
box-sizing: border-box;
padding: 10px 12px 0 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: start;
width: 100%;
flex: 1; /* 让内容区域占据剩余空间 */
overflow: hidden; /* 防止内容溢出 */
}
.card-title-column {
display: flex;
align-items: start;
flex-direction: column;
width: 100%;
}
.card-title {
font-size: 16px;
font-weight: bold;
color: #222;
width: 100%;
/* 限制标题最多显示两行 */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
max-height: 2.8em; /* 2行的高度 */
}
.card-tags {

View File

@ -7,16 +7,17 @@ const API = '/agent/assistant/chat';
* 获取AI聊天流式信息仅微信小程序支持
* @param {Object} params 请求参数
* @param {Function} onChunk 回调每收到一段数据触发
* @returns {Promise}
* @returns {Object} 包含Promise和requestTask的对象
*/
function agentChatStream(params, onChunk) {
return new Promise((resolve, reject) => {
let requestTask;
const promise = new Promise((resolve, reject) => {
const token = uni.getStorageSync('token');
let hasError = false;
console.log("发送请求内容: ", params)
// #ifdef MP-WEIXIN
const requestTask = uni.request({
requestTask = uni.request({
url: BASE_URL + API, // 替换为你的接口地址
method: 'POST',
data: params,
@ -28,22 +29,21 @@ function agentChatStream(params, onChunk) {
},
responseType: 'arraybuffer',
success(res) {
resolve(res.data);
resolve(res.data);
},
fail(err) {
console.log("====> ", JSON.stringify(err))
reject(err);
reject(err);
},
complete(res) {
complete(res) {
if(res.statusCode !== 200) {
console.log("====> ", JSON.stringify(res))
if (onChunk) {
onChunk({ error: true, message: '服务器错误', detail: res });
}
reject(res);
}
}
console.log("====> ", JSON.stringify(res))
if (onChunk) {
onChunk({ error: true, message: '服务器错误', detail: res });
}
reject(res);
}
}
});
requestTask.onHeadersReceived(res => {
@ -59,11 +59,12 @@ function agentChatStream(params, onChunk) {
});
requestTask.onChunkReceived(res => {
if (hasError) return;
// 检查请求是否已被中止
if (hasError || requestTask.isAborted) return;
const base64 = uni.arrayBufferToBase64(res.data);
let data = '';
try {
data = decodeURIComponent(escape(atob(base64)));
data = decodeURIComponent(escape(weAtob(base64)));
} catch (e) {
// 某些平台可能不支持 atob可以直接用 base64
data = base64;
@ -75,8 +76,62 @@ function agentChatStream(params, onChunk) {
});
// #endif
});
return {
promise,
requestTask
};
}
// window.atob兼容性处理
const weAtob = (string) => {
const b64re = /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/;
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
// 去除空白字符
string = String(string).replace(/[\t\n\f\r ]+/g, '');
// 验证 Base64 编码
if (!b64re.test(string)) {
throw new TypeError(
// eslint-disable-next-line quotes
"Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded."
);
}
// 填充字符
string += '=='.slice(2 - (string.length & 3));
let bitmap,
result = '',
r1,
r2,
i = 0;
for (; i < string.length;) {
bitmap =
(b64.indexOf(string.charAt(i++)) << 18) |
(b64.indexOf(string.charAt(i++)) << 12) |
((r1 = b64.indexOf(string.charAt(i++))) << 6) |
(r2 = b64.indexOf(string.charAt(i++)));
if (r1 === 64) {
result += String.fromCharCode((bitmap >> 16) & 255);
} else if (r2 === 64) {
result += String.fromCharCode(
(bitmap >> 16) & 255,
(bitmap >> 8) & 255
);
} else {
result += String.fromCharCode(
(bitmap >> 16) & 255,
(bitmap >> 8) & 255,
bitmap & 255
);
}
}
return result;
};
// 解析SSE分段数据
function parseSSEChunk(raw) {
// 拆分为多段
@ -97,4 +152,4 @@ function parseSSEChunk(raw) {
return results;
}
export { agentChatStream }
export { agentChatStream }

View File

@ -1,4 +1,5 @@
import { BASE_URL } from "../../constant/base";
import { goLogin } from "@/hooks/useGoLogin";
const defaultConfig = {
header: {
@ -14,7 +15,7 @@ function request(url, args = {}, method = 'POST', customConfig = {}) {
}
// 动态获取 token
const token = uni.getStorageSync('token');
let header = {
...defaultConfig.header,
...customConfig.header
@ -26,16 +27,16 @@ function request(url, args = {}, method = 'POST', customConfig = {}) {
if (token) {
header.Authorization = `Bearer ${token}`;
}
}
}
console.log("请求头customConfig:" + JSON.stringify(customConfig))
const config = {
...defaultConfig,
...customConfig,
header
};
console.log("请求接口:" + url)
console.log("请求头:" + JSON.stringify(config))
console.log("请求参数:" + JSON.stringify(args))
@ -49,6 +50,10 @@ function request(url, args = {}, method = 'POST', customConfig = {}) {
success: (res) => {
console.log("请求响应:" + JSON.stringify(res))
resolve(res.data)
if(res.statusCode && res.statusCode === 424) {
uni.setStorageSync('token', '')
goLogin();
}
},
fail: (err) => {
console.error("请求失败:", err);
@ -69,4 +74,4 @@ request.get = function(url, args = {}, config = {}) {
};
export default request;
export default request;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/input_stop_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB