feat: 长文本在详情页面中的消息流式输出

This commit is contained in:
2026-04-03 16:05:45 +08:00
parent d2fc0973dd
commit 5e21938d06
4 changed files with 171 additions and 17 deletions

View File

@@ -10,7 +10,8 @@
:scroll-with-animation="true" @scroll="handleScroll" @scrolltolower="handleScrollToLower">
<!-- welcome栏 -->
<ChatTopWelcome ref="welcomeRef" :mainPageDataModel="mainPageDataModel" />
<NoticeMessage></NoticeMessage>
<!-- <NoticeMessage></NoticeMessage> -->
<view class="area-msg-list-content" v-for="item in chatMsgList" :key="item.msgId" :id="item.msgId">
<template v-if="item.msgType === MessageRole.AI">

View File

@@ -16,7 +16,7 @@
<text class="font-size-12 font-400 font-color-600">正在生成</text>
<DotLoading />
</view>
<view v-if="isOverflow && finish" class="flex flex-row flex-items-center mt-8" @click="lookDetailAction">
<view v-if="isOverflow" class="flex flex-row flex-items-center mt-8" @click="lookDetailAction">
<text class="font-size-12 font-400 theme-color-500 mr-4">查看详情</text>
<uni-icons class="icon-active" type="right" size="14" color="opacity"></uni-icons>
</view>
@@ -27,10 +27,11 @@
</template>
<script setup>
import { defineProps, computed, ref, watch } from "vue";
import { defineProps, computed, ref, watch, onBeforeUnmount } from "vue";
import ChatMarkdown from "../../chat/ChatMarkdown/index.vue";
import DotLoading from "../../loading/DotLoading.vue";
import StreamManager from '@/utils/StreamManager.js';
const isOverflow = ref(false)
@@ -93,9 +94,37 @@ watch(
const lookDetailAction = () => {
const message = props.text ? String(props.text) : "";
uni.navigateTo({
url: `/pages/long-answer/index?message=${encodeURIComponent(message)}`,
// 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅
const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
StreamManager.openStream(streamId, message, !!props.finish);
// 将当前组件后续 props.text/props.finish 的更新转发到 StreamManager
const stopForward = watch(
() => props.text,
(v) => {
StreamManager.updateStream(streamId, v ? String(v) : "", !!props.finish);
}
);
const stopFinishWatcher = watch(
() => props.finish,
(f) => {
StreamManager.updateStream(streamId, props.text ? String(props.text) : "", !!f);
if (f) {
stopForward();
stopFinishWatcher();
}
}
);
// 清理:组件卸载时停止转发(若仍存在)
onBeforeUnmount(() => {
try {
stopForward && stopForward();
stopFinishWatcher && stopFinishWatcher();
} catch (e) {}
});
uni.navigateTo({ url: `/pages/long-answer/index?streamId=${encodeURIComponent(streamId)}` });
}
</script>

View File

@@ -1,32 +1,104 @@
<template>
<view class="bg-F5F7FA flex flex-col h-screen overflow-hidden">
<TopNavBar :title="title" backgroundColor="transparent" />
<view class="flex-full overflow-hidden scroll-y p-12 border-box">
<ChatMarkdown :text="answerText" />
<view class="flex flex-col bg-liner h-screen overflow-hidden">
<!-- 顶部固定导航 -->
<view class="flex-shrink-0">
<TopNavBar :title="title" background="transparent" />
</view>
<!-- 滚动区域 -->
<scroll-view class="flex-full overflow-hidden" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation>
<view class="pt-12 px-12 pb-24 border-box">
<!-- 答案内容支持markdown -->
<ChatMarkdown :text="answerText" />
<!-- 底部锚点必须存在 -->
<view id="bottom-anchor"></view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import ChatMarkdown from "../index/components/chat/ChatMarkdown/index.vue";
import { defineProps, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { defineProps, ref, nextTick } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import StreamManager from "@/utils/StreamManager.js";
const props = defineProps({
answerText: {
type: String,
default: "",
}
},
});
const answerText = ref(props.answerText || "");
const title = ref("");
onLoad(({ message = "" }) => {
answerText.value = decodeURIComponent(message);
if (answerText.value.length > 6) {
title.value = answerText.value.substring(0, 6) + "...";
let unsubscribe = null;
/** ✅ scroll-into-view 控制 */
const scrollIntoViewId = ref("");
/** ✅ 防抖 */
let scrollTimer = null;
function scrollToBottom() {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
// ❗关键:强制触发滚动(小程序必须这样)
scrollIntoViewId.value = "";
nextTick(() => {
// 再次 nextTick + 延迟,兼容 markdown 渲染延迟
setTimeout(() => {
scrollIntoViewId.value = "bottom-anchor";
}, 50);
});
scrollTimer = null;
}, 100);
}
onLoad(({ message = "", streamId = "" }) => {
if (streamId) {
// ✅ 流式数据
unsubscribe = StreamManager.subscribe(
streamId,
(text = "", finished = false) => {
answerText.value = text || "";
if (answerText.value.length > 6) {
title.value = answerText.value.substring(0, 6) + "...";
}
nextTick(() => {
scrollToBottom();
});
}
);
} else {
// ✅ 非流式
answerText.value = decodeURIComponent(message || "");
if (answerText.value.length > 6) {
title.value = answerText.value.substring(0, 6) + "...";
}
nextTick(() => {
scrollToBottom();
});
}
});
</script>
onUnload(() => {
try {
unsubscribe && unsubscribe();
} catch (e) { }
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,52 @@
// 简单的流式数据管理器:开启流、更新流、订阅流、关闭流
const streams = {};
function openStream(id, initial = '', finished = false) {
if (!id) return;
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
streams[id].text = initial || '';
streams[id].finished = !!finished;
// notify existing subscribers
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
}
function updateStream(id, text, finished = false) {
if (!id || !streams[id]) return;
streams[id].text = text || '';
streams[id].finished = !!finished;
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
}
function subscribe(id, cb) {
if (!id) return () => {};
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
streams[id].subs.add(cb);
// send current snapshot immediately
cb(streams[id].text, streams[id].finished);
return () => {
streams[id] && streams[id].subs.delete(cb);
// 移除空流
if (streams[id] && streams[id].subs.size === 0 && streams[id].finished) {
delete streams[id];
}
};
}
function closeStream(id) {
if (!id || !streams[id]) return;
streams[id].subs.forEach((cb) => cb(streams[id].text, true));
delete streams[id];
}
function getSnapshot(id) {
if (!id || !streams[id]) return { text: '', finished: false };
return { text: streams[id].text, finished: streams[id].finished };
}
export default {
openStream,
updateStream,
subscribe,
closeStream,
getSnapshot,
};