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,129 @@
import assert from "assert";
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const detailPagePath = resolve(
scriptDir,
"../src/pages/ChatMain/ChatLongAnswer/index.vue"
);
const parsedValuePath = resolve(
scriptDir,
"../src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue"
);
const detailStylePath = resolve(
scriptDir,
"../src/pages/ChatMain/ChatLongAnswer/styles/index.scss"
);
const skeletonComponentPath = resolve(
scriptDir,
"../src/pages/ChatMain/ChatLongAnswer/LongAnswerSectionSkeleton.vue"
);
const detailPageSource = readFileSync(detailPagePath, "utf8");
const parsedValueSource = readFileSync(parsedValuePath, "utf8");
const detailStyleSource = readFileSync(detailStylePath, "utf8");
const skeletonComponentSource = readFileSync(skeletonComponentPath, "utf8");
assert.match(
detailPageSource,
/const\s+streamFinished\s*=\s*ref/,
"long answer page should track whether the forwarded stream has finished"
);
assert.match(
detailPageSource,
/import\s+LongAnswerSectionSkeleton\s+from\s+"\.\/LongAnswerSectionSkeleton\.vue"/,
"long answer page should use a sibling skeleton component"
);
assert.doesNotMatch(
detailPageSource,
/DEFERRED_SPECIAL_SECTION_KEYS/,
"long answer page should not use a special-key whitelist for section skeletons"
);
assert.doesNotMatch(
detailPageSource,
/LONG_TEXT_KEYS\.(sceneImage|contentImage|spotLocate|questionSuggest|commodityList|photoList|aigcComponet)/,
"section skeleton readiness should not be tied to named special keys"
);
assert.match(
detailPageSource,
/<LongAnswerSectionSkeleton\s+v-if="shouldRenderSectionSkeleton\(section\)"/,
"each section should decide whether to show its own skeleton inside the contentSections loop"
);
assert.match(
detailPageSource,
/v-else-if="shouldUseParsedValueView\(section\)"/,
"ParsedValueView should render after the per-section skeleton check"
);
assert.match(
detailPageSource,
/getSectionIndex\(section\)/,
"section readiness should use the section position to know when the next key has started"
);
assert.match(
detailPageSource,
/hasSectionAfter\(section\)/,
"a deferred section should become renderable when a later key arrives"
);
assert.match(
detailPageSource,
/isSectionReady\(section\)/,
"long answer page should centralize when a structured section is ready to render"
);
assert.match(
detailPageSource,
/isNonStringSectionValue/,
"structured skeletons should be driven by non-string parsed values"
);
assert.match(
detailPageSource,
/isPendingStructuredText/,
"pending JSON-like section strings should use the skeleton until parsed"
);
assert.match(
detailPageSource,
/section\.parsedValue\s*!==\s*null\s*&&\s*isNonStringSectionValue\(section\.parsedValue\)/,
"parsed non-string values should identify structured sections"
);
assert.doesNotMatch(
detailPageSource,
/shouldRenderStreamingReceiver|class="long-answer-receiver"/,
"long answer page should not use one global receiver box"
);
assert.doesNotMatch(
detailPageSource,
/:defer-special-render=/,
"ParsedValueView should not hide deferred sections internally"
);
assert.doesNotMatch(
parsedValueSource,
/deferSpecialRender|shouldDeferSpecialRender/,
"ParsedValueView should stay focused on rendering values, not streaming readiness"
);
assert.match(
skeletonComponentSource,
/\.long-answer-section-skeleton\s*{/,
"section skeleton styles should live in the sibling skeleton component"
);
assert.doesNotMatch(
detailStyleSource,
/\.long-answer-section-skeleton\b|\.long-answer-receiver\b/,
"long answer page styles should not contain skeleton implementation details"
);

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;
}
}