feat: 长文本组件的对接调试
This commit is contained in:
@@ -8,8 +8,19 @@
|
||||
<!-- ✅ 滚动区域 -->
|
||||
<scroll-view class="flex-full overflow-hidden chat-scroll" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation @scroll="onScroll" @touchstart="onTouchStart" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<view class="pt-12 px-12 pb-24 border-box">
|
||||
<!-- ✅ 内容,支持markdown -->
|
||||
<ChatMarkdown :text="answerText" />
|
||||
<template v-for="section in renderSections" :key="section.contentKey">
|
||||
<view v-if="section.contentKey === 'tag'" class="long-answer-tag">
|
||||
{{ section.contentValue }}
|
||||
</view>
|
||||
<view v-else-if="section.contentKey === 'title'" class="long-answer-title">
|
||||
{{ section.contentValue }}
|
||||
</view>
|
||||
<ChatMarkdown v-else-if="section.contentKey === 'content'" :text="section.contentValue" />
|
||||
<view v-else-if="section.parsedValue !== null" class="long-answer-block">
|
||||
<ChatMarkdown :text="toMarkdownText(section.parsedValue)" />
|
||||
</view>
|
||||
<ChatMarkdown v-else :text="section.contentValue" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ 底部锚点(必须存在) -->
|
||||
<view id="bottom-anchor"></view>
|
||||
@@ -21,9 +32,13 @@
|
||||
<script setup>
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
||||
import { defineProps, ref, nextTick } from "vue";
|
||||
import { defineProps, ref, nextTick, computed } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import StreamManager from "@/utils/StreamManager.js";
|
||||
import {
|
||||
getLongTextSections,
|
||||
getLongTextValue,
|
||||
} from "@/utils/longTextCard";
|
||||
|
||||
const props = defineProps({
|
||||
answerText: {
|
||||
@@ -34,9 +49,36 @@ const props = defineProps({
|
||||
|
||||
const answerText = ref(props.answerText || "");
|
||||
const title = ref("");
|
||||
const longTextData = ref(null);
|
||||
|
||||
let unsubscribe = null;
|
||||
|
||||
const toMarkdownText = (value) => {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => toMarkdownText(item)).filter(Boolean).join("\n\n");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const renderSections = computed(() => {
|
||||
const data = longTextData.value;
|
||||
if (data && data.values) {
|
||||
return getLongTextSections(data).filter((section) => section.contentValue);
|
||||
}
|
||||
|
||||
return answerText.value
|
||||
? [{ contentKey: "content", contentValue: answerText.value }]
|
||||
: [];
|
||||
});
|
||||
|
||||
/** ✅ scroll-into-view 控制 */
|
||||
const scrollIntoViewId = ref("");
|
||||
|
||||
@@ -147,9 +189,12 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
|
||||
// ✅ 流式数据
|
||||
unsubscribe = StreamManager.subscribe(
|
||||
streamId,
|
||||
(text = "", finished = false) => {
|
||||
(text = "", finished = false, payload = null) => {
|
||||
answerText.value = text || "";
|
||||
title.value = computeTitle(answerText.value);
|
||||
longTextData.value = payload || null;
|
||||
title.value = computeTitle(
|
||||
getLongTextValue(longTextData.value, "title") || answerText.value
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
// 每次接收数据都重新测量高度(content size 可能变化,比如加载图)
|
||||
@@ -169,6 +214,7 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
|
||||
} else {
|
||||
// ✅ 非流式
|
||||
answerText.value = decodeURIComponent(message || "");
|
||||
longTextData.value = null;
|
||||
title.value = computeTitle(answerText.value);
|
||||
|
||||
nextTick(() => {
|
||||
@@ -190,6 +236,27 @@ onUnload(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.long-answer-tag {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba($theme-color-500, 0.2);
|
||||
background: rgba($theme-color-500, 0.08);
|
||||
color: $theme-color-500;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.long-answer-title {
|
||||
margin-bottom: 12px;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
.long-answer-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
>
|
||||
<AnswerComponent
|
||||
v-if="item.componentName === CompName.longTextCard"
|
||||
:text="item.componentMsg || item.msg"
|
||||
:title="item.title"
|
||||
:longTextData="item.longTextData"
|
||||
:finish="item.finish"
|
||||
/>
|
||||
<QuickBookingComponent
|
||||
@@ -258,6 +257,10 @@ import {
|
||||
} from "@/request/api/ConversationApi";
|
||||
import WebSocketManager from "@/utils/WebSocketManager";
|
||||
import { IdUtils } from "@/utils";
|
||||
import {
|
||||
appendLongTextChunk,
|
||||
createLongTextData,
|
||||
} from "@/utils/longTextCard";
|
||||
import { checkToken } from "@/hooks/useGoLogin";
|
||||
import { useAppStore } from "@/store";
|
||||
import { getAccessToken } from "@/constant/token";
|
||||
@@ -781,7 +784,7 @@ const handleWebSocketMessage = (data) => {
|
||||
messageId: currentSessionMessageId,
|
||||
replyMessageId: data.replyMessageId || "",
|
||||
componentName: "",
|
||||
title: "",
|
||||
longTextData: null,
|
||||
finish: false,
|
||||
};
|
||||
chatMsgList.value.push(aiMsg);
|
||||
@@ -803,7 +806,7 @@ const handleWebSocketMessage = (data) => {
|
||||
messageId: currentSessionMessageId,
|
||||
replyMessageId: data.replyMessageId || "",
|
||||
componentName: "",
|
||||
title: "",
|
||||
longTextData: null,
|
||||
finish: false,
|
||||
};
|
||||
chatMsgList.value.push(aiMsg);
|
||||
@@ -842,13 +845,30 @@ const handleWebSocketMessage = (data) => {
|
||||
if (data.componentName) {
|
||||
aiItem.componentName = data.componentName;
|
||||
if (data.componentName === CompName.longTextCard) {
|
||||
aiItem.longTextData = aiItem.longTextData || createLongTextData();
|
||||
if (aiItem.msg && aiItem.msg.length > 0) {
|
||||
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg;
|
||||
if (!aiItem.isLoading) {
|
||||
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg;
|
||||
}
|
||||
aiItem.msg = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLongText =
|
||||
aiItem.componentName === CompName.longTextCard ||
|
||||
data.componentName === CompName.longTextCard;
|
||||
|
||||
if (isLongText && data.contentKey) {
|
||||
aiItem.longTextData = aiItem.longTextData || createLongTextData();
|
||||
appendLongTextChunk(aiItem.longTextData, data);
|
||||
if (aiItem.isLoading) {
|
||||
aiItem.msg = "";
|
||||
aiItem.isLoading = false;
|
||||
}
|
||||
nextTick(() => scrollToBottom());
|
||||
}
|
||||
|
||||
// 确保消息内容是字符串类型
|
||||
if (data.content && typeof data.content !== "string") {
|
||||
try {
|
||||
@@ -861,9 +881,6 @@ const handleWebSocketMessage = (data) => {
|
||||
// 直接拼接内容到对应 AI 消息
|
||||
if (data.content) {
|
||||
// 如果该条消息属于 longTextCard,使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空
|
||||
const isLongText =
|
||||
aiItem.componentName === CompName.longTextCard ||
|
||||
data.componentName === CompName.longTextCard;
|
||||
if (isLongText) {
|
||||
if (aiItem.isLoading) {
|
||||
aiItem.componentMsg = (aiItem.componentMsg || "") + data.content;
|
||||
@@ -900,7 +917,6 @@ const handleWebSocketMessage = (data) => {
|
||||
|
||||
// 处理组件调用
|
||||
if (data.componentName) {
|
||||
chatMsgList.value[aiMsgIndex].title = data.content;
|
||||
chatMsgList.value[aiMsgIndex].componentName = data.componentName;
|
||||
}
|
||||
|
||||
@@ -1173,7 +1189,7 @@ const sendChat = async (message, isInstruct = false) => {
|
||||
messageId: currentSessionMessageId,
|
||||
replyMessageId: "",
|
||||
componentName: "",
|
||||
title: "",
|
||||
longTextData: null,
|
||||
finish: false,
|
||||
};
|
||||
chatMsgList.value.push(aiMsg);
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
<view class="w-full bg-white border-box border-ff overflow-hidden rounded-20 flex flex-col">
|
||||
<!-- 占位撑开 -->
|
||||
<view class="w-vw"></view>
|
||||
<view class="flex flex-col px-12 pb-12 pt-4 border-box border-left-4">
|
||||
<view class="flex flex-col px-16 pt-16 pb-12 border-box">
|
||||
<view v-if="tag" class="long-answer-tag">{{ tag }}</view>
|
||||
<view v-if="title" class="flex flex-row flex-items-start flex-justify-start mb-8">
|
||||
<uni-icons class="icon-active" type="fire-filled" size="18" color="opacity" />
|
||||
<text class="font-size-16 font-500 text-color-900 ml-6"> {{ title }}</text>
|
||||
</view>
|
||||
<!-- 文字内容,最多显示3行 -->
|
||||
<view class="answer-content font-size-12 font-color-600">
|
||||
<ChatMarkdown :text="processedText" />
|
||||
<view v-if="processedContent" class="answer-content font-size-12 font-color-600">
|
||||
<ChatMarkdown :text="processedContent" />
|
||||
</view>
|
||||
<!-- 超过3行时显示...提示 -->
|
||||
<view v-if="!finish" class="flex flex-row flex-items-center mt-8">
|
||||
<text class="font-size-12 font-400 font-color-600">正在生成</text>
|
||||
<ChatLoading />
|
||||
</view>
|
||||
<view v-if="isOverflow" class="flex flex-row flex-items-center mt-8" @click="lookDetailAction">
|
||||
<text class="font-size-12 font-400 theme-color-500 mr-4">查看详情</text>
|
||||
<view v-if="isOverflow" class="flex flex-row flex-items-center flex-justify-between mt-8" @click="lookDetailAction">
|
||||
<text class="font-size-12 font-400 theme-color-500 mr-4">查看完整{{ tag }}</text>
|
||||
<uni-icons class="icon-active" type="right" size="14" color="opacity"></uni-icons>
|
||||
</view>
|
||||
|
||||
@@ -32,16 +33,21 @@ import { defineProps, computed, watch, onBeforeUnmount } from "vue";
|
||||
import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue";
|
||||
import ChatLoading from "../../ChatMain/ChatLoading/index.vue";
|
||||
import StreamManager from '@/utils/StreamManager.js';
|
||||
import {
|
||||
getLongTextPreviewText,
|
||||
getLongTextValue,
|
||||
hasLongTextExtraSections,
|
||||
} from "@/utils/longTextCard";
|
||||
|
||||
// 直接根据文字长度判断,超过约100个字符认为会溢出(约3行)
|
||||
const props = defineProps({
|
||||
title: {
|
||||
content: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
longTextData: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
finish: {
|
||||
type: Boolean,
|
||||
@@ -49,16 +55,22 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const tag = computed(() => getLongTextValue(props.longTextData, "tag"));
|
||||
const title = computed(() => getLongTextValue(props.longTextData, "title"));
|
||||
const previewContent = computed(() => {
|
||||
return getLongTextPreviewText(props.longTextData) || (props.content ? String(props.content) : "");
|
||||
});
|
||||
|
||||
// 处理文本内容:按行截断以保证预览最多显示三行(更贴近视觉行数)
|
||||
// 点击“查看详情”会跳转到完整页面(不受预览截断影响)。
|
||||
const PREVIEW_LINES = 3;
|
||||
const PREVIEW_CHAR_LIMIT = 100; // 作为备用,当没有换行但过长时也会截断
|
||||
const processedText = computed(() => {
|
||||
const txt = props.text ? String(props.text) : "";
|
||||
if (!txt) return "";
|
||||
const processedContent = computed(() => {
|
||||
const content = previewContent.value ? String(previewContent.value) : "";
|
||||
if (!content) return "";
|
||||
|
||||
// 按行分割(保留空行)
|
||||
const lines = txt.split(/\r?\n/);
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// 如果行数超过限制,截取前 PREVIEW_LINES 行并添加省略号
|
||||
if (lines.length > PREVIEW_LINES) {
|
||||
@@ -66,21 +78,26 @@ const processedText = computed(() => {
|
||||
}
|
||||
|
||||
// 若虽然行数不超过,但总长度仍然很长,做字符级截断作为兜底
|
||||
if (txt.length > PREVIEW_CHAR_LIMIT) {
|
||||
return txt.slice(0, PREVIEW_CHAR_LIMIT) + "...";
|
||||
if (content.length > PREVIEW_CHAR_LIMIT) {
|
||||
return content.slice(0, PREVIEW_CHAR_LIMIT) + "...";
|
||||
}
|
||||
|
||||
return txt;
|
||||
return content;
|
||||
});
|
||||
|
||||
const isOverflow = computed(() => {
|
||||
const textStr = props.text ? String(props.text) : "";
|
||||
const lines = textStr.split(/\r?\n/);
|
||||
return lines.length > PREVIEW_LINES || textStr.length > PREVIEW_CHAR_LIMIT;
|
||||
const contentStr = previewContent.value ? String(previewContent.value) : "";
|
||||
const lines = contentStr.split(/\r?\n/);
|
||||
return (
|
||||
hasLongTextExtraSections(props.longTextData) ||
|
||||
lines.length > PREVIEW_LINES ||
|
||||
contentStr.length > PREVIEW_CHAR_LIMIT
|
||||
);
|
||||
});
|
||||
|
||||
let stopForwardWatcher = null;
|
||||
let stopFinishWatcher = null;
|
||||
let stopLongTextWatcher = null;
|
||||
|
||||
const cleanupStreamWatchers = () => {
|
||||
if (stopForwardWatcher) {
|
||||
@@ -91,30 +108,51 @@ const cleanupStreamWatchers = () => {
|
||||
stopFinishWatcher();
|
||||
stopFinishWatcher = null;
|
||||
}
|
||||
if (stopLongTextWatcher) {
|
||||
stopLongTextWatcher();
|
||||
stopLongTextWatcher = null;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(cleanupStreamWatchers);
|
||||
|
||||
const lookDetailAction = () => {
|
||||
const message = props.text ? String(props.text) : "";
|
||||
const message = previewContent.value ? String(previewContent.value) : "";
|
||||
// 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅
|
||||
const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
|
||||
StreamManager.openStream(streamId, message, !!props.finish);
|
||||
const updateStream = () => {
|
||||
StreamManager.updateStream(
|
||||
streamId,
|
||||
previewContent.value ? String(previewContent.value) : "",
|
||||
!!props.finish,
|
||||
props.longTextData || null,
|
||||
);
|
||||
};
|
||||
|
||||
StreamManager.openStream(streamId, message, !!props.finish, props.longTextData || null);
|
||||
|
||||
cleanupStreamWatchers();
|
||||
|
||||
if (!props.finish) {
|
||||
// 将当前组件后续 props.text/props.finish 的更新转发到 StreamManager
|
||||
// 将当前组件后续 props.content/props.finish 的更新转发到 StreamManager
|
||||
stopForwardWatcher = watch(
|
||||
() => props.text,
|
||||
(v) => {
|
||||
StreamManager.updateStream(streamId, v ? String(v) : "", !!props.finish);
|
||||
}
|
||||
() => props.content,
|
||||
updateStream
|
||||
);
|
||||
stopLongTextWatcher = watch(
|
||||
() => props.longTextData,
|
||||
updateStream,
|
||||
{ deep: true }
|
||||
);
|
||||
stopFinishWatcher = watch(
|
||||
() => props.finish,
|
||||
(f) => {
|
||||
StreamManager.updateStream(streamId, props.text ? String(props.text) : "", !!f);
|
||||
StreamManager.updateStream(
|
||||
streamId,
|
||||
previewContent.value ? String(previewContent.value) : "",
|
||||
!!f,
|
||||
props.longTextData || null,
|
||||
);
|
||||
if (f) {
|
||||
cleanupStreamWatchers();
|
||||
}
|
||||
@@ -142,7 +180,16 @@ const lookDetailAction = () => {
|
||||
line-height: 16px;
|
||||
max-height: 80px;
|
||||
}
|
||||
.border-left-4 {
|
||||
border-left: 4px solid $theme-color-500;
|
||||
.long-answer-tag {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba($theme-color-500, 0.2);
|
||||
background: rgba($theme-color-500, 0.08);
|
||||
color: $theme-color-500;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,8 +15,10 @@ const getEvnUrl = async () => {
|
||||
if (developVersion) {
|
||||
const appStore = useAppStore();
|
||||
appStore.setServerConfig({
|
||||
baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址
|
||||
wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址
|
||||
// baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址
|
||||
// wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址
|
||||
baseUrl: devUrl, // 服务器基础地址
|
||||
wssUrl: wssDevUrl, // 服务器wss地址
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
// 简单的流式数据管理器:开启流、更新流、订阅流、关闭流
|
||||
const streams = {};
|
||||
|
||||
function openStream(id, initial = '', finished = false) {
|
||||
if (!id) return;
|
||||
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
|
||||
streams[id].text = initial || '';
|
||||
streams[id].finished = !!finished;
|
||||
// notify existing subscribers
|
||||
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
|
||||
function notify(stream) {
|
||||
stream.subs.forEach((cb) => cb(stream.text, stream.finished, stream.payload));
|
||||
}
|
||||
|
||||
function updateStream(id, text, finished = false) {
|
||||
function openStream(id, initial = '', finished = false, payload = null) {
|
||||
if (!id) return;
|
||||
streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() };
|
||||
streams[id].text = initial || '';
|
||||
streams[id].finished = !!finished;
|
||||
streams[id].payload = payload || null;
|
||||
// notify existing subscribers
|
||||
notify(streams[id]);
|
||||
}
|
||||
|
||||
function updateStream(id, text, finished = false, payload = null) {
|
||||
if (!id || !streams[id]) return;
|
||||
streams[id].text = text || '';
|
||||
streams[id].finished = !!finished;
|
||||
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
|
||||
streams[id].payload = payload || null;
|
||||
notify(streams[id]);
|
||||
}
|
||||
|
||||
function subscribe(id, cb) {
|
||||
if (!id) return () => {};
|
||||
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
|
||||
streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() };
|
||||
streams[id].subs.add(cb);
|
||||
// send current snapshot immediately
|
||||
cb(streams[id].text, streams[id].finished);
|
||||
cb(streams[id].text, streams[id].finished, streams[id].payload);
|
||||
return () => {
|
||||
streams[id] && streams[id].subs.delete(cb);
|
||||
// 移除空流
|
||||
@@ -34,13 +40,13 @@ function subscribe(id, cb) {
|
||||
|
||||
function closeStream(id) {
|
||||
if (!id || !streams[id]) return;
|
||||
streams[id].subs.forEach((cb) => cb(streams[id].text, true));
|
||||
streams[id].subs.forEach((cb) => cb(streams[id].text, true, streams[id].payload));
|
||||
delete streams[id];
|
||||
}
|
||||
|
||||
function getSnapshot(id) {
|
||||
if (!id || !streams[id]) return { text: '', finished: false };
|
||||
return { text: streams[id].text, finished: streams[id].finished };
|
||||
if (!id || !streams[id]) return { text: '', finished: false, payload: null };
|
||||
return { text: streams[id].text, finished: streams[id].finished, payload: streams[id].payload };
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
115
src/utils/longTextCard.js
Normal file
115
src/utils/longTextCard.js
Normal file
@@ -0,0 +1,115 @@
|
||||
export const LONG_TEXT_KEYS = {
|
||||
tag: "tag",
|
||||
title: "title",
|
||||
content: "content",
|
||||
checklist: "checklist",
|
||||
suggest: "suggest",
|
||||
commodity: "commodity",
|
||||
actionZone: "action_zone",
|
||||
};
|
||||
|
||||
export const LONG_TEXT_FIELD_CONFIG = [
|
||||
{ key: LONG_TEXT_KEYS.tag },
|
||||
{ key: LONG_TEXT_KEYS.title },
|
||||
{ key: LONG_TEXT_KEYS.content },
|
||||
{ key: LONG_TEXT_KEYS.checklist },
|
||||
{ key: LONG_TEXT_KEYS.suggest },
|
||||
{ key: LONG_TEXT_KEYS.commodity },
|
||||
{ key: LONG_TEXT_KEYS.actionZone },
|
||||
];
|
||||
|
||||
export const LONG_TEXT_PREVIEW_KEYS = [
|
||||
LONG_TEXT_KEYS.content,
|
||||
LONG_TEXT_KEYS.title,
|
||||
LONG_TEXT_KEYS.tag,
|
||||
];
|
||||
|
||||
const CONFIGURED_KEYS = LONG_TEXT_FIELD_CONFIG.map((item) => item.key);
|
||||
|
||||
export const createLongTextData = () => ({
|
||||
values: {},
|
||||
parsedValues: {},
|
||||
});
|
||||
|
||||
const toText = (value) => {
|
||||
if (value === undefined || value === null) return "";
|
||||
return typeof value === "string" ? value : String(value);
|
||||
};
|
||||
|
||||
const shouldParseJSON = (raw) => {
|
||||
if (!raw || typeof raw !== "string") return false;
|
||||
return /^[\s]*[\[{]/.test(raw);
|
||||
};
|
||||
|
||||
const tryParseJSON = (raw) => {
|
||||
if (!shouldParseJSON(raw)) {
|
||||
return { ok: false, value: null };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) };
|
||||
} catch (e) {
|
||||
return { ok: false, value: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const appendLongTextChunk = (target, chunk = {}) => {
|
||||
if (!target || !chunk.contentKey) return target;
|
||||
|
||||
const key = String(chunk.contentKey);
|
||||
const value = toText(chunk.contentValue);
|
||||
|
||||
if (!target.values) target.values = {};
|
||||
if (!target.parsedValues) target.parsedValues = {};
|
||||
|
||||
target.values[key] = (target.values[key] || "") + value;
|
||||
|
||||
const parsed = tryParseJSON(target.values[key]);
|
||||
if (parsed.ok) {
|
||||
target.parsedValues[key] = parsed.value;
|
||||
} else {
|
||||
delete target.parsedValues[key];
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
export const getLongTextValue = (data, key) => {
|
||||
if (!data || !data.values || !key) return "";
|
||||
return data.values[key] || "";
|
||||
};
|
||||
|
||||
export const getLongTextParsedValue = (data, key, fallback = undefined) => {
|
||||
if (!data || !data.parsedValues || !key) return fallback;
|
||||
return Object.prototype.hasOwnProperty.call(data.parsedValues, key)
|
||||
? data.parsedValues[key]
|
||||
: fallback;
|
||||
};
|
||||
|
||||
export const getLongTextPreviewText = (data, keys = LONG_TEXT_PREVIEW_KEYS) => {
|
||||
for (const key of keys) {
|
||||
const value = getLongTextValue(data, key);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const hasLongTextExtraSections = (data, previewKeys = LONG_TEXT_PREVIEW_KEYS) => {
|
||||
if (!data || !data.values) return false;
|
||||
return Object.keys(data.values).some((key) => !previewKeys.includes(key));
|
||||
};
|
||||
|
||||
export const getLongTextSections = (data) => {
|
||||
if (!data || !data.values) return [];
|
||||
|
||||
const extraKeys = Object.keys(data.values).filter(
|
||||
(key) => !CONFIGURED_KEYS.includes(key),
|
||||
);
|
||||
|
||||
return [...CONFIGURED_KEYS, ...extraKeys]
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(data.values, key))
|
||||
.map((key) => ({
|
||||
contentKey: key,
|
||||
contentValue: getLongTextValue(data, key),
|
||||
parsedValue: getLongTextParsedValue(data, key, null),
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user