feat(wanfa): 新增路线详情参考价格配置及展示功能
- 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义
This commit is contained in:
1 parent
f18971a430
commit
feec6ab80f
20 files changed
+351
-17
No files matched your search
@@ -124,6 +124,10 @@ export type PublicDetail = {
|
||||
excluded: string[];
|
||||
notes: string[];
|
||||
gallery: string[];
|
||||
priceStartingValue: number | null;
|
||||
priceUnit: "person" | "day" | "group" | null;
|
||||
pricePeopleRange: string;
|
||||
priceDescription: string;
|
||||
conciergeAdvisor: PublicConciergeAdvisor | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
<view class="ml-auto flex min-w-0 items-center gap-2">
|
||||
<view class="min-w-0">
|
||||
<text class="block text-[10px] text-[#9b9188]">参考起价</text>
|
||||
<text class="mt-0.5 block whitespace-nowrap text-[18px] font-bold leading-none text-[#e96635]">¥{{
|
||||
price }}万</text>
|
||||
<text v-if="props.priceStartingValue !== null" class="mt-0.5 block whitespace-nowrap text-[18px] font-bold leading-none text-[#e96635]">¥{{
|
||||
formatWan(props.priceStartingValue) }}万{{ priceUnitSuffix }}</text>
|
||||
<text v-else class="mt-0.5 block whitespace-nowrap text-[15px] font-semibold leading-none text-[#a59b92]">价格待确认</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="showConcierge"
|
||||
@@ -37,16 +38,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { formatWan } from "@/lib/data";
|
||||
import type { PublicDetail } from "@/lib/types";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
price: number;
|
||||
const props = withDefaults(defineProps<{
|
||||
priceStartingValue: number | null;
|
||||
priceUnit: PublicDetail["priceUnit"];
|
||||
favorite: boolean;
|
||||
showConcierge?: boolean;
|
||||
}>(), {
|
||||
priceStartingValue: null,
|
||||
priceUnit: null,
|
||||
showConcierge: true,
|
||||
});
|
||||
|
||||
const priceUnitLabel = computed(() => {
|
||||
if (!props.priceUnit) return "";
|
||||
return { person: "人", day: "天", group: "团" }[props.priceUnit];
|
||||
});
|
||||
const priceUnitSuffix = computed(() => (priceUnitLabel.value ? ` / ${priceUnitLabel.value}` : ""));
|
||||
|
||||
defineEmits<{
|
||||
call: [];
|
||||
concierge: [];
|
||||
|
||||
@@ -31,6 +31,10 @@ function createRouteFallback(route: PlayRoute): DetailPresentation {
|
||||
"最终行程以出行日期、人数和资源确认结果为准。",
|
||||
],
|
||||
gallery: Array.from(new Set([route.image, ...sharedGallery])).slice(0, 5),
|
||||
priceStartingValue: null,
|
||||
priceUnit: null,
|
||||
pricePeopleRange: "",
|
||||
priceDescription: "",
|
||||
conciergeAdvisor: null,
|
||||
};
|
||||
}
|
||||
@@ -61,6 +65,19 @@ function cleanGallery(value: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function cleanPriceStartingValue(value: unknown, fallback: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function cleanPriceUnit(value: unknown, fallback: PublicDetail["priceUnit"]): PublicDetail["priceUnit"] {
|
||||
return value === "person" || value === "day" || value === "group" ? value : fallback;
|
||||
}
|
||||
|
||||
function cleanOptionalText(value: unknown, fallback: string) {
|
||||
return cleanText(value) || fallback;
|
||||
}
|
||||
|
||||
export function findWanfaRouteDetailFallback(routeId: string) {
|
||||
return routeFallbacks.get(routeId) ?? null;
|
||||
}
|
||||
@@ -78,6 +95,10 @@ export function normalizeDetailPresentation(
|
||||
const intro = cleanText(source.intro) || fallback?.intro || "";
|
||||
const gallery = cleanGallery(source.gallery);
|
||||
const conciergeAdvisor = normalizeConciergeAdvisor(source.conciergeAdvisor);
|
||||
const priceStartingValue = cleanPriceStartingValue(source.priceStartingValue, fallback?.priceStartingValue ?? null);
|
||||
const priceUnit = cleanPriceUnit(source.priceUnit, fallback?.priceUnit ?? null);
|
||||
const pricePeopleRange = cleanOptionalText(source.pricePeopleRange, fallback?.pricePeopleRange ?? "");
|
||||
const priceDescription = cleanOptionalText(source.priceDescription, fallback?.priceDescription ?? "");
|
||||
|
||||
if (!key || !eyebrow || !duration || !title || !subtitle || !intro) {
|
||||
return fallback;
|
||||
@@ -95,6 +116,10 @@ export function normalizeDetailPresentation(
|
||||
excluded: cleanList(source.excluded).length ? cleanList(source.excluded) : fallback?.excluded ?? [],
|
||||
notes: cleanList(source.notes).length ? cleanList(source.notes) : fallback?.notes ?? [],
|
||||
gallery: gallery.length ? gallery : fallback?.gallery ?? [],
|
||||
priceStartingValue,
|
||||
priceUnit,
|
||||
pricePeopleRange,
|
||||
priceDescription,
|
||||
conciergeAdvisor,
|
||||
};
|
||||
}
|
||||
@@ -43,7 +43,8 @@
|
||||
</scroll-view>
|
||||
|
||||
<DetailActionBar
|
||||
:price="1.68"
|
||||
:price-starting-value="presentation?.priceStartingValue ?? null"
|
||||
:price-unit="presentation?.priceUnit ?? null"
|
||||
:favorite="false"
|
||||
:show-concierge="Boolean(presentation?.conciergeAdvisor)"
|
||||
@call="goCall"
|
||||
@@ -59,6 +60,7 @@ import { computed, ref } from "vue";
|
||||
import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
|
||||
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
|
||||
import { fetchPublicDetail } from "@/lib/api";
|
||||
import { formatWan } from "@/lib/data";
|
||||
import { goBack } from "@/lib/navigation";
|
||||
import { rememberRecentlyViewed, syncRecentlyViewed } from "@/lib/recentlyViewed";
|
||||
import { SUPPORT_PHONE } from "@/lib/types";
|
||||
@@ -82,7 +84,22 @@ const errorMessage = ref("");
|
||||
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
|
||||
|
||||
const heroImage = computed(() => presentation.value?.gallery[0] ?? "");
|
||||
const priceParagraphs = computed(() => ["最终价格会根据出行日期、人数和资源确认结果生成。"]);
|
||||
const priceParagraphs = computed(() => {
|
||||
const current = presentation.value;
|
||||
if (!current) return ["价格将根据出行日期、人数和资源确认结果生成。"];
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
if (current.priceStartingValue !== null) {
|
||||
const unitLabels = { person: "人", day: "天", group: "团" } as const;
|
||||
const unit = current.priceUnit ? ` / ${unitLabels[current.priceUnit]}` : "";
|
||||
const peopleRange = current.pricePeopleRange ? `,适用${current.pricePeopleRange}` : "";
|
||||
paragraphs.push(`参考起价:¥${formatWan(current.priceStartingValue)}万${unit}${peopleRange}`);
|
||||
} else {
|
||||
paragraphs.push("价格待确认,最终价格会根据出行日期、人数和资源确认结果生成。");
|
||||
}
|
||||
if (current.priceDescription) paragraphs.push(current.priceDescription);
|
||||
return paragraphs;
|
||||
});
|
||||
|
||||
function parseRouteId(value: unknown) {
|
||||
if (typeof value !== "string") return "";
|
||||
|
||||
@@ -20,6 +20,10 @@ describe("wanfa route detail presentation", () => {
|
||||
excluded: [" 往返大交通 "],
|
||||
notes: [" 请准备雨具。 "],
|
||||
gallery: [" https://example.test/cover.jpg "],
|
||||
priceStartingValue: 1.68,
|
||||
priceUnit: "person",
|
||||
pricePeopleRange: " 2-4人 ",
|
||||
priceDescription: " 价格以最终确认方案为准。 ",
|
||||
conciergeAdvisor: null,
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -34,6 +38,10 @@ describe("wanfa route detail presentation", () => {
|
||||
excluded: ["往返大交通"],
|
||||
notes: ["请准备雨具。"],
|
||||
gallery: ["https://example.test/cover.jpg"],
|
||||
priceStartingValue: 1.68,
|
||||
priceUnit: "person",
|
||||
pricePeopleRange: "2-4人",
|
||||
priceDescription: "价格以最终确认方案为准。",
|
||||
conciergeAdvisor: null,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user