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>
|
</view>
|
||||||
|
|
||||||
<template v-for="section in contentSections" :key="section.contentKey">
|
<template v-for="section in contentSections" :key="section.contentKey">
|
||||||
<ParsedValueView
|
<LongAnswerSectionSkeleton
|
||||||
v-if="shouldUseParsedValueView(section)"
|
v-if="shouldRenderSectionSkeleton(section)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParsedValueView v-else-if="shouldUseParsedValueView(section)"
|
||||||
:field-key="section.contentKey"
|
:field-key="section.contentKey"
|
||||||
:value="section.parsedValue !== null ? section.parsedValue : section.contentValue"
|
:value="section.parsedValue !== null ? section.parsedValue : section.contentValue"
|
||||||
/>
|
/>
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||||
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
||||||
|
import LongAnswerSectionSkeleton from "./LongAnswerSectionSkeleton.vue";
|
||||||
import ParsedValueView from "./ParsedValueView.vue";
|
import ParsedValueView from "./ParsedValueView.vue";
|
||||||
import { defineProps, ref, nextTick, computed } from "vue";
|
import { defineProps, ref, nextTick, computed } from "vue";
|
||||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
@@ -62,6 +66,7 @@ const props = defineProps({
|
|||||||
const answerText = ref(props.answerText || "");
|
const answerText = ref(props.answerText || "");
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const longTextData = ref(null);
|
const longTextData = ref(null);
|
||||||
|
const streamFinished = ref(false);
|
||||||
const longAnswerTagColor = ref(pickRandomTagToneColor());
|
const longAnswerTagColor = ref(pickRandomTagToneColor());
|
||||||
const longAnswerTagStyle = computed(() => buildTagToneStyle(longAnswerTagColor.value));
|
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 控制 */
|
/** ✅ scroll-into-view 控制 */
|
||||||
const scrollIntoViewId = ref("");
|
const scrollIntoViewId = ref("");
|
||||||
|
|
||||||
@@ -208,6 +254,7 @@ const scrollToBottom = () => {
|
|||||||
onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
|
onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
|
||||||
// 记录初始完成状态
|
// 记录初始完成状态
|
||||||
isFinishedOnInit = finished === "1";
|
isFinishedOnInit = finished === "1";
|
||||||
|
streamFinished.value = isFinishedOnInit;
|
||||||
longAnswerTagColor.value = tagToneColor ? decodeURIComponent(tagToneColor) : longAnswerTagColor.value;
|
longAnswerTagColor.value = tagToneColor ? decodeURIComponent(tagToneColor) : longAnswerTagColor.value;
|
||||||
|
|
||||||
console.log("LongAnswer onLoad with params:", { message, streamId, finished, tagToneColor });
|
console.log("LongAnswer onLoad with params:", { message, streamId, finished, tagToneColor });
|
||||||
@@ -222,6 +269,7 @@ onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
|
|||||||
unsubscribe = StreamManager.subscribe(
|
unsubscribe = StreamManager.subscribe(
|
||||||
streamId,
|
streamId,
|
||||||
(text = "", finished = false, payload = null) => {
|
(text = "", finished = false, payload = null) => {
|
||||||
|
streamFinished.value = !!finished;
|
||||||
answerText.value = text || "";
|
answerText.value = text || "";
|
||||||
longTextData.value = payload || null;
|
longTextData.value = payload || null;
|
||||||
title.value = computeTitle(
|
title.value = computeTitle(
|
||||||
@@ -247,6 +295,7 @@ onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => {
|
|||||||
// ✅ 非流式
|
// ✅ 非流式
|
||||||
answerText.value = decodeURIComponent(message || "");
|
answerText.value = decodeURIComponent(message || "");
|
||||||
longTextData.value = null;
|
longTextData.value = null;
|
||||||
|
streamFinished.value = true;
|
||||||
title.value = computeTitle(answerText.value);
|
title.value = computeTitle(answerText.value);
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|||||||
@@ -25,4 +25,4 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user