feat(wanfa): 新增路线详情参考价格配置及展示功能
- 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义
This commit is contained in:
@@ -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 = {
|
||||
|
||||
64
WonderQ-Admin/alembic/versions/0037_detail_price_config.py
Normal file
64
WonderQ-Admin/alembic/versions/0037_detail_price_config.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
28
WonderQ-Admin/tests/test_detail_price_config_migration.py
Normal file
28
WonderQ-Admin/tests/test_detail_price_config_migration.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user