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

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

View File

@@ -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="不配置管家" />

View File

@@ -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,

View File

@@ -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,
});

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);
}

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;
}

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 = {

View File

@@ -0,0 +1,64 @@
"""Add configurable price presentation fields to detail records."""
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from app.migration_compat import baseline_column_names
revision = "0037_detail_price_config"
down_revision = "0036_customer_browse_history"
branch_labels = None
depends_on = None
PRICE_COLUMNS = {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
def missing_price_columns(existing_columns: set[str]) -> set[str]:
return PRICE_COLUMNS.difference(existing_columns)
def is_offline_mode() -> bool:
try:
return context.is_offline_mode()
except (NameError, AttributeError):
return False
def upgrade() -> None:
if is_offline_mode():
columns_to_add = missing_price_columns(baseline_column_names("DetailRecord"))
else:
existing_columns = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}
columns_to_add = missing_price_columns(existing_columns)
definitions = {
"priceStartingValue": sa.Column("priceStartingValue", sa.Float(), nullable=True),
"priceUnit": sa.Column("priceUnit", sa.String(), nullable=True),
"pricePeopleRange": sa.Column("pricePeopleRange", sa.String(), nullable=True),
"priceDescription": sa.Column("priceDescription", sa.Text(), nullable=True),
}
for name, column in definitions.items():
if name in columns_to_add:
op.add_column("DetailRecord", column)
def downgrade() -> None:
if is_offline_mode():
columns_to_drop = PRICE_COLUMNS.intersection(baseline_column_names("DetailRecord"))
else:
columns_to_drop = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}.intersection(PRICE_COLUMNS)
for name in ("priceDescription", "pricePeopleRange", "priceUnit", "priceStartingValue"):
if name in columns_to_drop:
op.drop_column("DetailRecord", name)

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
@@ -273,6 +273,10 @@ class DetailRecord(AdminOwnedContent, Base):
excluded: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
notes: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
gallery: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
priceStartingValue: Mapped[float | None] = mapped_column(Float, nullable=True)
priceUnit: Mapped[str | None] = mapped_column(String, nullable=True)
pricePeopleRange: Mapped[str | None] = mapped_column(String, nullable=True)
priceDescription: Mapped[str | None] = mapped_column(Text, nullable=True)
conciergeAdvisorId: Mapped[str | None] = mapped_column(String, nullable=True)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)

View File

@@ -11,6 +11,7 @@ LeadType = Literal["general", "vehicle"]
VehicleServiceType = Literal["charter", "transfer"]
CharterDuration = Literal["halfDay", "fullDay"]
BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"]
PriceUnit = Literal["person", "day", "group"]
class LoginIn(BaseModel):
@@ -281,6 +282,13 @@ def normalize_detail_gallery(value: list[str]) -> list[str]:
return normalized
def normalize_optional_price_text(value: str | None) -> str | None:
if value is None:
return None
normalized = normalize_optional_text(value)
return normalized or None
class DetailCreate(BaseModel):
key: str = Field(min_length=1, max_length=120)
eyebrow: str = Field(min_length=1, max_length=80)
@@ -293,6 +301,10 @@ class DetailCreate(BaseModel):
excluded: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
gallery: list[str] = Field(default_factory=list)
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@@ -312,6 +324,11 @@ class DetailCreate(BaseModel):
def validate_detail_gallery(cls, value: list[str]) -> list[str]:
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_price_text(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_concierge_advisor_id(cls, value: str | None) -> str | None:
@@ -333,6 +350,10 @@ class DetailPatch(BaseModel):
excluded: list[str] | None = None
notes: list[str] | None = None
gallery: list[str] | None = None
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@@ -358,6 +379,11 @@ class DetailPatch(BaseModel):
raise ValueError("字段不能为空")
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_optional_price_text_fields(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_optional_concierge_advisor_id(cls, value: str | None) -> str | None:

View File

@@ -80,6 +80,10 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
"excluded": detail.excluded or [],
"notes": detail.notes or [],
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
"priceStartingValue": detail.priceStartingValue,
"priceUnit": detail.priceUnit,
"pricePeopleRange": detail.pricePeopleRange,
"priceDescription": detail.priceDescription,
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
}

View File

@@ -0,0 +1,28 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0037_detail_price_config.py"
SPEC = spec_from_file_location("detail_price_config_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_price_columns_are_added_only_when_missing():
assert MIGRATION.missing_price_columns(set()) == {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({"priceStartingValue", "priceUnit"}) == {
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}) == set()

View File

@@ -77,6 +77,10 @@ def make_detail(**overrides):
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"isActive": True,
"sortOrder": 0,
"conciergeAdvisorId": None,
@@ -124,11 +128,33 @@ def test_detail_schema_trims_text_and_array_items():
excluded=[" 往返大交通 "],
notes=[" 请准备雨具。 "],
gallery=[" https://example.test/family-water.jpg "],
priceStartingValue=1.68,
priceUnit="person",
pricePeopleRange=" 2-4人 ",
priceDescription=" 价格以最终确认方案为准。 ",
)
assert detail.key == "family-water"
assert detail.highlights == ["核心景观串联", "小团出行"]
assert detail.gallery == ["https://example.test/family-water.jpg"]
assert detail.priceStartingValue == 1.68
assert detail.priceUnit == "person"
assert detail.pricePeopleRange == "2-4人"
assert detail.priceDescription == "价格以最终确认方案为准。"
def test_detail_schema_rejects_unknown_price_unit():
with pytest.raises(ValidationError):
DetailCreate(
key="family-water",
eyebrow="玩法推荐",
duration="5天4晚",
title="亲子玩水",
subtitle="贵州路线",
intro="介绍",
priceStartingValue=1.68,
priceUnit="month",
)
def test_detail_schema_rejects_invalid_gallery_url():
@@ -188,6 +214,10 @@ def test_public_detail_returns_frontend_contract_without_auth():
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"conciergeAdvisor": None,
},
}
@@ -294,6 +324,10 @@ def test_admin_detail_create_normalizes_key_and_appends():
"excluded": [],
"notes": [],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
},
)
finally:
@@ -301,6 +335,8 @@ def test_admin_detail_create_normalizes_key_and_appends():
assert response.status_code == 201
assert response.json()["data"]["key"] == "family-water"
assert fake_db.added[0].priceStartingValue == 1.68
assert fake_db.added[0].priceUnit == "person"
assert fake_db.added[0].sortOrder == 3

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;
};

View File

@@ -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: [];

View File

@@ -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,
};
}

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 "";

View File

@@ -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,
});
});

View File

@@ -6,7 +6,7 @@
管家顾问资料管理的字段、图片、排序与删除约束见 [concierge-api.md](./concierge-api.md)。该文档是本主契约的管家领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
详情展示内容的字段、图片、排序与商品领域隔离约束见 [detail-api.md](./detail-api.md)。该文档是本主契约的详情领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
详情展示内容及路线参考价格字段、图片、排序与商品领域隔离约束见 [detail-api.md](./detail-api.md)。该文档是本主契约的详情领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
首页和用车站点模块的字段、单例、排序与删除约束见 [module-config-api.md](./module-config-api.md)。

View File

@@ -11,12 +11,14 @@
- 详情页识别键、标题眉标、出行时长和标题文案。
- 详情介绍、行程亮点、费用包含、费用不含和注意事项。
- 详情页图片画廊及图片顺序。
- 详情页参考价格:价格起始值、单位、适用人数范围和价格说明。
- 详情页可选的联系管家顾问 ID;顾问资料仍由管家领域维护。
- 启用状态和详情列表顺序。
本接口不负责:
- 商品、商品价格、商品库存或商品详情表。
- 订单或预订价格计算;本接口中的价格仅用于路线详情展示。
- Product、ProductImage 或任何商品外键。
- 订单、预订、收藏、评价或线索。
- 管家顾问的头像、二维码、服务详情和管家 CRUD;详情只保存顾问 ID。
@@ -30,6 +32,7 @@
`detailPresentation.ts` 是前台展示适配器,不是持久化模型:
- `eyebrow`、`duration`、`title`、`subtitle`、`intro`、`highlights`、`included`、`excluded`、`notes`、`gallery` 组成最终展示对象。
- `priceStartingValue`、`priceUnit`、`pricePeopleRange`、`priceDescription` 组成可选的参考价格展示对象。
- 当前实现优先消费 Public API 返回的最终展示字段。
- 接口失败、字段不完整或详情未配置时,MiniAPP 按路线 ID 使用本地网络图片和模拟文案兜底。
@@ -76,6 +79,10 @@ type DetailPresentation = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string;
priceDescription: string;
};
type DetailRecord = DetailPresentation & {
@@ -101,8 +108,12 @@ type PublicConciergeAdvisor = {
qrImage: string;
};
type DetailCreate = DetailPresentation & {
type DetailCreate = Omit<DetailPresentation, "priceStartingValue" | "priceUnit" | "pricePeopleRange" | "priceDescription"> & {
key: string;
priceStartingValue?: number | null;
priceUnit?: "person" | "day" | "group" | null;
pricePeopleRange?: string | null;
priceDescription?: string | null;
conciergeAdvisorId?: string | null;
isActive?: boolean;
sortOrder?: number;
@@ -135,6 +146,10 @@ type DetailReorderRequest = {
| `excluded` | `string[]` | 是 | “费用不含”列表,保留数组顺序。 |
| `notes` | `string[]` | 是 | “注意事项”列表,保留数组顺序。 |
| `gallery` | `string[]` | 是 | 详情图片 URL 列表,按展示顺序返回;建议最多 6 张以匹配当前前台逻辑。 |
| `priceStartingValue` | `number \| null` | 否 | 参考起始值,单位为万元;未配置时为 `null`,不参与订单或报价计算。 |
| `priceUnit` | `"person" \| "day" \| "group" \| null` | 否 | 价格展示单位,分别对应“人”“天”“团”;未配置价格时为 `null`。 |
| `pricePeopleRange` | `string` | 否 | 适用人数范围,例如“2-4人”;仅用于展示。 |
| `priceDescription` | `string` | 否 | 价格补充说明,例如“价格以最终确认方案为准”。 |
| `conciergeAdvisorId` | `string \| null` | 否 | 关联的管家顾问 ID;不建立数据库外键,空字符串保存为 `null`。 |
| `isActive` | `boolean` | 响应必填 | 是否进入已发布前台内容,创建默认 `true`。 |
| `sortOrder` | `number` | 响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 |
@@ -173,6 +188,10 @@ Authorization: Bearer <admin-jwt>
"excluded": ["往返大交通及个人消费"],
"notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"],
"gallery": ["https://example.test/assets/detail-01.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"isActive": true,
"sortOrder": 0,
"createdAt": "2026-01-01T00:00:00Z",
@@ -303,13 +322,14 @@ POST /api/admin/media-assets/upload
Admin UI 应按以下方式调用:
1. 玩法页加载时同时调用玩法分类、`GET /api/admin/details` 和 `GET /api/admin/concierge/advisors`;详情编辑器嵌入现有路线编辑抽屉,不新增侧边菜单。
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍、价格配置和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
3. `highlights`、`included`、`excluded`、`notes` 使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。
4. 使用图片上传接口维护 `gallery`,支持新增、删除和调整图片顺序。
5. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
6. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
8. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单、价格或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
5. 在详情图片后维护价格起始值、价格单位、适用人数范围和价格说明;价格未配置时提交空值,不能写入前台固定价格。
6. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
7. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
8. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
9. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
建议的 Admin UI API 封装函数:
@@ -339,6 +359,10 @@ const presentation: DetailPresentation = {
excluded: record.excluded,
notes: record.notes,
gallery: record.gallery,
priceStartingValue: publicDetail.priceStartingValue,
priceUnit: publicDetail.priceUnit,
pricePeopleRange: publicDetail.pricePeopleRange,
priceDescription: publicDetail.priceDescription,
conciergeAdvisor: publicDetail.conciergeAdvisor ?? null,
};
```

View File

@@ -182,6 +182,10 @@ type PublicDetail = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string;
priceDescription: string;
conciergeAdvisor: {
avatar: string;
name: string;