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

@@ -35,6 +35,29 @@ function updateLines(field: "highlights" | "included" | "excluded" | "notes" | "
<el-input :model-value="modelValue[field].join('\n')" type="textarea" :rows="4" :disabled="disabled" :placeholder="placeholder" @update:model-value="updateLines(field, $event)" />
</el-form-item>
<ImageGalleryEditor :model-value="modelValue.gallery" :disabled="disabled" @update:model-value="update('gallery', $event)" />
<div class="detail-price-heading">
<strong>路线价格</strong>
<span>配置后将在前台路线详情中展示参考起价。</span>
</div>
<div class="editor-options">
<el-form-item label="价格起始值">
<el-input-number :model-value="modelValue.priceStartingValue ?? undefined" :disabled="disabled" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="例如:1.68" @update:model-value="update('priceStartingValue', $event)" />
</el-form-item>
<el-form-item label="价格单位">
<el-select :model-value="modelValue.priceUnit ?? '__none__'" :disabled="disabled" placeholder="请选择单位" @update:model-value="update('priceUnit', $event === '__none__' ? null : $event)">
<el-option value="__none__" label="未配置" />
<el-option value="person" label="人" />
<el-option value="day" label="天" />
<el-option value="group" label="团" />
</el-select>
</el-form-item>
</div>
<el-form-item label="适用人数范围">
<el-input :model-value="modelValue.pricePeopleRange ?? ''" maxlength="80" :disabled="disabled" placeholder="例如:2-4人" @update:model-value="update('pricePeopleRange', $event)" />
</el-form-item>
<el-form-item label="价格说明">
<el-input :model-value="modelValue.priceDescription ?? ''" type="textarea" :rows="3" maxlength="500" :disabled="disabled" placeholder="例如:价格以最终确认方案为准。" @update:model-value="update('priceDescription', $event)" />
</el-form-item>
<el-form-item label="联系管家">
<el-select :model-value="modelValue.conciergeAdvisorId ?? '__none__'" :disabled="disabled" placeholder="不配置管家" @update:model-value="update('conciergeAdvisorId', $event === '__none__' ? null : $event)">
<el-option value="__none__" label="不配置管家" />
@@ -40,6 +40,10 @@ watch(() => [props.route?.id, props.detail?.id], () => {
excluded: [...props.detail.excluded],
notes: [...props.detail.notes],
gallery: [...props.detail.gallery],
priceStartingValue: props.detail.priceStartingValue,
priceUnit: props.detail.priceUnit,
pricePeopleRange: props.detail.pricePeopleRange,
priceDescription: props.detail.priceDescription,
conciergeAdvisorId: props.detail.conciergeAdvisorId,
isActive: props.detail.isActive,
sortOrder: props.detail.sortOrder,
@@ -38,6 +38,10 @@ describe("wanfa editor helpers", () => {
excluded: [],
notes: [" 雨具 ", ""],
gallery: [" https://example.com/detail.jpg "],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: " 2-4人 ",
priceDescription: " 价格以最终确认方案为准。 ",
conciergeAdvisorId: " advisor-1 ",
isActive: true,
})).toEqual({
@@ -52,6 +56,10 @@ describe("wanfa editor helpers", () => {
excluded: [],
notes: ["雨具"],
gallery: ["https://example.com/detail.jpg"],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: "2-4人",
priceDescription: "价格以最终确认方案为准。",
conciergeAdvisorId: "advisor-1",
isActive: true,
});
+22 -2
View File
@@ -1,4 +1,4 @@
import type { DetailCreate, WanfaRouteCreate } from "@/types";
import type { DetailCreate, DetailPriceUnit, WanfaRouteCreate } from "@/types";
export function createEmptyWanfaRoute(): WanfaRouteCreate {
return { title: "", subtitle: "", image: "", routeCount: 0, demandKeyword: "" };
@@ -27,6 +27,10 @@ export function createEmptyDetail(): DetailCreate {
excluded: [],
notes: [],
gallery: [],
priceStartingValue: null,
priceUnit: null,
pricePeopleRange: "",
priceDescription: "",
conciergeAdvisorId: null,
isActive: true,
};
@@ -45,9 +49,13 @@ export function compactDetail(draft: DetailCreate): DetailCreate {
excluded: compactList(draft.excluded),
notes: compactList(draft.notes),
gallery: compactList(draft.gallery),
priceStartingValue: normalizePriceStartingValue(draft.priceStartingValue),
priceUnit: normalizePriceUnit(draft.priceUnit),
pricePeopleRange: draft.pricePeopleRange?.trim() || null,
priceDescription: draft.priceDescription?.trim() || null,
conciergeAdvisorId: draft.conciergeAdvisorId?.trim() || null,
isActive: draft.isActive ?? true,
sortOrder: draft.sortOrder,
...(draft.sortOrder === undefined ? {} : { sortOrder: draft.sortOrder }),
};
}
@@ -63,9 +71,21 @@ export function validateWanfaWrite(route: WanfaRouteCreate, detail: DetailCreate
return "请完整填写详情眉标、时长、标题、副标题和详情介绍。";
}
if (detail.gallery.some((image) => !isHttpUrl(image))) return "详情图片必须是有效的 HTTP(S) URL。";
if (detail.priceStartingValue !== null && detail.priceStartingValue !== undefined && !detail.priceUnit) {
return "配置价格起始值时请选择价格单位。";
}
return null;
}
function normalizePriceStartingValue(value: number | null | undefined) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return null;
return Math.max(0, Number(Number(value).toFixed(2)));
}
function normalizePriceUnit(value: DetailPriceUnit | null | undefined): DetailPriceUnit | null {
return value === "person" || value === "day" || value === "group" ? value : null;
}
function compactList(items: string[]) {
return items.map((item) => item.trim()).filter(Boolean);
}
+12
View File
@@ -604,6 +604,18 @@ input {
font-size: 12px;
line-height: 1.5;
}
.detail-price-heading {
display: grid;
gap: 4px;
margin: 22px 0 18px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.detail-price-heading span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.route-editor .el-alert {
margin-top: 18px;
}
+12 -1
View File
@@ -204,6 +204,10 @@ export type DetailRecord = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string | null;
priceDescription: string | null;
conciergeAdvisorId: string | null;
isActive: boolean;
sortOrder: number;
@@ -211,7 +215,14 @@ export type DetailRecord = {
updatedAt?: string;
};
export type DetailCreate = Omit<DetailRecord, "id" | "createdAt" | "updatedAt" | "sortOrder"> & { sortOrder?: number };
export type DetailPriceUnit = "person" | "day" | "group";
export type DetailPriceConfig = {
priceStartingValue: number | null;
priceUnit: DetailPriceUnit | null;
pricePeopleRange: string | null;
priceDescription: string | null;
};
export type DetailCreate = Omit<DetailRecord, "id" | "createdAt" | "updatedAt" | "sortOrder" | keyof DetailPriceConfig> & Partial<DetailPriceConfig> & { sortOrder?: number };
export type DetailPatch = Partial<DetailCreate>;
export type MediaAsset = {