feat: 实现骨架屏的渲染

This commit is contained in:
2026-06-05 16:31:03 +08:00
parent c59342c22c
commit c5d3bf5dbb
4 changed files with 262 additions and 3 deletions

View File

@@ -0,0 +1,81 @@
<template>
<view class="long-answer-section-skeleton">
<view class="long-answer-section-skeleton-head">
<view class="long-answer-section-skeleton-dot"></view>
<view class="long-answer-section-skeleton-line long-answer-section-skeleton-line--short"></view>
</view>
<view class="long-answer-section-skeleton-line long-answer-section-skeleton-line--wide"></view>
<view class="long-answer-section-skeleton-line long-answer-section-skeleton-line--mid"></view>
</view>
</template>
<style scoped lang="scss">
.long-answer-section-skeleton {
display: flex;
height: 112px;
box-sizing: border-box;
flex-direction: column;
justify-content: center;
gap: 10px;
overflow: hidden;
padding: 0 16px;
}
.long-answer-section-skeleton-head {
display: flex;
align-items: center;
gap: 6px;
}
.long-answer-section-skeleton-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: $theme-color-300;
animation: long-answer-section-skeleton-pulse 1000ms ease-in-out infinite;
}
.long-answer-section-skeleton-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, $theme-color-100, $theme-color-200, $theme-color-100);
background-size: 200% 100%;
animation: long-answer-section-skeleton-shimmer 1400ms ease-in-out infinite;
}
.long-answer-section-skeleton-line--short {
width: 28%;
}
.long-answer-section-skeleton-line--wide {
width: 78%;
}
.long-answer-section-skeleton-line--mid {
width: 52%;
}
@keyframes long-answer-section-skeleton-pulse {
0%,
100% {
opacity: 0.38;
transform: scale(0.88);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes long-answer-section-skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
</style>

View File

@@ -19,8 +19,11 @@
</view>
<template v-for="section in contentSections" :key="section.contentKey">
<ParsedValueView
v-if="shouldUseParsedValueView(section)"
<LongAnswerSectionSkeleton
v-if="shouldRenderSectionSkeleton(section)"
/>
<ParsedValueView v-else-if="shouldUseParsedValueView(section)"
:field-key="section.contentKey"
:value="section.parsedValue !== null ? section.parsedValue : section.contentValue"
/>
@@ -41,6 +44,7 @@
<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";
@@ -62,6 +66,7 @@ const props = defineProps({
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));
@@ -110,6 +115,47 @@ const contentSections = computed(() => {
);
});
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("");
@@ -208,6 +254,7 @@ const scrollToBottom = () => {
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 });
@@ -222,6 +269,7 @@ onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
unsubscribe = StreamManager.subscribe(
streamId,
(text = "", finished = false, payload = null) => {
streamFinished.value = !!finished;
answerText.value = text || "";
longTextData.value = payload || null;
title.value = computeTitle(
@@ -247,6 +295,7 @@ onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
// ✅ 非流式
answerText.value = decodeURIComponent(message || "");
longTextData.value = null;
streamFinished.value = true;
title.value = computeTitle(answerText.value);
nextTick(() => {

View File

@@ -25,4 +25,4 @@
font-size: 18px;
font-weight: 600;
line-height: 28px;
}
}