From 19b97fc3267d5a8639c9ea7d24315f4945d47e35 Mon Sep 17 00:00:00 2001 From: zoujing Date: Thu, 4 Jun 2026 09:47:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A1=AB=E5=85=85=E4=BA=86=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gression-long-answer-parsed-value-view.mjs | 129 ++++++++++++++++++ .../ChatLongAnswer/ParsedValueView.vue | 104 ++++++++++---- src/pages/ChatMain/ChatLongAnswer/index.vue | 1 - .../styles/ParsedValueView.scss | 6 +- src/utils/longTextCard.js | 124 +++++++++++++++-- 5 files changed, 331 insertions(+), 33 deletions(-) create mode 100644 scripts/regression-long-answer-parsed-value-view.mjs diff --git a/scripts/regression-long-answer-parsed-value-view.mjs b/scripts/regression-long-answer-parsed-value-view.mjs new file mode 100644 index 0000000..7833900 --- /dev/null +++ b/scripts/regression-long-answer-parsed-value-view.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const componentPath = resolve( + scriptDir, + "../src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue" +); +const detailPagePath = resolve( + scriptDir, + "../src/pages/ChatMain/ChatLongAnswer/index.vue" +); +const longTextCardPath = resolve(scriptDir, "../src/utils/longTextCard.js"); + +const componentSource = readFileSync(componentPath, "utf8"); +const detailPageSource = readFileSync(detailPagePath, "utf8"); +const longTextCardSource = readFileSync(longTextCardPath, "utf8"); +const { getLongTextSections } = await import(pathToFileURL(longTextCardPath)); + +assert.match( + longTextCardSource, + /export\s+const\s+normalizeLongTextSpotLocate\s*=/, + "longTextCard should export normalizeLongTextSpotLocate for malformed spot_locate keys" +); + +assert.match( + longTextCardSource, + /export\s+const\s+normalizeLongTextQuestionSuggest\s*=/, + "longTextCard should export normalizeLongTextQuestionSuggest for JSON-string question_suggest values" +); + +assert.match( + longTextCardSource, + /export\s+const\s+normalizeLongTextContentImage\s*=/, + "longTextCard should export normalizeLongTextContentImage for string/object content_image values" +); + +assert.match( + componentSource, + /entry\.type\s*===\s*["']content-image["']/, + "ParsedValueView should render content_image with the dedicated image style" +); + +assert.match( + componentSource, + /entry\.type\s*===\s*["']spot-locate["']/, + "ParsedValueView should render spot_locate with the dedicated POI action card" +); + +assert.match( + componentSource, + /entry\.type\s*===\s*["']question-suggest["']/, + "ParsedValueView should render question_suggest with dedicated FAQ chips" +); + +assert.match( + componentSource, + /sendReply\(question\)/, + "question_suggest chips should send the selected follow-up question" +); + +assert.match( + componentSource, + /openMap\(entry\.value\)/, + "spot_locate action card should open the normalized map location" +); + +const hiddenDetailKeysMatch = detailPageSource.match( + /const\s+HIDDEN_DETAIL_SECTION_KEYS\s*=\s*\[([\s\S]*?)\];/ +); + +assert.ok( + hiddenDetailKeysMatch, + "long answer detail page should define HIDDEN_DETAIL_SECTION_KEYS" +); + +assert.doesNotMatch( + hiddenDetailKeysMatch[1], + /LONG_TEXT_KEYS\.contentSummary/, + "long answer detail page should not hide content_summary" +); + +const ignoredFieldKeysMatch = componentSource.match( + /const\s+IGNORED_FIELD_KEYS\s*=\s*\[([\s\S]*?)\];/ +); + +assert.ok( + ignoredFieldKeysMatch, + "ParsedValueView should define IGNORED_FIELD_KEYS" +); + +assert.doesNotMatch( + ignoredFieldKeysMatch[1], + /["']content_summary["']/, + "ParsedValueView should not ignore content_summary" +); + +const receivedKeys = [ + "tag", + "title", + "content_summary", + "content_image", + "view_section_title", + "view_section_items", + "suggestion_section_title", + "suggestion_section_content", + "light_reminder_title", + "light_reminder_items", + "spot_locate", + "question_suggest", +]; + +const longTextData = { + values: {}, + parsedValues: {}, +}; + +receivedKeys.forEach((key) => { + longTextData.values[key] = key; +}); + +assert.deepEqual( + getLongTextSections(longTextData).map((section) => section.contentKey), + receivedKeys, + "long text detail sections should preserve server receive order, including configured fields after extra fields" +); diff --git a/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue b/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue index 3251ae7..7a76361 100644 --- a/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue +++ b/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue @@ -21,7 +21,24 @@ - - @@ -55,7 +55,12 @@ import { computed, defineProps, ref } from "vue"; import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant"; import { getRandomTagToneStyle } from "@/utils/tagTone"; -import { LONG_TEXT_KEYS } from "@/utils/longTextCard"; +import { + LONG_TEXT_KEYS, + normalizeLongTextContentImage, + normalizeLongTextQuestionSuggest, + normalizeLongTextSpotLocate, +} from "@/utils/longTextCard"; const props = defineProps({ fieldKey: { @@ -77,7 +82,7 @@ const contentBodyListTextStyle = computed(() => ({ color: contentBodyListToneStyle.value.color, })); -const IGNORED_FIELD_KEYS = ["container_type", "content", "content_summary", "components"]; +const IGNORED_FIELD_KEYS = ["container_type", "content", "components"]; const isArrayValue = (value) => Array.isArray(value); @@ -151,9 +156,49 @@ const createImageEntry = (key, value) => ({ }, }); +const createSpecialFieldEntry = (key, value) => { + if (key === LONG_TEXT_KEYS.contentImage) { + const image = normalizeLongTextContentImage(value); + return image.url + ? [{ + key, + type: "content-image", + value: image, + }] + : []; + } + + if (key === LONG_TEXT_KEYS.spotLocate) { + const spot = normalizeLongTextSpotLocate(value); + return hasDisplayValue(spot) + ? [{ + key, + type: "spot-locate", + value: spot, + }] + : []; + } + + if (key === LONG_TEXT_KEYS.questionSuggest) { + const questions = normalizeLongTextQuestionSuggest(value); + return questions.length + ? [{ + key, + type: "question-suggest", + value: questions, + }] + : []; + } + + return null; +}; + const renderFieldEntries = computed(() => { if (isIgnoredField.value || !hasDisplayValue(props.value)) return []; + const specialFieldEntry = createSpecialFieldEntry(props.fieldKey, props.value); + if (specialFieldEntry) return specialFieldEntry; + const value = sanitizeValue(props.value); if (isImageValue(value)) { return [createImageEntry(props.fieldKey, value)]; @@ -200,14 +245,27 @@ const renderFieldEntries = computed(() => { const isIgnoredField = computed(() => IGNORED_FIELD_KEYS.includes(props.fieldKey)); const shouldRenderField = computed(() => renderFieldEntries.value.length > 0); +const hasMapLocation = (spot) => { + return Number.isFinite(spot?.latitude) && Number.isFinite(spot?.longitude); +}; + const openMap = (spot) => { + if (!hasMapLocation(spot)) { + uni.showToast({ + title: "暂无位置信息", + icon: "none", + }); + return; + } + const latitude = spot.latitude; const longitude = spot.longitude; - const address = spot.name || ''; + const name = spot.name || ""; + const address = spot.description || name; uni.getLocation({ type: 'gcj02', success: () => { - uni.openLocation({ latitude, longitude, address }); + uni.openLocation({ latitude, longitude, name, address }); }, fail: () => { - uni.openLocation({ latitude, longitude, address }); + uni.openLocation({ latitude, longitude, name, address }); }}); }; diff --git a/src/pages/ChatMain/ChatLongAnswer/index.vue b/src/pages/ChatMain/ChatLongAnswer/index.vue index 4d30a27..800cc21 100644 --- a/src/pages/ChatMain/ChatLongAnswer/index.vue +++ b/src/pages/ChatMain/ChatLongAnswer/index.vue @@ -67,7 +67,6 @@ let unsubscribe = null; const HIDDEN_DETAIL_SECTION_KEYS = [ LONG_TEXT_KEYS.containerType, - LONG_TEXT_KEYS.contentSummary, ]; const shouldUseParsedValueView = (section) => { diff --git a/src/pages/ChatMain/ChatLongAnswer/styles/ParsedValueView.scss b/src/pages/ChatMain/ChatLongAnswer/styles/ParsedValueView.scss index ec966b0..0fa9e47 100644 --- a/src/pages/ChatMain/ChatLongAnswer/styles/ParsedValueView.scss +++ b/src/pages/ChatMain/ChatLongAnswer/styles/ParsedValueView.scss @@ -127,6 +127,10 @@ line-height: 42px; } +.detail-solid-button::after { + border: 0; +} + .detail-faq-wrap { margin: 0; } @@ -160,4 +164,4 @@ font-size: 8px; font-weight: 900; line-height: 11px; -} \ No newline at end of file +} diff --git a/src/utils/longTextCard.js b/src/utils/longTextCard.js index d63523e..26f3c04 100644 --- a/src/utils/longTextCard.js +++ b/src/utils/longTextCard.js @@ -88,8 +88,6 @@ export const LONG_TEXT_PREVIEW_KEYS = [ LONG_TEXT_KEYS.tag, ]; -const CONFIGURED_KEYS = LONG_TEXT_FIELD_CONFIG.map((item) => item.key); - export const createLongTextData = () => ({ values: {}, parsedValues: {}, @@ -116,6 +114,121 @@ const tryParseJSON = (raw) => { } }; +const parseMaybeJSON = (value) => { + if (typeof value !== "string") return value; + + const parsed = tryParseJSON(value.trim()); + return parsed.ok ? parsed.value : value; +}; + +const toTrimmedText = (value) => { + if (value === undefined || value === null) return ""; + return String(value).trim(); +}; + +const toFiniteNumber = (value) => { + if (value === undefined || value === null || value === "") return null; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +}; + +const pickFirstValue = (...values) => { + return values.find((value) => value !== undefined && value !== null && value !== ""); +}; + +const normalizeObjectKeys = (value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + + return Object.keys(value).reduce((result, key) => { + const normalizedKey = String(key).trim().replace(/[::]+$/, ""); + result[normalizedKey] = value[key]; + return result; + }, {}); +}; + +export const normalizeLongTextContentImage = (value) => { + const parsedValue = parseMaybeJSON(value); + + if (typeof parsedValue === "string") { + return { + url: toTrimmedText(parsedValue), + caption: "", + }; + } + + const valueObj = normalizeObjectKeys(parsedValue); + return { + url: toTrimmedText( + pickFirstValue( + valueObj.url, + valueObj.image_url, + valueObj.image, + valueObj.image_id, + valueObj.content_image + ) + ), + caption: toTrimmedText(pickFirstValue(valueObj.caption, valueObj.image_caption)), + }; +}; + +export const normalizeLongTextQuestionSuggest = (value) => { + const parsedValue = parseMaybeJSON(value); + const rawList = Array.isArray(parsedValue) + ? parsedValue + : [parsedValue?.questions, parsedValue?.items, parsedValue?.list].find(Array.isArray) || []; + + return rawList + .map((item) => toTrimmedText(item)) + .filter(Boolean); +}; + +export const normalizeLongTextSpotLocate = (value) => { + const valueObj = normalizeObjectKeys(parseMaybeJSON(value)); + + return { + name: toTrimmedText( + pickFirstValue( + valueObj.spot_name, + valueObj.sopt_name, + valueObj.name, + valueObj.title + ) + ), + description: toTrimmedText( + pickFirstValue( + valueObj.spot_description, + valueObj.sopt_description, + valueObj.description, + valueObj.desc + ) + ), + longitude: toFiniteNumber( + pickFirstValue( + valueObj.spot_longitude, + valueObj.sopt_longitude, + valueObj.longitude, + valueObj.lng + ) + ), + latitude: toFiniteNumber( + pickFirstValue( + valueObj.spot_latitude, + valueObj.sopt_latitude, + valueObj.latitude, + valueObj.lat + ) + ), + tag: toTrimmedText( + pickFirstValue( + valueObj.spot_tag, + valueObj.sopt_tag, + valueObj.tag, + valueObj.type + ) + ), + }; +}; + export const appendLongTextChunk = (target, chunk = {}) => { if (!target || !chunk.contentKey) return target; @@ -165,12 +278,7 @@ export const hasLongTextExtraSections = (data, previewKeys = LONG_TEXT_PREVIEW_K 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)) + return Object.keys(data.values) .map((key) => ({ contentKey: key, contentValue: getLongTextValue(data, key),