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
321 lines
9.9 KiB
Vue
321 lines
9.9 KiB
Vue
<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>
|