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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user