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 }}
+ 价格待确认