feat: 组件的数据结构调整

This commit is contained in:
2026-06-05 11:35:06 +08:00
parent 1be97986df
commit 3e06ae544b
7 changed files with 575 additions and 231 deletions

View File

@@ -18,69 +18,148 @@ 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));
const {
appendLongTextChunk,
createLongTextData,
getLongTextSections,
} = await import(pathToFileURL(longTextCardPath));
assert.match(
assert.doesNotMatch(
longTextCardSource,
/export\s+const\s+normalizeLongTextSpotLocate\s*=/,
"longTextCard should export normalizeLongTextSpotLocate for malformed spot_locate keys"
/normalizeLongText/,
"longTextCard should not contain field-specific normalize helpers"
);
assert.match(
assert.doesNotMatch(
longTextCardSource,
/export\s+const\s+normalizeLongTextQuestionSuggest\s*=/,
"longTextCard should export normalizeLongTextQuestionSuggest for JSON-string question_suggest values"
/spot_longitude|spot_latitude|spot_tag/,
"longTextCard should not know spot_locate child field names"
);
assert.match(
longTextCardSource,
/export\s+const\s+normalizeLongTextContentImage\s*=/,
"longTextCard should export normalizeLongTextContentImage for string/object content_image values"
assert.doesNotMatch(
componentSource,
/isGenericObjectSectionField/,
"ParsedValueView should use renderFieldEntries instead of a separate generic object branch"
);
assert.match(
componentSource,
/entry\.type\s*===\s*["']content-image["']/,
"ParsedValueView should render content_image with the dedicated image style"
/getRenderEntriesForObject/,
"ParsedValueView should derive object rows through the existing entry renderer"
);
assert.doesNotMatch(
componentSource,
/STRUCTURED_SECTION_CONFIG/,
"ParsedValueView should not require per-field structured section config"
);
assert.doesNotMatch(
componentSource,
/LONG_TEXT_KEYS\.(preparationSection|sectionSuggestion|pitfallSection|viewSection|suggestionSection|lightReminder|photoSpotSection|phoneSection|decisionSection|routeWarning|tourRoutes|facilitiesAlongTheWay)/,
"ParsedValueView should not reference ordinary section keys individually"
);
assert.doesNotMatch(
componentSource,
/content-body-section-dot|content-body-section-list/,
"generic object arrays should keep the original list-card rendering style"
);
assert.doesNotMatch(
componentSource,
/entry\.type\s*===\s*'(question-suggest|photo-list|aigc-componet|commodity-list|spot-locate)'/,
"renderFieldEntries should only render generic text/image/list entries"
);
assert.doesNotMatch(
componentSource,
/createSpecialFieldEntry/,
"dedicated long text components should be selected by top-level computed branches only"
);
assert.match(
componentSource,
/entry\.type\s*===\s*["']spot-locate["']/,
/shouldRenderQuestionSuggest/,
"top-level question_suggest fields should render through the dedicated chips"
);
assert.match(
componentSource,
/shouldRenderCommodityList/,
"top-level commodity_list fields should render through the dedicated product list"
);
assert.match(
componentSource,
/shouldRenderPhotoList/,
"top-level photo_list fields should render through the dedicated photo swiper"
);
assert.match(
componentSource,
/shouldRenderAigcComponet/,
"top-level aigc_componet fields should render through the dedicated AIGC card"
);
assert.match(
componentSource,
/shouldRenderSpotLocate/,
"top-level spot_locate fields should render through the dedicated POI card"
);
assert.doesNotMatch(
componentSource,
/createRenderEntry\(key,\s*entryKey,\s*value\[key\]\)/,
"nested object keys should not trigger dedicated long text components"
);
assert.match(
componentSource,
/openMap\(spotLocateValue\)/,
"ParsedValueView should render spot_locate with the dedicated POI action card"
);
assert.match(
componentSource,
/entry\.type\s*===\s*["']question-suggest["']/,
/getSpotLocateValue/,
"ParsedValueView should own spot_locate value shaping"
);
assert.doesNotMatch(
componentSource,
/spot_longitude:|spot_latitude:|spot_tag:/,
"ParsedValueView should not read malformed spot_locate keys"
);
assert.match(
componentSource,
/shouldRenderQuestionSuggest/,
"ParsedValueView should render question_suggest with dedicated FAQ chips"
);
assert.match(
componentSource,
/getQuestionSuggestItems/,
"ParsedValueView should own question_suggest value shaping"
);
assert.match(
componentSource,
/sendReply\(question\)/,
"question_suggest chips should send the selected follow-up question"
);
assert.doesNotMatch(
detailPageSource,
/LONG_TEXT_KEYS\.content\b/,
"long answer detail page should not reference removed content key"
);
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"
/LONG_TEXT_KEYS\.contentImage\b/,
"ParsedValueView should render content_image with the dedicated image style"
);
const ignoredFieldKeysMatch = componentSource.match(
@@ -102,28 +181,27 @@ const receivedKeys = [
"tag",
"title",
"content_summary",
"scene_image",
"content_image",
"view_section_title",
"view_section_items",
"suggestion_section_title",
"suggestion_section_content",
"light_reminder_title",
"light_reminder_items",
"view_section",
"suggestion_section",
"light_reminder",
"spot_locate",
"question_suggest",
];
const longTextData = {
values: {},
parsedValues: {},
};
const longTextData = createLongTextData();
receivedKeys.forEach((key) => {
longTextData.values[key] = key;
appendLongTextChunk(longTextData, { contentKey: key, contentValue: key });
});
appendLongTextChunk(longTextData, {
contentKey: "view_section_items",
contentValue: "old flat field",
});
assert.deepEqual(
getLongTextSections(longTextData).map((section) => section.contentKey),
receivedKeys,
"long text detail sections should preserve server receive order, including configured fields after extra fields"
[...receivedKeys, "view_section_items"],
"long text detail sections should keep configured fields first and preserve unknown contentKey fields"
);

View File

@@ -42,9 +42,16 @@ for (const snippet of forbiddenCompatibilitySnippets) {
const requiredStrictRenderingSnippets = [
"LONG_TEXT_KEYS.sceneImage",
"LONG_TEXT_KEYS.contentImage",
"LONG_TEXT_KEYS.commodityList",
"LONG_TEXT_KEYS.photoList",
"LONG_TEXT_KEYS.aigcComponet",
"shouldRenderSpotLocate",
"shouldRenderQuestionSuggest",
"shouldRenderCommodityList",
"shouldRenderPhotoList",
"shouldRenderAigcComponet",
"getRenderEntriesForObject",
"commodity.commodity_id",
"commodity.commodity_name",
"commodity.commodity_price",
@@ -62,9 +69,7 @@ const requiredStrictRenderingSnippets = [
"jumpAigcClick",
"getAccessToken",
"navigateTo",
"content-body-list-marker",
"entry.value.length > 1",
"formatListMarker(index)",
"content-body-list-card",
];
for (const snippet of requiredStrictRenderingSnippets) {
@@ -75,4 +80,71 @@ for (const snippet of requiredStrictRenderingSnippets) {
);
}
const removedInnerSpecialEntrySnippets = [
"createSpecialFieldEntry",
"entry.type === 'question-suggest'",
"entry.type === 'photo-list'",
"entry.type === 'aigc-componet'",
"entry.type === 'commodity-list'",
"entry.type === 'spot-locate'",
];
for (const snippet of removedInnerSpecialEntrySnippets) {
assert.equal(
source.includes(snippet),
false,
`ParsedValueView should keep special fields at the top level only: ${snippet}`
);
}
const removedFlatFieldSnippets = [
"preparationSectionTitle",
"preparationSectionItems",
"sectionSuggestionTitle",
"sectionSuggestionContent",
"pitfallSectionTitle",
"pitfallSectionItems",
"viewSectionTitle",
"viewSectionItems",
"suggestionSectionTitle",
"suggestionSectionContent",
"lightReminderTitle",
"lightReminderItems",
];
for (const snippet of removedFlatFieldSnippets) {
assert.equal(
source.includes(`LONG_TEXT_KEYS.${snippet}`),
false,
`ParsedValueView should not reference removed flat field: ${snippet}`
);
}
const removedStructuredConfigSnippets = [
"STRUCTURED_SECTION_CONFIG",
"isGenericObjectSectionField",
"content-body-section-dot",
"content-body-section-list",
"LONG_TEXT_KEYS.preparationSection",
"LONG_TEXT_KEYS.sectionSuggestion",
"LONG_TEXT_KEYS.pitfallSection",
"LONG_TEXT_KEYS.viewSection",
"LONG_TEXT_KEYS.suggestionSection",
"LONG_TEXT_KEYS.lightReminder",
"LONG_TEXT_KEYS.photoSpotSection",
"LONG_TEXT_KEYS.phoneSection",
"LONG_TEXT_KEYS.decisionSection",
"LONG_TEXT_KEYS.routeWarning",
"LONG_TEXT_KEYS.tourRoutes",
"LONG_TEXT_KEYS.facilitiesAlongTheWay",
];
for (const snippet of removedStructuredConfigSnippets) {
assert.equal(
source.includes(snippet),
false,
`ParsedValueView should use generic section rendering instead of configured field: ${snippet}`
);
}
console.log("ParsedValueView strict field checks passed");

View File

@@ -6,15 +6,45 @@ const source = await readFile(resolve("src/utils/longTextCard.js"), "utf8");
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const longTextCard = await import(moduleUrl);
assert.doesNotMatch(
source,
/normalizeLongText/,
"longTextCard should not contain field-specific normalize helpers"
);
assert.doesNotMatch(
source,
/spot_longitude|spot_latitude|spot_tag/,
"longTextCard should not know spot_locate child field names"
);
const {
LONG_TEXT_FIELD_CONFIG,
LONG_TEXT_KEYS,
appendLongTextChunk,
createLongTextData,
getLongTextParsedValue,
getLongTextSections,
getLongTextValue,
hasLongTextExtraSections,
parseLongTextDisplayValue,
sanitizeLongTextDisplayValue,
hasLongTextDisplayValue,
formatLongTextDisplayValue,
} = longTextCard;
assert.equal(
source.includes("REMOVED_LONG_TEXT_FIELD_KEYS"),
false,
"longTextCard should not carry a removed field blacklist"
);
assert.equal(
source.includes("hasLongTextChunkPayload"),
false,
"ChatMainList should not ask longTextCard to inspect plain data.content payloads"
);
assert.deepEqual(
parseLongTextDisplayValue('[ "see bridge", "hear water" ]'),
["see bridge", "hear water"],
@@ -22,25 +52,29 @@ assert.deepEqual(
);
assert.deepEqual(
parseLongTextDisplayValue('{"spot_name":"bridge","spot_longitude:":107.712345}'),
{ spot_name: "bridge", "spot_longitude:": 107.712345 },
parseLongTextDisplayValue('{"spot_name":"bridge","spot_longitude":107.712345}'),
{ spot_name: "bridge", spot_longitude: 107.712345 },
"serialized objects should be parsed for display"
);
assert.deepEqual(
sanitizeLongTextDisplayValue(
{
content: "hidden",
components: ["hidden"],
view_section_items: '[ "check piers", "count arches" ]',
preparation_section: {
preparation_section_title: "keep",
preparation_section_items: '[ "check piers", "count arches" ]',
},
nested: {
title: "keep",
},
},
["content", "components"]
[]
),
{
view_section_items: ["check piers", "count arches"],
preparation_section: {
preparation_section_title: "keep",
preparation_section_items: ["check piers", "count arches"],
},
nested: {
title: "keep",
},
@@ -54,20 +88,28 @@ assert.equal(formatLongTextDisplayValue(true), "\u662f");
assert.equal(formatLongTextDisplayValue({ title: "bridge" }), '{"title":"bridge"}');
const expectedNewKeys = {
preparationSectionTitle: "preparation_section_title",
preparationSectionItems: "preparation_section_items",
sectionSuggestionTitle: "section_suggestion_title",
sectionSuggestionContent: "section_suggestion_content",
pitfallSectionTitle: "pitfall_section_title",
pitfallSectionItems: "pitfall_section_items",
tag: "tag",
title: "title",
contentSummary: "content_summary",
sceneImage: "scene_image",
preparationSection: "preparation_section",
sectionSuggestion: "section_suggestion",
pitfallSection: "pitfall_section",
commodityList: "commodity_list",
photoSpotSectionTitle: "photo_spot_section_title",
photoSpotSectionItems: "photo_spot_section_items",
bestTimeSuggestion: "best_time_suggestion",
phoneSectionTitle: "phone_section_title",
phoneSectionItems: "phone_section_items",
contentImage: "content_image",
viewSection: "view_section",
suggestionSection: "suggestion_section",
lightReminder: "light_reminder",
spotLocate: "spot_locate",
photoSpotSection: "photo_spot_section",
phoneSection: "phone_section",
photoList: "photo_list",
aigcComponet: "aigc_componet",
decisionSection: "decision_section",
routeWarning: "route_warning",
tourRoutes: "tour_routes",
facilitiesAlongTheWay: "facilities_along_the_way",
questionSuggest: "question_suggest",
};
for (const [keyName, keyValue] of Object.entries(expectedNewKeys)) {
@@ -79,4 +121,153 @@ for (const [keyName, keyValue] of Object.entries(expectedNewKeys)) {
);
}
const removedKeys = [
"content",
"guideConclusion",
"keyFacts",
"preparationSectionTitle",
"preparationSectionItems",
"sectionSuggestionTitle",
"sectionSuggestionContent",
"pitfallSectionTitle",
"pitfallSectionItems",
"viewSectionTitle",
"viewSectionItems",
"suggestionSectionTitle",
"suggestionSectionContent",
"lightReminderTitle",
"lightReminderItems",
"components",
"actionZone",
];
for (const keyName of removedKeys) {
assert.equal(
Object.prototype.hasOwnProperty.call(LONG_TEXT_KEYS, keyName),
false,
`${keyName} should be removed from LONG_TEXT_KEYS`
);
}
const oldFlatFieldValues = [
"preparation_section_title",
"preparation_section_items",
"view_section_title",
"view_section_items",
];
for (const keyValue of oldFlatFieldValues) {
assert.equal(
LONG_TEXT_FIELD_CONFIG.some((item) => item.key === keyValue),
false,
`${keyValue} should be removed from LONG_TEXT_FIELD_CONFIG`
);
}
const longTextData = createLongTextData();
appendLongTextChunk(longTextData, { contentKey: "title", contentValue: "漂流攻略" });
appendLongTextChunk(longTextData, {
contentKey: "preparation_section",
contentValue: {
preparation_section_title: "下水前",
preparation_section_items: ["带手机防水袋", "穿涉水鞋"],
},
});
appendLongTextChunk(longTextData, {
contentKey: "future_section",
contentValue: {
future_section_title: "未来新增字段",
future_section_items: ["不改 LONG_TEXT_KEYS 也展示"],
},
});
appendLongTextChunk(longTextData, {
contentKey: "content_image",
contentValue: "https://oss.nianxx.cn/XiaoQiKong/GQ00001.jpg",
});
appendLongTextChunk(longTextData, {
contentKey: "spot_locate",
contentValue: '{"spot_name":"卧龙潭","spot_longitude":"107.712345","spot_latitude":"25.251234","spot_tag":"景点"}',
});
assert.equal(getLongTextValue(longTextData, "title"), "漂流攻略");
assert.deepEqual(
getLongTextParsedValue(longTextData, "preparation_section"),
{
preparation_section_title: "下水前",
preparation_section_items: ["带手机防水袋", "穿涉水鞋"],
},
"object contentValue should be serialized and parsed"
);
assert.deepEqual(
getLongTextSections(longTextData).map((section) => section.contentKey),
["title", "preparation_section", "future_section", "content_image", "spot_locate"],
"sections should place unknown fields by receive order between configured fields"
);
assert.equal(hasLongTextExtraSections(longTextData), true);
assert.deepEqual(
getLongTextParsedValue(longTextData, "future_section"),
{
future_section_title: "未来新增字段",
future_section_items: ["不改 LONG_TEXT_KEYS 也展示"],
},
"future top-level fields should keep parsed values"
);
const mergedPayload = {
tag: "攻略",
title: "合并结构标题",
content_summary: "合并结构摘要",
preparation_section: {
preparation_section_title: "下水前",
preparation_section_items: ["带手机防水袋", "穿涉水鞋"],
},
spot_locate: {
spot_name: "卧龙潭",
spot_longitude: "107.712345",
spot_latitude: "25.251234",
spot_tag: "景点",
},
photo_list: [
{
photo_name: "桥边机位",
photo_description: "站在桥头拍",
photo_url: "https://example.com/photo.png",
},
],
photo_spot_section: "{\n\"photo_spot_section_title\":\"四个不会错的机位\",\n\"photo_spot_items\":\"- 涵碧潭半清半浊\\n- 断桥飞瀑错位玩\",\n\"best_time_suggestion\":\"下午 2 点后人少\"\n}",
phone_section: "{\n\"phone_section_title\":\"手机党看这里\",\n\"phone_section_items\":\"- 手机倒过来贴近水面\\n- 瀑布用长曝光\"\n}",
aigc_componet: {
background: "https://example.com/aigc.png",
title: "AIGC合影",
description: "生成合影",
jumpUrl: "https://example.com/aigc",
},
question_suggest: ["还要准备什么?"],
future_section: {
future_section_title: "后续新增模块",
future_section_items: ["仍然展示"],
},
};
const mergedLongTextData = createLongTextData();
appendLongTextChunk(mergedLongTextData, {
contentKey: "photo_card_payload",
contentValue: mergedPayload,
});
assert.deepEqual(
getLongTextSections(mergedLongTextData).map((section) => section.contentKey),
[
"photo_card_payload",
],
"unknown contentKey fields should be kept as a single generic section"
);
assert.deepEqual(
getLongTextParsedValue(mergedLongTextData, "photo_card_payload"),
mergedPayload,
"unknown contentKey object values should keep parsed values"
);
console.log("longTextCard display helpers passed");