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:
duanshuwen
2026-05-26 22:49:52 +08:00
parent 548df7020c
commit ac8f5b5f64
159 changed files with 12439 additions and 629 deletions

View 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>

View 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>