feat: 实现骨架屏的渲染
This commit is contained in:
129
scripts/regression-long-answer-section-skeleton.mjs
Normal file
129
scripts/regression-long-answer-section-skeleton.mjs
Normal 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"
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user