feat: 增加商品组件

This commit is contained in:
2026-06-04 14:28:47 +08:00
parent afee02de33
commit 881abda55e
5 changed files with 241 additions and 4 deletions

View File

@@ -40,4 +40,25 @@ for (const snippet of forbiddenCompatibilitySnippets) {
);
}
const requiredStrictRenderingSnippets = [
"LONG_TEXT_KEYS.sceneImage",
"LONG_TEXT_KEYS.commodityList",
"commodity.commodity_id",
"commodity.commodity_name",
"commodity.commodity_price",
"commodity.commodity_tag",
"commodity.commodity_photo",
"content-body-list-marker",
"entry.value.length > 1",
"formatListMarker(index)",
];
for (const snippet of requiredStrictRenderingSnippets) {
assert.equal(
source.includes(snippet),
true,
`ParsedValueView should render exact strict field: ${snippet}`
);
}
console.log("ParsedValueView strict field checks passed");

View File

@@ -7,6 +7,8 @@ const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("b
const longTextCard = await import(moduleUrl);
const {
LONG_TEXT_FIELD_CONFIG,
LONG_TEXT_KEYS,
parseLongTextDisplayValue,
sanitizeLongTextDisplayValue,
hasLongTextDisplayValue,
@@ -51,4 +53,23 @@ assert.equal(hasLongTextDisplayValue({ a: "", b: [" ", null] }), false);
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",
commodityList: "commodity_list",
};
for (const [keyName, keyValue] of Object.entries(expectedNewKeys)) {
assert.equal(LONG_TEXT_KEYS[keyName], keyValue, `${keyName} should be registered`);
assert.equal(
LONG_TEXT_FIELD_CONFIG.some((item) => item.key === keyValue),
true,
`${keyValue} should be in LONG_TEXT_FIELD_CONFIG`
);
}
console.log("longTextCard display helpers passed");

View File

@@ -1,6 +1,6 @@
<template>
<view v-if="shouldRenderField" class="parsed-value">
<image v-if="shouldRenderContentImage" class="content-body-image" :src="contentImageUrl"
<image v-if="shouldRenderContentImage" class="content-body-image content-body-image-bottom" :src="contentImageUrl"
mode="widthFix" @click="handlePreviewClick(contentImageUrl)" />
<template v-else-if="shouldRenderSpotLocate">
@@ -26,6 +26,47 @@
</view>
</template>
<template v-else-if="shouldRenderCommodityList">
<view class="detail-action-zone">
<view class="detail-action-label">相关票务</view>
<scroll-view class="detail-product-scroll" scroll-x>
<view class="detail-product-row">
<view
v-for="commodity in commodityItems"
:key="commodity.commodity_id || commodity.commodity_name"
class="detail-product-card"
>
<image
v-if="commodity.commodity_photo"
class="detail-product-image"
:src="commodity.commodity_photo"
mode="aspectFill"
@click="handlePreviewClick(commodity.commodity_photo)"
/>
<view class="detail-product-body">
<view v-if="commodity.commodity_tag" class="detail-product-tag">
{{ commodity.commodity_tag }}
</view>
<view v-if="commodity.commodity_name" class="detail-product-name">
{{ commodity.commodity_name }}
</view>
<view v-if="commodity.commodity_price" class="detail-product-price">
<text class="detail-product-currency">¥</text>{{ commodity.commodity_price }}
</view>
<button
v-if="commodity.commodity_id"
class="detail-buy-button"
@click.stop="openCommodityDetail(commodity)"
>
立即购买
</button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<template v-else>
<template v-for="entry in renderFieldEntries" :key="entry.key">
<view v-if="entry.type === 'text'" class="content-body-text">
@@ -42,6 +83,9 @@
<view v-else-if="entry.type === 'list'" class="content-body-list-card" :style="contentBodyListCardStyle">
<view v-for="(item, index) in entry.value" :key="index" class="content-body-list-item">
<view v-if="entry.value.length > 1" class="content-body-list-marker" :style="contentBodyListTextStyle">
{{ formatListMarker(index) }}
</view>
<view class="content-body-list-text" :style="contentBodyListTextStyle">
{{ formatLeafValue(item) }}
</view>
@@ -104,6 +148,17 @@ const formatLeafValue = (value) => {
return formatLongTextDisplayValue(value, IGNORED_FIELD_KEYS);
};
const LIST_MARKERS = [
"①", "②", "③", "④", "⑤",
"⑥", "⑦", "⑧", "⑨", "⑩",
"⑪", "⑫", "⑬", "⑭", "⑮",
"⑯", "⑰", "⑱", "⑲", "⑳",
];
const formatListMarker = (index) => {
return LIST_MARKERS[index] || `${index + 1}.`;
};
const toTrimmedText = (value) => {
if (value === undefined || value === null) return "";
return String(value).trim();
@@ -131,9 +186,13 @@ const createImageEntry = (key, value) => ({
/// ======== Render Logic ========
const isIgnoredField = computed(() => IGNORED_FIELD_KEYS.includes(props.fieldKey));
const isContentImageField = computed(() => props.fieldKey === LONG_TEXT_KEYS.contentImage);
const isContentImageField = computed(() =>
props.fieldKey === LONG_TEXT_KEYS.contentImage ||
props.fieldKey === LONG_TEXT_KEYS.sceneImage
);
const isSpotLocateField = computed(() => props.fieldKey === LONG_TEXT_KEYS.spotLocate);
const isQuestionSuggestField = computed(() => props.fieldKey === LONG_TEXT_KEYS.questionSuggest);
const isCommodityListField = computed(() => props.fieldKey === LONG_TEXT_KEYS.commodityList);
const displayValue = computed(() => sanitizeValue(props.value));
@@ -161,6 +220,21 @@ const questionSuggestItems = computed(() => {
: [];
});
const commodityItems = computed(() => {
if (!isArrayValue(displayValue.value)) return [];
return displayValue.value
.filter((item) => isObjectValue(item))
.map((commodity) => ({
commodity_id: toTrimmedText(commodity.commodity_id),
commodity_name: toTrimmedText(commodity.commodity_name),
commodity_price: toTrimmedText(commodity.commodity_price),
commodity_tag: toTrimmedText(commodity.commodity_tag),
commodity_photo: toTrimmedText(commodity.commodity_photo),
}))
.filter((commodity) => hasDisplayValue(commodity));
});
const shouldRenderContentImage = computed(() => {
return isContentImageField.value && !!contentImageUrl.value;
});
@@ -173,6 +247,10 @@ const shouldRenderQuestionSuggest = computed(() => {
return isQuestionSuggestField.value && questionSuggestItems.value.length > 0;
});
const shouldRenderCommodityList = computed(() => {
return isCommodityListField.value && commodityItems.value.length > 0;
});
/// 其他字段走通用渲染逻辑
const renderFieldEntries = computed(() => {
if (isIgnoredField.value || !hasDisplayValue(props.value)) return [];
@@ -226,7 +304,8 @@ const shouldRenderField = computed(() => {
if (
shouldRenderContentImage.value ||
shouldRenderSpotLocate.value ||
shouldRenderQuestionSuggest.value
shouldRenderQuestionSuggest.value ||
shouldRenderCommodityList.value
) {
return true;
}
@@ -265,6 +344,13 @@ const sendReply = (item) => {
uni.$emit(SEND_MESSAGE_CONTENT_TEXT, item);
};
const openCommodityDetail = (commodity) => {
if (!commodity.commodity_id) return;
uni.navigateTo({
url: `/pages/goods/index?commodityId=${commodity.commodity_id}`,
});
};
const handlePreviewClick = (imageUrl) => {
uni.previewImage({
current: imageUrl,

View File

@@ -14,6 +14,11 @@
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.content-body-image-bottom {
margin-bottom: 8px;
}
.content-body-image {
@@ -36,6 +41,7 @@
gap: 4px;
padding: 12px;
border-radius: 12px;
margin-bottom: 8px;
}
.content-body-list-item {
@@ -43,6 +49,14 @@
align-items: flex-start;
}
.content-body-list-marker {
width: 22px;
flex-shrink: 0;
font-size: 15px;
font-weight: 800;
line-height: 20px;
}
.content-body-list-text {
flex: 1;
font-size: 15px;
@@ -56,7 +70,7 @@
}
.detail-action-label {
padding: 12px 0 12px;
padding: 0 0 10px;
color: #94a3b8;
font-size: 10px;
font-weight: 900;
@@ -131,6 +145,87 @@
border: 0;
}
.detail-product-scroll {
width: 100%;
white-space: nowrap;
}
.detail-product-row {
display: flex;
gap: 10px;
}
.detail-product-card {
width: 220px;
flex-shrink: 0;
overflow: hidden;
border: 1px solid #f1f5f9;
border-radius: 20px;
background: #fff;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
}
.detail-product-image {
width: 100%;
height: 110px;
background: #f1f5f9;
}
.detail-product-body {
padding: 10px 12px 12px;
}
.detail-product-tag {
display: inline-flex;
width: fit-content;
margin-bottom: 4px;
padding: 2px 6px;
border-radius: 6px;
color: #f43f5e;
background: #fef2f2;
font-size: 9px;
font-weight: 900;
}
.detail-product-name {
overflow: hidden;
color: #1e293b;
font-size: 13px;
font-weight: 900;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-product-price {
margin: 4px 0 8px;
color: #f43f5e;
font-size: 20px;
font-weight: 900;
line-height: 26px;
}
.detail-product-currency {
font-size: 12px;
}
.detail-buy-button {
width: 100%;
height: 34px;
padding: 0;
border: 0;
border-radius: 12px;
color: #451a03;
background: #fbbf24;
font-size: 12px;
font-weight: 900;
line-height: 34px;
}
.detail-buy-button::after {
border: 0;
}
.detail-faq-wrap {
margin: 0;
}

View File

@@ -7,6 +7,13 @@ export const LONG_TEXT_KEYS = {
guideConclusion: "guide_conclusion",
keyFacts: "key_facts",
sceneImage: "scene_image",
preparationSectionTitle: "preparation_section_title",
preparationSectionItems: "preparation_section_items",
sectionSuggestionTitle: "section_suggestion_title",
sectionSuggestionContent: "section_suggestion_content",
pitfallSectionTitle: "pitfall_section_title",
pitfallSectionItems: "pitfall_section_items",
commodityList: "commodity_list",
contentImage: "content_image",
viewSectionTitle: "view_section_title",
viewSectionItems: "view_section_items",
@@ -55,6 +62,13 @@ export const LONG_TEXT_FIELD_CONFIG = [
{ key: LONG_TEXT_KEYS.guideConclusion },
{ key: LONG_TEXT_KEYS.keyFacts },
{ key: LONG_TEXT_KEYS.sceneImage },
{ key: LONG_TEXT_KEYS.preparationSectionTitle },
{ key: LONG_TEXT_KEYS.preparationSectionItems },
{ key: LONG_TEXT_KEYS.sectionSuggestionTitle },
{ key: LONG_TEXT_KEYS.sectionSuggestionContent },
{ key: LONG_TEXT_KEYS.pitfallSectionTitle },
{ key: LONG_TEXT_KEYS.pitfallSectionItems },
{ key: LONG_TEXT_KEYS.commodityList },
{ key: LONG_TEXT_KEYS.contentImage },
{ key: LONG_TEXT_KEYS.viewSectionTitle },
{ key: LONG_TEXT_KEYS.viewSectionItems },