feat: add new features, update theme and build config
- Add 40+ new UI components including chat modules, discovery cards, photo galleries, FAQ and booking tools - Standardize brand color across all styles by replacing $theme-color-500 SCSS variables with #0ccd58 - Add sass 1.58.3 dependency and update vite config for modern scss compiler support - Refactor existing components (AddCarCrad, login page) and remove unused /quick/list router route - Add utility functions for URL parameter handling - Add static assets including custom znicons font, component images and icons - Fix scss syntax issues and deprecation warnings
This commit is contained in:
227
src/pages/home/components/ChatLongAnswer/ParsedValueView.vue
Normal file
227
src/pages/home/components/ChatLongAnswer/ParsedValueView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div v-if="shouldRenderField" class="parsed-value">
|
||||
<template v-for="entry in renderFieldEntries" :key="entry.key">
|
||||
<div v-if="entry.type === 'text'" class="content-body-text">
|
||||
{{ entry.value }}
|
||||
</div>
|
||||
<div v-else-if="entry.type === 'image'" class="content-body-image-card">
|
||||
<img class="content-body-image" :src="entry.value.image_id" mode="widthFix"
|
||||
@click="handlePredivClick(entry.value.image_id)" />
|
||||
<div v-if="entry.value.caption" class="content-body-image-caption">
|
||||
{{ entry.value.caption }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="entry.type === 'list'" class="content-body-list-card">
|
||||
<div v-for="(item, index) in entry.value" :key="index" class="content-body-list-item">
|
||||
<div class="content-body-list-text">
|
||||
{{ formatLeafValue(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
fieldKey: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
value: {
|
||||
type: [Object, Array, String, Number, Boolean],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const IGNORED_FIELD_KEYS = ["container_type", "content", "content_summary", "components"];
|
||||
|
||||
const isArrayValue = (value) => Array.isArray(value);
|
||||
|
||||
const isObjectValue = (value) => {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const parseJsonStringValue = (value) => {
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
const text = value.trim();
|
||||
if (!/^[\[{]/.test(text)) return value;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeValue = (value) => {
|
||||
const parsedValue = parseJsonStringValue(value);
|
||||
|
||||
if (isArrayValue(parsedValue)) {
|
||||
return parsedValue.map((item) => sanitizeValue(item));
|
||||
}
|
||||
if (isObjectValue(parsedValue)) {
|
||||
return Object.keys(parsedValue).reduce((result, key) => {
|
||||
if (IGNORED_FIELD_KEYS.includes(key)) return result;
|
||||
result[key] = sanitizeValue(parsedValue[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
const hasDisplayValue = (value) => {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return !!value.trim();
|
||||
if (isArrayValue(value)) return value.some((item) => hasDisplayValue(item));
|
||||
if (isObjectValue(value)) {
|
||||
const valueObj = sanitizeValue(value);
|
||||
return Object.keys(valueObj).some((key) => hasDisplayValue(valueObj[key]));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const formatLeafValue = (value) => {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "boolean") return value ? "是" : "否";
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(sanitizeValue(value));
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const isImageValue = (value) => {
|
||||
return isObjectValue(value) && hasDisplayValue(value.image_id);
|
||||
};
|
||||
|
||||
const createImageEntry = (key, value) => ({
|
||||
key,
|
||||
type: "image",
|
||||
value: {
|
||||
image_id: formatLeafValue(value.image_id).trim(),
|
||||
caption: hasDisplayValue(value.caption) ? formatLeafValue(value.caption) : "",
|
||||
},
|
||||
});
|
||||
|
||||
const renderFieldEntries = computed(() => {
|
||||
if (isIgnoredField.value || !hasDisplayValue(props.value)) return [];
|
||||
|
||||
const value = sanitizeValue(props.value);
|
||||
if (isImageValue(value)) {
|
||||
return [createImageEntry(props.fieldKey, value)];
|
||||
}
|
||||
|
||||
if (isArrayValue(value)) {
|
||||
return [{
|
||||
key: props.fieldKey,
|
||||
type: "list",
|
||||
value: value.filter((item) => hasDisplayValue(item)),
|
||||
}];
|
||||
}
|
||||
|
||||
if (isObjectValue(value)) {
|
||||
return Object.keys(value)
|
||||
.filter((key) => hasDisplayValue(value[key]))
|
||||
.map((key) => {
|
||||
const entryValue = value[key];
|
||||
if (isImageValue(entryValue)) {
|
||||
return createImageEntry(key, entryValue);
|
||||
}
|
||||
if (isArrayValue(entryValue)) {
|
||||
return {
|
||||
key,
|
||||
type: "list",
|
||||
value: entryValue.filter((item) => hasDisplayValue(item)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
type: "text",
|
||||
value: formatLeafValue(entryValue),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [{
|
||||
key: props.fieldKey,
|
||||
type: "text",
|
||||
value: formatLeafValue(value),
|
||||
}];
|
||||
});
|
||||
|
||||
const isIgnoredField = computed(() => IGNORED_FIELD_KEYS.includes(props.fieldKey));
|
||||
const shouldRenderField = computed(() => renderFieldEntries.value.length > 0);
|
||||
|
||||
const handlePredivClick = (imageUrl) => {
|
||||
uni.predivImage({
|
||||
current: imageUrl,
|
||||
urls: [imageUrl],
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.parsed-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.content-body-text {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.content-body-image-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.content-body-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.content-body-image-caption {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.content-body-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-left: 4px solid #0CCD58;
|
||||
border-radius: 12px;
|
||||
background: rgba(#0CCD58, 0.08);
|
||||
}
|
||||
|
||||
.content-body-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content-body-list-text {
|
||||
flex: 1;
|
||||
color: $theme-color-800;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
298
src/pages/home/components/ChatLongAnswer/index.vue
Normal file
298
src/pages/home/components/ChatLongAnswer/index.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="flex flex-col bg-liner h-screen overflow-hidden">
|
||||
<!-- ✅ 顶部固定导航 -->
|
||||
<div class="flex-shrink-0">
|
||||
<TopNavBar :title="title" background="transparent" />
|
||||
</div>
|
||||
|
||||
<!-- ✅ 滚动区域 -->
|
||||
<div class="flex-full overflow-hidden chat-scroll" scroll-y :scroll-into-div="scrollIntodivId" scroll-with-animation
|
||||
@scroll="onScroll" @touchstart="onTouchStart" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<div class="flex flex-col pt-12 px-12 pb-24 border-box gap-10">
|
||||
<div v-if="headerSections.title || headerSections.tag" class="long-answer-header">
|
||||
<div v-if="headerSections.title" class="long-answer-title">
|
||||
{{ headerSections.title.contentValue }}
|
||||
</div>
|
||||
<div v-if="headerSections.tag" class="long-answer-tag">
|
||||
{{ headerSections.tag.contentValue }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="section in contentSections" :key="section.contentKey">
|
||||
<ParsedValuediv v-if="shouldUseParsedValuediv(section)" :field-key="section.contentKey"
|
||||
:value="section.parsedValue !== null ? section.parsedValue : section.contentValue" />
|
||||
|
||||
<ChatMarkdown v-else-if="section.contentKey === LONG_TEXT_KEYS.content" :text="section.contentValue" />
|
||||
|
||||
<ChatMarkdown v-else :text="section.contentValue" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ 底部锚点(必须存在) -->
|
||||
<div id="bottom-anchor"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
||||
import ParsedValuediv from "./ParsedValuediv.vue";
|
||||
import { defineProps, ref, nextTick, computed } from "vue";
|
||||
import StreamManager from "@/utils/StreamManager.js";
|
||||
import {
|
||||
LONG_TEXT_KEYS,
|
||||
getLongTextSections,
|
||||
getLongTextValue,
|
||||
} from "@/utils/longTextCard";
|
||||
|
||||
const props = defineProps({
|
||||
answerText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const answerText = ref(props.answerText || "");
|
||||
const title = ref("");
|
||||
const longTextData = ref(null);
|
||||
|
||||
let unsubscribe = null;
|
||||
|
||||
const HIDDEN_DETAIL_SECTION_KEYS = [
|
||||
LONG_TEXT_KEYS.containerType,
|
||||
LONG_TEXT_KEYS.contentSummary,
|
||||
];
|
||||
|
||||
const shouldUseParsedValuediv = (section) => {
|
||||
return (
|
||||
section.fromLongTextData &&
|
||||
section.contentKey !== LONG_TEXT_KEYS.tag &&
|
||||
section.contentKey !== LONG_TEXT_KEYS.title &&
|
||||
section.contentKey !== LONG_TEXT_KEYS.content &&
|
||||
!HIDDEN_DETAIL_SECTION_KEYS.includes(section.contentKey)
|
||||
);
|
||||
};
|
||||
|
||||
const renderSections = computed(() => {
|
||||
const data = longTextData.value;
|
||||
if (data && data.values) {
|
||||
return getLongTextSections(data)
|
||||
.filter((section) => section.contentValue)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
fromLongTextData: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return answerText.value
|
||||
? [{ contentKey: "content", contentValue: answerText.value }]
|
||||
: [];
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
/** ✅ scroll-into-div 控制 */
|
||||
const scrollIntodivId = ref("");
|
||||
|
||||
/** 滚动控制状态 */
|
||||
const isNearBottom = ref(true);
|
||||
const scrolldivHeight = ref(0);
|
||||
const SCROLL_THRESHOLD = 150; // px
|
||||
|
||||
/** 用户交互状态,用户滚动/触摸时临时禁用自动滚动 */
|
||||
const userInteracting = ref(false);
|
||||
let interactionTimer = null;
|
||||
|
||||
/** 是否已完成(从 URL 参数判断),完成状态下不初始初自动滚到底部 */
|
||||
let isFinishedOnInit = false;
|
||||
|
||||
/** ✅ 防抖 */
|
||||
let scrollTimer = null;
|
||||
|
||||
const measureScrolldivHeight = () => {
|
||||
try {
|
||||
// 使用 uni.createSelectorQuery 获取 scroll-div 的准确高度
|
||||
uni.createSelectorQuery()
|
||||
.select(".chat-scroll")
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect && rect.height) {
|
||||
scrolldivHeight.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-div 高度)
|
||||
const divHeight = scrolldivHeight.value;
|
||||
const distanceToBottom = scrollHeight - scrollTop - divHeight;
|
||||
|
||||
// 判断是否在底部附近(允许 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;
|
||||
}
|
||||
|
||||
scrollIntodivId.value = "";
|
||||
|
||||
nextTick(() => {
|
||||
// 再次 nextTick + 延迟,兼容 markdown 渲染延迟
|
||||
setTimeout(() => {
|
||||
scrollIntodivId.value = "bottom-anchor";
|
||||
// 测量高度以便后续滚动判断准确
|
||||
measureScrolldivHeight();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
scrollTimer = null;
|
||||
}, 100);
|
||||
}
|
||||
// TODO
|
||||
// onLoad(({ message = "", streamId = "", finished = "0" }) => {
|
||||
// // 记录初始完成状态
|
||||
// isFinishedOnInit = finished === "1";
|
||||
|
||||
// console.log("LongAnswer onLoad with params:", { message, streamId, finished });
|
||||
|
||||
// // 初次测量 scroll-div 高度
|
||||
// nextTick(() => {
|
||||
// measureScrolldivHeight();
|
||||
// });
|
||||
|
||||
// if (streamId) {
|
||||
// // ✅ 流式数据
|
||||
// unsubscribe = StreamManager.subscribe(
|
||||
// streamId,
|
||||
// (text = "", finished = false, payload = null) => {
|
||||
// answerText.value = text || "";
|
||||
// longTextData.value = payload || null;
|
||||
// title.value = computeTitle(
|
||||
// getLongTextValue(longTextData.value, "title") || answerText.value
|
||||
// );
|
||||
|
||||
// nextTick(() => {
|
||||
// // 每次接收数据都重新测量高度(content size 可能变化,比如加载图)
|
||||
// measureScrolldivHeight();
|
||||
|
||||
// // 流式完成时强制滚动到底部
|
||||
// if (finished) {
|
||||
// scrollToBottom();
|
||||
// }
|
||||
// // 流式中的数据更新:只有在用户未交互且接近底部时才自动滚动
|
||||
// else if (!userInteracting.value && isNearBottom.value) {
|
||||
// scrollToBottom();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// );
|
||||
// } else {
|
||||
// // ✅ 非流式
|
||||
// answerText.value = decodeURIComponent(message || "");
|
||||
// longTextData.value = null;
|
||||
// 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">
|
||||
.long-answer-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.long-answer-tag {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(#0CCD58, 0.2);
|
||||
background: rgba(#0CCD58, 0.08);
|
||||
color: #0CCD58;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.long-answer-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user