Files
YGChatCS/src/pages/ChatMain/ChatLongAnswer/index.vue
duanshuwen 39b91a8d05 refactor: remove gap.scss and inline gap classes
delete the standalone gap.scss utility file, remove its import from src/static/scss/index.scss, and update all component templates to replace predefined gap-* classes with inline gap-[px] notation
2026-07-24 16:54:46 +08:00

321 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="flex flex-col bg-white h-screen overflow-hidden">
<!-- 顶部固定导航 -->
<view class="flex-shrink-0">
<TopNavBar :title="title" background="transparent" />
</view>
<!-- 滚动区域 -->
<scroll-view class="flex-full overflow-hidden chat-scroll" scroll-y :scroll-into-view="scrollIntoViewId"
scroll-with-animation @scroll="onScroll" @touchstart="onTouchStart" @touchend="onTouchEnd"
@touchcancel="onTouchEnd">
<view class="flex flex-col pt-[12px] px-[16px] pb-[24px] gap-[10px]">
<template v-if="hasStructuredLongTextData">
<view v-if="headerSections.title || headerSections.tag" class="long-answer-header">
<view v-if="headerSections.title" class="long-answer-title">
{{ headerSections.title.contentValue }}
</view>
<view v-if="headerSections.tag" class="long-answer-tag" :style="longAnswerTagStyle">
{{ headerSections.tag.contentValue }}
</view>
</view>
<template v-for="section in contentSections" :key="section.contentKey">
<LongAnswerSectionSkeleton v-if="shouldRenderSectionSkeleton(section)" />
<ParsedValueView v-else-if="shouldUseParsedValueView(section)" :field-key="section.contentKey"
:value="section.parsedValue !== null ? section.parsedValue : section.contentValue" />
<ChatMarkdown v-else :text="section.contentValue" />
</template>
</template>
<ChatMarkdown v-else-if="answerText" :text="answerText" />
<!-- 底部锚点必须存在 -->
<view id="bottom-anchor"></view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import ChatMarkdown from "../ChatMarkdown/index.vue";
import LongAnswerSectionSkeleton from "./LongAnswerSectionSkeleton.vue";
import ParsedValueView from "./ParsedValueView.vue";
import { defineProps, ref, nextTick, computed } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import StreamManager from "@/utils/StreamManager.js";
import {
LONG_TEXT_KEYS,
getLongTextSections,
getLongTextValue,
} from "@/utils/longTextCard";
import { buildTagToneStyle, pickRandomTagToneColor } from "@/utils/tagTone";
const props = defineProps({
answerText: {
type: String,
default: "",
},
});
const answerText = ref(props.answerText || "");
const title = ref("");
const longTextData = ref(null);
const streamFinished = ref(false);
const longAnswerTagColor = ref(pickRandomTagToneColor());
const longAnswerTagStyle = computed(() => buildTagToneStyle(longAnswerTagColor.value));
let unsubscribe = null;
const HIDDEN_DETAIL_SECTION_KEYS = [];
const hasStructuredLongTextData = computed(() => {
const values = longTextData.value?.values;
return !!(values && Object.keys(values).length > 0);
});
const shouldUseParsedValueView = (section) => {
return (
section.fromLongTextData &&
section.contentKey !== LONG_TEXT_KEYS.tag &&
section.contentKey !== LONG_TEXT_KEYS.title &&
!HIDDEN_DETAIL_SECTION_KEYS.includes(section.contentKey)
);
};
const renderSections = computed(() => {
if (!hasStructuredLongTextData.value) return [];
return getLongTextSections(longTextData.value)
.filter((section) => section.contentValue)
.map((section) => ({
...section,
fromLongTextData: true,
}));
});
const headerSections = computed(() => {
const sections = renderSections.value;
return {
title: sections.find((section) => section.contentKey === LONG_TEXT_KEYS.title),
tag: sections.find((section) => section.contentKey === LONG_TEXT_KEYS.tag),
};
});
const contentSections = computed(() => {
return renderSections.value.filter(
(section) =>
section.contentKey !== LONG_TEXT_KEYS.title &&
section.contentKey !== LONG_TEXT_KEYS.tag &&
!HIDDEN_DETAIL_SECTION_KEYS.includes(section.contentKey)
);
});
const getSectionIndex = (section) => {
return contentSections.value.findIndex((item) => item.contentKey === section?.contentKey);
};
const hasSectionAfter = (section) => {
const sectionIndex = getSectionIndex(section);
return sectionIndex >= 0 && sectionIndex < contentSections.value.length - 1;
};
const isNonStringSectionValue = (value) => {
return value !== undefined && value !== null && typeof value !== "string";
};
const isPendingStructuredText = (value) => {
const text = typeof value === "string" ? value.trim() : "";
return text.startsWith("{") || text.startsWith("[");
};
const isStructuredSection = (section) => {
return (
section?.fromLongTextData &&
(
(section.parsedValue !== null && isNonStringSectionValue(section.parsedValue)) ||
isPendingStructuredText(section.contentValue)
)
);
};
const isSectionReady = (section) => {
if (!isStructuredSection(section)) return true;
return (
streamFinished.value ||
(section.parsedValue !== null && isNonStringSectionValue(section.parsedValue)) ||
hasSectionAfter(section)
);
};
const shouldRenderSectionSkeleton = (section) => {
return isStructuredSection(section) && !isSectionReady(section);
};
/** ✅ scroll-into-view 控制 */
const scrollIntoViewId = ref("");
/** 滚动控制状态 */
const isNearBottom = ref(true);
const scrollViewHeight = ref(0);
const SCROLL_THRESHOLD = 150; // px
/** 用户交互状态,用户滚动/触摸时临时禁用自动滚动 */
const userInteracting = ref(false);
let interactionTimer = null;
/** 是否已完成(从 URL 参数判断),完成状态下不初始初自动滚到底部 */
let isFinishedOnInit = false;
/** ✅ 防抖 */
let scrollTimer = null;
const measureScrollViewHeight = () => {
try {
// 使用 uni.createSelectorQuery 获取 scroll-view 的准确高度
uni.createSelectorQuery()
.select(".chat-scroll")
.boundingClientRect((rect) => {
if (rect && rect.height) {
scrollViewHeight.value = rect.height;
}
})
.exec();
} catch (e) { }
}
/** 生成展示用标题:去除前导 `#` 并截取前 6 字符(超过加省略号) */
const computeTitle = (text = "") => {
const t = (text || "").replace(/^#+\s*/, "");
return t.length > 8 ? t.substring(0, 8) + "..." : t;
}
const onScroll = (e) => {
try {
const { scrollTop = 0, scrollHeight = 0 } = e.detail || {};
// 计算距离底部的距离(使用准确的 scroll-view 高度)
const viewHeight = scrollViewHeight.value;
const distanceToBottom = scrollHeight - scrollTop - viewHeight;
// 判断是否在底部附近(允许 SCROLL_THRESHOLD 的误差范围)
// 注意:只更新 isNearBottom不在滚动时强制改变 userInteracting
const atBottom = distanceToBottom <= SCROLL_THRESHOLD;
isNearBottom.value = atBottom;
} catch (e) { }
}
const onTouchStart = () => {
// 触摸开始时,立即标记为用户交互状态
userInteracting.value = true;
clearTimeout(interactionTimer);
}
const onTouchEnd = () => {
// 触摸结束后延迟一段时间再取消交互状态
// 这样即使用户快速滚动,也不会被中途打断
clearTimeout(interactionTimer);
interactionTimer = setTimeout(() => {
userInteracting.value = false;
}, 600);
}
const scrollToBottom = () => {
if (scrollTimer) return;
if (isFinishedOnInit) return;
scrollTimer = setTimeout(() => {
// ❗关键:强制触发滚动(小程序必须这样)
// 如果用户正在交互,则跳过本次自动滚动
if (userInteracting.value) {
scrollTimer = null;
return;
}
scrollIntoViewId.value = "";
nextTick(() => {
// 再次 nextTick + 延迟,兼容 markdown 渲染延迟
setTimeout(() => {
scrollIntoViewId.value = "bottom-anchor";
// 测量高度以便后续滚动判断准确
measureScrollViewHeight();
}, 50);
});
scrollTimer = null;
}, 100);
}
onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
// 记录初始完成状态
isFinishedOnInit = finished === "1";
streamFinished.value = isFinishedOnInit;
longAnswerTagColor.value = tagToneColor ? decodeURIComponent(tagToneColor) : longAnswerTagColor.value;
console.log("LongAnswer onLoad with params:", { message, streamId, finished, tagToneColor });
// 初次测量 scroll-view 高度
nextTick(() => {
measureScrollViewHeight();
});
if (streamId) {
// ✅ 流式数据
unsubscribe = StreamManager.subscribe(
streamId,
(text = "", finished = false, payload = null) => {
streamFinished.value = !!finished;
answerText.value = text || "";
longTextData.value = payload || null;
title.value = computeTitle(
getLongTextValue(longTextData.value, LONG_TEXT_KEYS.title) || answerText.value
);
nextTick(() => {
// 每次接收数据都重新测量高度content size 可能变化,比如加载图)
measureScrollViewHeight();
// 流式完成时强制滚动到底部
if (finished) {
scrollToBottom();
}
// 流式中的数据更新:只有在用户未交互且接近底部时才自动滚动
else if (!userInteracting.value && isNearBottom.value) {
scrollToBottom();
}
});
}
);
} else {
// ✅ 非流式
answerText.value = decodeURIComponent(message || "");
longTextData.value = null;
streamFinished.value = true;
title.value = computeTitle(answerText.value);
nextTick(() => {
// 只有在初始化为非完成状态时才自动滚到底部
scrollToBottom();
});
}
});
onUnload(() => {
try {
unsubscribe && unsubscribe();
} catch (e) { }
// 清理定时器,避免内存泄漏
try { clearTimeout(scrollTimer); } catch (e) { }
try { clearTimeout(interactionTimer); } catch (e) { }
scrollTimer = null;
interactionTimer = null;
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>