记录一个AI对话VUE页面,使用了MutationObserver处理数据滚动问题

<script lang="jsx" >
import {defineComponent, ref} from "vue";
import {  CloseCircleOutlined } from '@ant-design/icons-vue';
import { useRouter } from "vue-router";
import { message } from 'ant-design-vue';
import { nextTick } from "vue";
import userAvatrImg from '@/assets/person.jpg'
import robotImg from './img/robot.png'

const AIChat = defineComponent({
    setup() {
        const router = useRouter()
        const msgList = ref([])

        // 头部render
        const renderHeader = () => {
            return <div class="header-nav">
                <div class="back-icon" onClick={router.back}></div>
                AI问答
            </div>
        }

        const renderMsgItemSlots = {
            'icon': () => {
                // return <UserOutlined />
                return <img src={userAvatrImg}></img>
            }
        }

        const renderMsgItemSlotsRoot = {
            'icon': () => {
                return <img style="width:100%;height:100%" src={robotImg}></img>
            }
        }

        const rendMsgItemUploading = (item) => {
            switch(item.uploading) {
                case 'loading':
                return <span>文件上传解析中...</span>
                case 'success':
                return (<>
                    <span>{item.fileList.length}份文件已上传</span><a-progress strokeColor="#F6A622" format={(percent) => `${percent}%`} size="small" style="width: 100px;margin-left:12px" percent={100} />
                    </>
                )
                case 'error':
                return <span style="color:red">文件解析异常,请尝试重新发送</span>
                case 'stop':
                return <span style="color:#F6A622">已停止</span>
            }
        }

        const isAiStop = ref(false)
                
        let controller = new AbortController();

        const onStop = () => {
            const msgVal = msgList.value
            if (!msgVal[msgVal.length - 1].isAi) {
                msgVal[msgVal.length - 1].uploading = 'stop'
            }
            isAiStop.value = true
            sendLoading.value = false
            controller.abort();
           
        }

        // 消息问答项render
        const renderMsgItem = (item) => {
            return <>
                <div class="msg-item" id={item.domId}>
                    <div class="msg-item-left">
                        {
                            !item.isAi ?
                            <a-avatar size={40} v-slots={renderMsgItemSlots}>
                                </a-avatar>
                            :  
                            <img style="width:100%;height:100%" src={robotImg}></img>
                        }
                    </div>
                    {
                        item.isAi ?
                        <div class="msg-item-right" v-html={item.content} >
                        </div> :
                        <div class="msg-item-right">
                            <div><span class="user-msg-title"> 文件附件:</span>
                                {
                                    rendMsgItemUploading(item)
                                } 
                            </div>
                            <div><span class="user-msg-title"> 筛选条件:</span>{item.sendMsg}</div>
                        </div>
                    }
                    
                </div>
            </>
        }
        const renderMsgBodyNoData = () => {
            return <>
                <div class="msg-body-no-data">
                    <img src={robotImg}></img>
                    <div style="margin-top: 10px;color:#777;">请上传需要筛选的文件并输入筛选条件</div>
                </div>
            </>
        }

        // 消息问答体render
        const renderMsgBody = () => {
            return <>
                <div class="msg-list-box">
                    <div class="msg-list-scroll-box">
                        {msgList.value.map((item) => {
                            return renderMsgItem(item)
                        })}
                    </div>
                    {
                        sendLoading.value ?
                        <div  class="stop-btn" onClick={onStop}>停止</div> :
                        ''
                    }
                    {
                        !msgList.value.length ?
                        renderMsgBodyNoData()
                        : ''
                    }
                </div>
            </>
        }

        // 上传的文件项目render
        const renderFooterLeftFileItem = (item, index) => {
            return <>
                <div class="footer-pdf-item">
                    <div class="pdf-item-left">
                        <div class="file-name" title={item.name}>
                            {item.name}
                        </div>
                        <div class="file-size">
                            {(item.size/1000).toFixed(2)}kb
                        </div>
                    </div>
                    <div class="pdf-item-right">
                        <div class="pdf-icon"></div>
                    </div>

                    <div class="footer-pdf-item-delete-icon" title="移除" onClick={() => pdfFileList.value.splice(index, 1)}>
                        <CloseCircleOutlined />
                    </div>
                </div>
            </>
        }

        const pdfFileList = ref([])

        // 点击文件上传
        const onUpload = () => {
            const dom = document.createElement('input')
            dom.type = 'file'
            dom.multiple = true
            dom.accept = `application/msword,application/pdf`
            dom.style.cssText = `
                position: fixed;left: -999px;top: -200px;width: 0;height: 0;opcity: 0;
            `
            document.body.appendChild(dom)
            dom.onchange = onUploadFileChange
            dom.click()
            setTimeout(() => {
                document.body.removeChild(dom)
            }, 2000)
        }

        const onUploadFileChange = (e) => {
            const { target } = e
            const { files } = target
            pdfFileList.value.push(...files)
        }

        // 底部左侧render
        const renderFooterLeft = () => {
            return <>
                <div class="footer-left">
                    <div class="footer-left-header">
                        <div style="display:flex;align-items:center;">
                            <div class="upload-icon"></div>
                            文件上传
                        </div>
                        <div class="upload-btn" onClick={onUpload}>
                            上传
                        </div>
                    </div>
                    <div class="file-pdf-box"> { pdfFileList.value.map((item, index) => renderFooterLeftFileItem(item, index)) } </div>
                </div>
            </>
        }

        const sendMsg = ref('')
        let sendLoading = ref(false)


        const httpPostSend = () => {
            const form = new FormData()
            form.append('prompt', sendMsg.value)
            pdfFileList.value.forEach((item) => {
                form.append('file', item)
            })
            let index = 0
            controller = new AbortController();
            // 发送请求获取流式数据
            fetch('/xuehua/resume-genie', {
                method: 'post',
                responseType: 'stream',
                body: form,
                signal: controller.signal
            })
            .then(response => {
                if (isAiStop.value) {
                    const msgVal = msgList.value
                    msgVal[msgVal.length - 1].uploading = 'stop'
                    sendLoading.value = false
                    return
                }
                const reader = response.body.getReader();  
                const decoder = new TextDecoder('utf-8');
                const domId = `user-${new Date().getTime()}_ai`
                let msgItemDom = null
                const observe = new MutationObserver(function(mu, ob){
                    if (msgItemDom) {
                        msgItemDom.scrollIntoView()
                        // msgItemDom.scrollTop = msgItemDom.scrollHeight - msgItemDom.clientHeight;
                    } else {
                        msgItemDom = document.querySelector(`#${domId}`)
                        msgItemDom?.scrollIntoView()
                    }
                })
                return reader.read().then(function processText({ done, value }) {  
                    if (done) {  
                        console.log('Stream finished.');  
                        if (!index) {
                            const msgVal = msgList.value
                            msgVal[msgVal.length - 1].uploading = 'error'
                        } else {
                            const msgVal = msgList.value
                            msgVal[msgVal.length - 2].uploading = 'success'
                        }
                        index = 0
                        sendLoading.value = false
                        return;  
                    }
                    const val = decoder.decode(value)
                    if (index == 0) {
                        msgList.value.push({
                            isAi: true,
                            content: val,
                            domId
                        })
                        index++
                        nextTick(() => {
                            const dom = document.querySelector(`#${domId}`)
                            dom.scrollIntoView()
                            observe.observe(dom, { attributes: true, childList: true, subtree: true });
                        })
                    } else {
                        if (!isAiStop.value) {
                            const msgVal = msgList.value
                            msgVal[msgVal.length - 1].content += val
                        } else {
                            sendLoading.value = false
                            const msgVal = msgList.value
                            msgVal[msgVal.length - 2].uploading = 'stop'
                        }
                    }
                    // console.log(`Received data chunk: ${decoder.decode(value)}`);  
                    // Continue reading  
                    return reader.read().then(processText);  
                });  
               
            })
            .catch(error => {
                sendLoading.value = false
                const msgVal = msgList.value
                let uploading = 'error'
                if (error.message == 'signal is aborted without reason' || 
                    error.message == 'BodyStreamBuffer was aborted'
                ) {
                    uploading = 'stop'
                    console.error('请求出错2:', error.message);
                }

                if (!msgVal[msgVal.length - 1].isAi) {
                    msgVal[msgVal.length - 1].uploading = uploading
                } else {
                    msgVal[msgVal.length - 2].uploading = uploading
                }
                console.error('请求出错:', error);
            });
        }


        const onSend = () => {
            if (sendLoading.value) {
                message.warning('请先等待AI完成解析');
                return
            }
            let fileLen = pdfFileList.value.length
            if (fileLen <= 0) {
                message.warning('至少上传一份文件哦!');
                return
            }
            if (!sendMsg.value.trim().length) {
                message.warning('至少输入一项筛选维度哦!');
                return
            }
            isAiStop.value = false
            const domId = `user-${new Date().getTime()}`
            msgList.value.push({
                isAi: false,
                fileList: pdfFileList.value,
                sendMsg: sendMsg.value,
                domId,
                uploading: 'loading'
            })
            nextTick(() => {
                document.querySelector(`#${domId}`).scrollIntoView()
            })
            sendLoading.value = true
            httpPostSend()
        }

        // 底部右侧render
        const renderFooterRight = () => {
            return <>
                <div class="footer-right">
                    <a-textarea 
                        style="border: none;outline: none;resize: none;"
                        placeholder="请输入筛选维度,多个筛选条件,可用“,”隔开" v-model:value={sendMsg.value}  />
                    <div style={{opacity: pdfFileList.value.length && sendMsg.value && !sendLoading.value ? `1` : `0.5` }} class="send-btn" onClick={onSend}>
                        <div class="send-icon"></div>
                    </div>
                </div>
            </>
        }

        // 底部render
        const renderFooter = () => {
            return <>
                <div class="page-footer-box">
                    {renderFooterLeft()}
                    {renderFooterRight()}
                </div>
            </>
        }

        // 页面
        return () => <div class="ai-chat-page">
            {renderHeader()}
            {renderMsgBody()}
            {renderFooter()}
        </div>
    }
})
export default AIChat
</script>

<style lang="less" scoped>
.ai-chat-page {
    height: 100%;
    padding: 0 30px;


    /*滚动条宽高 */
    ::-webkit-scrollbar {
        width: 6px;
        height: 6px;
    }
    ::-webkit-scrollbar-track {
        background:  #D8D8D8;;
        border-radius: 6px;
    }

    /* 滚动条上的滚动滑块。样式 */
    ::-webkit-scrollbar-thumb {
        border-radius: 6px;
        background: #F6A622;
    }

    /* 鼠标悬停时,设置滑块的背景颜色为深灰色 */
    ::-webkit-scrollbar-thumb:hover {
        background-color: #999999;
    }

    /* 鼠标按下时,设置滑块的背景颜色为灰色 */
    ::-webkit-scrollbar-thumb:active {
        background-color: #F6A622;
    }

}
.header-nav {
    height: 80px;
    display: flex;
    align-items: center;
    font-size: 20px;
    color: #0B0E12;
    .back-icon {
        width: 24px;
        height: 24px;
        background-image: url(./img/back.svg);
        margin-right: 8px;
        cursor: pointer;
    }
}

.msg-list-box {
    height: calc(100vh - 80px - 170px - 30px);
    border-radius: 10px;
    padding: 30px;
    box-sizing: border-box;
    background-color: #fff;
    position: relative;

    .msg-body-no-data {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        text-align: center;
        display: flex;
        align-items: center;
        flex-direction: column;
        justify-content: center;
        img {
            width: 50px;
            height: 50px;
        }
    }

    .stop-btn {
        position: absolute;
        bottom: 10px;
        left: 50%;
        transform: translate(-50%, 0);
        background-image: url(./img/stop.svg);
        background-position: 4px center;
        background-repeat: no-repeat;
        display: flex;
        align-items: center;
        padding: 8px;
        padding-left: 24px;
        background-color: #d7d1d1;
        line-height: 1;
        border-radius: 8px;
        font-size: 10px;
        cursor: pointer;
        animation: focus 1.2s infinite;
    }

    @keyframes focus {
		from {
			opacity: 1;
		}

		to {
			opacity: 0.5;
		}
	}

    .msg-list-scroll-box {
        height: calc(100vh - 80px - 170px - 30px - 60px);
        padding-right: 24%;
        overflow-y: auto;
    }
}

.msg-item:not(:first-child) {
    margin-top: 30px;
}

.msg-item {
    display: flex;
    align-items: flex-start;
    max-height: 81%;
    overflow-y: auto;
}

.msg-item-left {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
}

.msg-item-right {
    background: #F1F1F1;
    border-radius: 8px;
    flex-grow: 1;
    margin-left: 12px;
    padding: 12px;
    word-break: break-all;
    font-weight: 400;
    position: relative;

    .stop-btn {
        position: absolute;

    }

    .user-msg-title {
        font-size: 14px;
        color: #000000;
        font-weight: 500;
    }
    :deep(.ant-progress-text) {
        color: #F6A622;
        margin-left: 4px;
    }
}

.page-footer-box {
    margin-top: 16px;
    height: 170px;
    display: flex;

    .footer-left {
        width: 344px;
        height: 100%;
        background: #FFFFFF;
        border: 1px solid rgba(238,238,238,1);
        border-radius: 10px;
        flex-shrink: 0;
        padding: 0 12px 10px 12px;
        box-sizing: border-box;

        .footer-left-header {
            font-size: 14px;
            color: #000000;
            display: flex;
            justify-content: space-between;
            align-items: center;
            height: 52px;
        }

        .file-pdf-box {
            height: 108px;
            overflow-y: auto;
        }

        .footer-pdf-item:not(:first-child) {
            margin-top: 4px;
        }
        .footer-pdf-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            width: 200px;
            background: #FFFFFF;
            border: 1px solid rgba(225,229,235,1);
            padding: 8px 12px 9px 8px;
            position: relative;
        }

        .footer-pdf-item-delete-icon {
            position: absolute;
            top: 50%;
            left: calc(100% + 5px);
            transform: translate(0, -50%);
            opacity: 0.6;
            cursor: pointer;
        }

        .pdf-item-left {
            font-size: 12px;
            color: #0B0E12;
            font-weight: 500;
            white-space: nowrap;
            text-overflow: ellipsis;
            overflow: hidden;
        }
        .file-name {
            white-space: nowrap;
            text-overflow: ellipsis;
            overflow: hidden;
        }
        .file-size {
            font-size: 10px;
            color: #CDD0D3;
        }

        .pdf-item-right {
            width: 22px;
            flex-shrink: 0;
        }
        .pdf-icon {
            width: 22px;
            height: 20px;
            background-image: url(./img/pdf.svg);
        }

        .upload-btn {
            cursor: pointer;
            font-size: 12px;
            width: 78px;
            line-height: 28px;
            text-align: center;
            border-radius: 40px;
            color: #FFF;
            background: #F6A622;
        }
        .upload-icon {
            width: 28px;
            height: 28px;
            background-image: url(./img/upload.svg);
            background-size: 70%;
            background-position: center;
            background-repeat: no-repeat;
            margin-right: 8px;
        }
    }

    .footer-right {
        height: 100%;
        background: #FFFFFF;
        border: 1px solid rgba(238,238,238,1);
        border-radius: 10px;
        flex-grow: 1;
        margin-left: 12px;
        display: flex;
        overflow: hidden;

        .send-btn {
            width: 58px;
            opacity: 0.5;
            background: #F6A622;
            border-radius: 0px 4px 4px 0px;
            flex-shrink: 0;
            cursor: pointer;
            display: flex;
            justify-content: center;
            align-items: center;
        }
        .send-icon {
            width: 18px;
            height: 18px;
            background-image: url(./img//send.svg);
        }
    }
}

:deep(p:last-child) {
    margin-bottom: 0;
}

</style>

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值