feat(wanfa): 新增路线详情参考价格配置及展示功能

- 新增数据库迁移脚本,为详情表添加四个价格相关字段
- 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则
- 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位
- 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明
- 更新小程序端详情页面,支持展示配置的参考价格信息
- 补充相关测试用例,更新API文档与类型定义
This commit is contained in:
duanshuwen committed 2026-08-27 22:02:51 +08:00
1 parent f18971a430
commit feec6ab80f
20 files changed
+351 -17

No files matched your search

+4
View File
@@ -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,
};
}
+19 -2
View File
@@ -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,
});
});