- 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义
126 lines
5.0 KiB
TypeScript
126 lines
5.0 KiB
TypeScript
import { playCategories, type PlayRoute } from "../../play/components/playData";
|
|
import type { PublicDetail } from "@/lib/types";
|
|
import { normalizeConciergeAdvisor } from "../../concierge/components/conciergeTypes";
|
|
|
|
export type DetailPresentation = PublicDetail;
|
|
|
|
const sharedGallery = [
|
|
"https://images.unsplash.com/photo-1500534623283-312aade485b7?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=800",
|
|
"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&fm=jpg&q=80&w=1200&h=800",
|
|
];
|
|
|
|
function createRouteFallback(route: PlayRoute): DetailPresentation {
|
|
return {
|
|
key: route.id,
|
|
eyebrow: "玩法推荐",
|
|
duration: "5天4晚",
|
|
title: route.title,
|
|
subtitle: route.subtitle,
|
|
intro: `围绕${route.title}安排一段节奏舒适的贵州探索,把自然景观、在地体验和小团服务串成一条完整路线。`,
|
|
highlights: [
|
|
"核心景观串联,减少无效往返和重复换乘",
|
|
"小团出行,按同行人的节奏灵活调整",
|
|
"在地体验与舒适停留合理衔接",
|
|
"行程前确认天气、装备和接送细节",
|
|
],
|
|
included: ["行程内用车与接送服务", "方案中注明的景点门票和体验项目", "行前服务管家确认与途中跟进"],
|
|
excluded: ["往返大交通及个人消费", "未列明餐食和自选体验", "因个人原因产生的额外费用"],
|
|
notes: [
|
|
"贵州多山多雨,请准备防滑鞋、轻便雨具和薄外套。",
|
|
"户外体验会根据天气和同行人体力适当调整。",
|
|
"最终行程以出行日期、人数和资源确认结果为准。",
|
|
],
|
|
gallery: Array.from(new Set([route.image, ...sharedGallery])).slice(0, 5),
|
|
priceStartingValue: null,
|
|
priceUnit: null,
|
|
pricePeopleRange: "",
|
|
priceDescription: "",
|
|
conciergeAdvisor: null,
|
|
};
|
|
}
|
|
|
|
const routeFallbacks = new Map(
|
|
playCategories.flatMap((category) => category.routes).map((route) => [route.id, createRouteFallback(route)]),
|
|
);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object";
|
|
}
|
|
|
|
function cleanText(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
}
|
|
|
|
function cleanList(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.map((item) => cleanText(item)).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function cleanGallery(value: unknown) {
|
|
return Array.from(
|
|
new Set(
|
|
cleanList(value).filter((item) => /^https?:\/\//i.test(item)),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function normalizeDetailPresentation(
|
|
value: unknown,
|
|
fallback: DetailPresentation | null = null,
|
|
): DetailPresentation | null {
|
|
const source = isRecord(value) ? value : {};
|
|
const key = cleanText(source.key) || fallback?.key || "";
|
|
const eyebrow = cleanText(source.eyebrow) || fallback?.eyebrow || "";
|
|
const duration = cleanText(source.duration) || fallback?.duration || "";
|
|
const title = cleanText(source.title) || fallback?.title || "";
|
|
const subtitle = cleanText(source.subtitle) || fallback?.subtitle || "";
|
|
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;
|
|
}
|
|
|
|
return {
|
|
key,
|
|
eyebrow,
|
|
duration,
|
|
title,
|
|
subtitle,
|
|
intro,
|
|
highlights: cleanList(source.highlights).length ? cleanList(source.highlights) : fallback?.highlights ?? [],
|
|
included: cleanList(source.included).length ? cleanList(source.included) : fallback?.included ?? [],
|
|
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,
|
|
};
|
|
}
|