AI聊天交互页面(uniapp+vue3+zhipu)

一 简述

AI生成的聊天界面,使用流式输出

接口springboot提供

二 注意

1.流式地址,需要全地址,需要手动获取baseurl拼接

// 读取baseUrl
    import config from '@/config.js';
    const baseURL = config.baseUrl;

2.往响应式数组中,加响应式对象,实现流式输出

3.若流式请求方式app,小程序端报异步错误,可以参考如下链接,换请求方式

https://www.cnblogs.com/qxAndWorld/p/19342756

三 代码

<template>
    <view class="container">
        <!-- 聊天头部 -->
        <view class="header">
            <view class="header-content">
                <text class="header-title">AI 助手</text>
                <text class="header-subtitle">随时为您解答疑问</text>
            </view>
        </view>

        <!-- 聊天消息区域 -->
        <scroll-view class="chat-container" scroll-y="true" :scroll-top="scrollTop" :scroll-with-animation="true"
            @scrolltoupper="onScrollToUpper" upper-threshold="50">
            <view class="messages-wrapper">
                <view v-for="(message, index) in messages" :key="index" class="message-item"
                    :class="{ 'user-message': message.role === 'user', 'ai-message': message.role === 'ai' }">
                    <view class="avatar-container">
                        <view class="avatar"
                            :class="{ 'user-avatar': message.role === 'user', 'ai-avatar': message.role === 'ai' }">
                            {{ message.role === 'user' ? 'U' : 'AI' }}
                        </view>
                    </view>
                    <view class="message-content">
                        <view class="message-bubble"
                            :class="{ 'user-bubble': message.role === 'user', 'ai-bubble': message.role === 'ai' }">
                            <text class="message-text">{{ message.content }}</text>
                        </view>
                        <text class="message-time">{{ formatTime(message.timestamp) }}</text>
                    </view>
                </view>

                <!-- 加载状态 -->
                <view v-if="loading" class="loading-container">
                    <view class="typing-indicator">
                        <view class="dot"></view>
                        <view class="dot"></view>
                        <view class="dot"></view>
                    </view>
                </view>
            </view>
        </scroll-view>

        <!-- 输入区域 -->
        <view class="input-container">
            <view class="input-wrapper">
                <textarea class="input-field" placeholder="请输入您的问题..." v-model="inputText" :auto-height="true"
                    :maxlength="500" @confirm="sendMessage" @keyboardheightchange="onKeyboardChange" />
                <button class="send-button" :disabled="!inputText.trim() || loading" @click="sendMessage">
                    <text class="send-text">发送</text>
                </button>
            </view>
        </view>
    </view>
</template>

<script setup>
    import {
        ref,
        reactive,
        nextTick
    } from 'vue';
    import {
        fetchEventSource
    } from '@microsoft/fetch-event-source';

    // 读取baseUrl
    import config from '@/config.js';
    const baseURL = config.baseUrl;

    // 响应式数据
    const inputText = ref('')
    const loading = ref(false)
    const scrollTop = ref(0)
    const keyboardHeight = ref(0)



    // 消息数组
    const messages = reactive([{
        role: 'ai',
        content: '您好!我是AI助手,很高兴为您服务。请问有什么可以帮助您的吗?',
        timestamp: new Date()
    }])

    // 发送消息
    const sendMessage = async () => {
        if (!inputText.value.trim() || loading.value) return

        const userMessage = {
            role: 'user',
            content: inputText.value.trim(),
            timestamp: new Date()
        }

        messages.push(userMessage)
        inputText.value = ''

        // 滚动到底部
        await nextTick()
        scrollToBottom()

        // 显示加载状态
        loading.value = true

        try {
            // 模拟AI回复(实际项目中这里应该是API调用)
            setTimeout(() => {
                
                const aiResponse = reactive({
                    role: 'ai',
                    content: "",
                    timestamp: new Date()
                })
                messages.push(aiResponse)    
                

                // 获取AI回复(模拟)
                fetchEventSource(baseURL + '/ai/callChatAiflux', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        // 如果需要认证,可加:
                        // 'Authorization': 'Bearer ' + token
                    },
                    body: JSON.stringify({
                        "text": userMessage.content
                    }),
                        
                    // 收到新事件
                    onmessage(event) {
                        if (event.data) {
                            // const data = JSON.parse(event.data)
                            // 假设后端每次返回 { content: "部分文本" }
                            // airetmsg.value = airetmsg.value + event.data;
                            aiResponse.content = aiResponse.content + event.data;
                            // console.log(aiResponse.content);
                            // console.log(event.data)
                        }
                    },

                    // 连接打开
                    onopen(response) {
                        
                        if (response.ok) {
                            console.log('SSE 连接已建立')
                        } else {
                            throw new Error(`连接失败: ${response.status}`)
                        }
                    },

                    // 错误处理
                    onerror(err) {
                        console.error('SSE 错误:', err)
                        // 可选:自动重连逻辑(默认会重连)
                        throw err; // 抛出错误将停止重连
                    },

                    // 流结束(服务器关闭连接)
                    onclose() {
                        console.log('SSE 连接已关闭')

                    }
                })


                
                loading.value = false

                // 再次滚动到底部
                nextTick(() => {
                    scrollToBottom()
                })
            }, 1500)
        } catch (error) {
            console.error('发送消息失败:', error)
            loading.value = false

            // 添加错误提示
            messages.push({
                role: 'ai',
                content: '抱歉,网络出现问题,请稍后重试。',
                timestamp: new Date()
            })
        }
    }



    // 格式化时间
    function formatTime(date) {
        const now = new Date()
        const diff = now - date
        const minutes = Math.floor(diff / 60000)

        if (minutes < 1) return '刚刚'
        if (minutes < 60) return `${minutes}分钟前`

        const hours = Math.floor(minutes / 60)
        if (hours < 24) return `${hours}小时前`

        return `${Math.floor(hours / 24)}天前`
    }

    // 滚动到底部
    function scrollToBottom() {
        nextTick(() => {
            scrollTop.value = 999999
        })
    }

    // 监听键盘变化
    function onKeyboardChange(e) {
        keyboardHeight.value = e.detail.height
    }

    // 上拉加载更多(模拟)
    function onScrollToUpper() {
        // 这里可以实现加载历史消息的逻辑
        console.log('滚动到顶部')
    }
</script>

<style lang="scss">
    .container {
        height: 100vh;
        display: flex;
        flex-direction: column;
        background-color: #f5f5f5;
    }

    .header {
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        padding: 40rpx 30rpx;
        color: white;

        .header-content {
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .header-title {
            font-size: 36rpx;
            font-weight: bold;
            margin-bottom: 10rpx;
        }

        .header-subtitle {
            font-size: 28rpx;
            opacity: 0.9;
        }
    }

    .chat-container {
        flex: 1;
        padding: 30rpx;
        box-sizing: border-box;

        .messages-wrapper {
            min-height: 100%;
        }
    }

    .message-item {
        display: flex;
        margin-bottom: 40rpx;
        animation: slideIn 0.3s ease-out;

        &.user-message {
            flex-direction: row-reverse;

            .message-content {
                align-items: flex-end;
            }
        }

        &.ai-message {
            .message-content {
                align-items: flex-start;
            }
        }
    }

    @keyframes slideIn {
        from {
            opacity: 0;
            transform: translateY(20rpx);
        }

        to {
            opacity: 1;
            transform: translateY(0);
        }
    }

    .avatar-container {
        display: flex;
        align-items: flex-start;
        margin-right: 20rpx;

        .user-message & {
            margin-right: 0;
            margin-left: 20rpx;
        }
    }

    .avatar {
        width: 80rpx;
        height: 80rpx;
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 32rpx;
        font-weight: bold;
        color: white;

        &.user-avatar {
            background: linear-gradient(135deg, #ff6b6b, #ee5a24);
        }

        &.ai-avatar {
            background: linear-gradient(135deg, #4ecdc4, #44a08d);
        }
    }

    .message-content {
        display: flex;
        flex-direction: column;
        flex: 1;
    }

    .message-bubble {
        max-width: 70%;
        padding: 25rpx 30rpx;
        border-radius: 30rpx;
        position: relative;
        margin-bottom: 15rpx;

        &::after {
            content: '';
            position: absolute;
            width: 0;
            height: 0;
            border-style: solid;
        }

        &.user-bubble {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            margin-left: auto;
            border-bottom-right-radius: 10rpx;

            &::after {
                top: 20rpx;
                right: -20rpx;
                border-width: 10rpx 0 10rpx 20rpx;
                border-color: transparent transparent transparent #667eea;
            }
        }

        &.ai-bubble {
            background: white;
            color: #333;
            border-bottom-left-radius: 10rpx;

            &::after {
                top: 20rpx;
                left: -20rpx;
                border-width: 10rpx 20rpx 10rpx 0;
                border-color: transparent #ffffff transparent transparent;
            }
        }
    }

    .message-text {
        line-height: 1.6;
        font-size: 30rpx;
        word-wrap: break-word;
        word-break: break-all;
    }

    .message-time {
        font-size: 24rpx;
        color: #999;
        text-align: right;
    }

    .loading-container {
        display: flex;
        justify-content: center;
        padding: 30rpx 0;
    }

    .typing-indicator {
        display: flex;
        align-items: center;
        padding: 20rpx 30rpx;
        background: white;
        border-radius: 30rpx;
        border-bottom-left-radius: 10rpx;

        .dot {
            width: 12rpx;
            height: 12rpx;
            background: #999;
            border-radius: 50%;
            margin: 0 6rpx;
            animation: bounce 1.4s infinite both;

            &:nth-child(2) {
                animation-delay: 0.2s;
            }

            &:nth-child(3) {
                animation-delay: 0.4s;
            }
        }
    }

    @keyframes bounce {

        0%,
        80%,
        100% {
            transform: scale(0.8);
            opacity: 0.6;
        }

        40% {
            transform: scale(1.2);
            opacity: 1;
        }
    }

    .input-container {
        padding: 30rpx;
        background: white;
        border-top: 1rpx solid #eee;
    }

    .input-wrapper {
        display: flex;
        align-items: flex-end;
        gap: 20rpx;
    }

    .input-field {
        flex: 1;
        min-height: 80rpx;
        max-height: 200rpx;
        padding: 20rpx;
        border: 2rpx solid #e0e0e0;
        border-radius: 20rpx;
        font-size: 30rpx;
        background: #fafafa;
    }

    .send-button {
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        color: white;
        border: none;
        border-radius: 20rpx;
        padding: 25rpx 40rpx;
        font-size: 30rpx;
        font-weight: bold;

        &:not(:disabled) {
            opacity: 1;
        }

        &:disabled {
            opacity: 0.6;
        }
    }

    .send-text {
        color: white;
    }

    /* 在不同平台上的特殊样式 */
    /* #ifdef MP-WEIXIN */
    .container {
        padding-bottom: env(safe-area-inset-bottom);
    }

    /* #endif */

    /* #ifdef H5 */
    .container {
        padding-bottom: 0;
    }

    /* #endif */
</style>

 

posted @ 2025-12-12 14:52  qx和世界  阅读(1)  评论(1)    收藏  举报