From feec6ab80f687d7f69c8ad54100b65f868b32852 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 27 Aug 2026 22:02:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(wanfa):=20=E6=96=B0=E5=A2=9E=E8=B7=AF?= =?UTF-8?q?=E7=BA=BF=E8=AF=A6=E6=83=85=E5=8F=82=E8=80=83=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8F=8A=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义 --- .../src/components/wanfa/DetailFields.vue | 23 +++++++ .../src/components/wanfa/WanfaRouteEditor.vue | 4 ++ WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts | 8 +++ WonderQ-Admin-UI-Vue/src/lib/wanfa.ts | 24 ++++++- WonderQ-Admin-UI-Vue/src/styles.css | 12 ++++ WonderQ-Admin-UI-Vue/src/types.ts | 13 +++- .../versions/0037_detail_price_config.py | 64 +++++++++++++++++++ WonderQ-Admin/app/models.py | 6 +- WonderQ-Admin/app/schemas.py | 26 ++++++++ WonderQ-Admin/app/serializers.py | 4 ++ .../test_detail_price_config_migration.py | 28 ++++++++ WonderQ-Admin/tests/test_details.py | 36 +++++++++++ WonderQ-MiniAPP/src/lib/types.ts | 4 ++ .../detail/components/DetailActionBar.vue | 20 ++++-- .../detail/components/detailPresentation.ts | 25 ++++++++ WonderQ-MiniAPP/src/pages/detail/index.vue | 21 +++++- WonderQ-MiniAPP/tests/detail-data.test.ts | 8 +++ docs/admin-api-requirements.md | 2 +- docs/detail-api.md | 36 +++++++++-- docs/public-api.md | 4 ++ 20 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 WonderQ-Admin/alembic/versions/0037_detail_price_config.py create mode 100644 WonderQ-Admin/tests/test_detail_price_config_migration.py diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue index 1b2779b..e001298 100644 --- a/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue @@ -35,6 +35,29 @@ function updateLines(field: "highlights" | "included" | "excluded" | "notes" | " +
+ 路线价格 + 配置后将在前台路线详情中展示参考起价。 +
+
+ + + + + + + + + + + +
+ + + + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue index 637aad1..976bf79 100644 --- a/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue @@ -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, diff --git a/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts b/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts index 01a5ce0..167957f 100644 --- a/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts +++ b/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts @@ -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, }); diff --git a/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts b/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts index 90e523c..901a4bc 100644 --- a/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts +++ b/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts @@ -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); } diff --git a/WonderQ-Admin-UI-Vue/src/styles.css b/WonderQ-Admin-UI-Vue/src/styles.css index 2c387ae..7a5f552 100644 --- a/WonderQ-Admin-UI-Vue/src/styles.css +++ b/WonderQ-Admin-UI-Vue/src/styles.css @@ -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; } diff --git a/WonderQ-Admin-UI-Vue/src/types.ts b/WonderQ-Admin-UI-Vue/src/types.ts index fb46046..a32985e 100644 --- a/WonderQ-Admin-UI-Vue/src/types.ts +++ b/WonderQ-Admin-UI-Vue/src/types.ts @@ -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 & { 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 & Partial & { sortOrder?: number }; export type DetailPatch = Partial; export type MediaAsset = { diff --git a/WonderQ-Admin/alembic/versions/0037_detail_price_config.py b/WonderQ-Admin/alembic/versions/0037_detail_price_config.py new file mode 100644 index 0000000..ab9658c --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0037_detail_price_config.py @@ -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) diff --git a/WonderQ-Admin/app/models.py b/WonderQ-Admin/app/models.py index 52ca5e1..e41dbed 100644 --- a/WonderQ-Admin/app/models.py +++ b/WonderQ-Admin/app/models.py @@ -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) diff --git a/WonderQ-Admin/app/schemas.py b/WonderQ-Admin/app/schemas.py index c99b4ff..60035ee 100644 --- a/WonderQ-Admin/app/schemas.py +++ b/WonderQ-Admin/app/schemas.py @@ -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: diff --git a/WonderQ-Admin/app/serializers.py b/WonderQ-Admin/app/serializers.py index 3454ab7..dab061f 100644 --- a/WonderQ-Admin/app/serializers.py +++ b/WonderQ-Admin/app/serializers.py @@ -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, } diff --git a/WonderQ-Admin/tests/test_detail_price_config_migration.py b/WonderQ-Admin/tests/test_detail_price_config_migration.py new file mode 100644 index 0000000..d1ea8b9 --- /dev/null +++ b/WonderQ-Admin/tests/test_detail_price_config_migration.py @@ -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() diff --git a/WonderQ-Admin/tests/test_details.py b/WonderQ-Admin/tests/test_details.py index deb01ad..421647d 100644 --- a/WonderQ-Admin/tests/test_details.py +++ b/WonderQ-Admin/tests/test_details.py @@ -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 diff --git a/WonderQ-MiniAPP/src/lib/types.ts b/WonderQ-MiniAPP/src/lib/types.ts index bc9ee06..21070c5 100644 --- a/WonderQ-MiniAPP/src/lib/types.ts +++ b/WonderQ-MiniAPP/src/lib/types.ts @@ -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; }; diff --git a/WonderQ-MiniAPP/src/pages/detail/components/DetailActionBar.vue b/WonderQ-MiniAPP/src/pages/detail/components/DetailActionBar.vue index e6bac3c..9037a34 100644 --- a/WonderQ-MiniAPP/src/pages/detail/components/DetailActionBar.vue +++ b/WonderQ-MiniAPP/src/pages/detail/components/DetailActionBar.vue @@ -24,8 +24,9 @@ 参考起价 - ¥{{ - price }}万 + ¥{{ + formatWan(props.priceStartingValue) }}万{{ priceUnitSuffix }} + 价格待确认