feat(hotel groups): add price, tags and publish status support

extend HotelGroup type with new fields including coverImage, priceAmount, priceUnit, tags and draft/published status. refactor admin utilities to separate hotel group and vehicle option handling, add proper payload normalization for hotel group submissions including syncing coverImage and image fields. update admin UI components to support hotel group-specific configurations, including default "起/晚" price unit, trimmed and limited tags, and publish status toggle. update admin documentation to reflect new hotel group API, split hotel groups docs from vehicle options, and document all new fields.
This commit is contained in:
duanshuwen
2026-07-03 22:45:16 +08:00
parent 86a12dd1a0
commit f7401d8dc6
4 changed files with 137 additions and 40 deletions

View File

@@ -108,6 +108,11 @@ export type HotelGroup = {
title: string;
description: string | null;
image: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
status?: "draft" | "published";
isActive: boolean;
sortOrder: number;
createdAt?: string;

View File

@@ -180,7 +180,21 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
if (moduleId === "routeSections") {
return { title: "", subtitle: "", productIds: [], isActive: true, sortOrder };
}
if (moduleId === "hotelGroups" || moduleId === "vehicleOptions") {
if (moduleId === "hotelGroups") {
return {
title: "",
description: "",
image: "",
coverImage: "",
priceAmount: null,
priceUnit: "起/晚",
tags: [],
status: "published",
isActive: true,
sortOrder,
};
}
if (moduleId === "vehicleOptions") {
return { title: "", description: "", image: "", isActive: true, sortOrder };
}
return { alt: "", image: "", isActive: false, sortOrder };
@@ -236,7 +250,25 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "hotelGroups" || moduleId === "vehicleOptions") {
if (moduleId === "hotelGroups") {
const coverImage = draft.coverImage?.trim() || draft.image?.trim() || null;
const isActive = draft.status ? draft.status === "published" : draft.isActive ?? true;
payload.title = draft.title?.trim();
payload.description = draft.description?.trim() || null;
payload.image = coverImage;
payload.coverImage = coverImage;
payload.priceAmount = draft.priceAmount === null || draft.priceAmount === undefined ? null : Number(draft.priceAmount);
payload.priceUnit = draft.priceUnit?.trim() || "起/晚";
payload.tags = (draft.tags ?? []).map((tag) => tag.trim()).filter(Boolean).slice(0, 3);
payload.status = isActive ? "published" : "draft";
payload.isActive = isActive;
payload.productIds = undefined;
payload.kicker = undefined;
payload.subtitle = undefined;
payload.targetType = undefined;
payload.targetValue = undefined;
}
if (moduleId === "vehicleOptions") {
payload.title = draft.title?.trim();
payload.description = draft.description?.trim() || null;
payload.productIds = undefined;

View File

@@ -276,6 +276,22 @@ export function StructurePage({
.join(" ");
const setDraftFromItem = (item: EditableSiteItem) => {
const imageValue = "image" in item ? item.image || "" : "";
const coverImageValue =
"coverImage" in item
? item.coverImage || imageValue
: moduleId === "hotelGroups"
? imageValue
: undefined;
const activeStatus =
"status" in item && (item.status === "draft" || item.status === "published")
? item.status
: moduleId === "hotelGroups"
? "isActive" in item && item.isActive === false
? "draft"
: "published"
: undefined;
setDraft({
title: "title" in item ? item.title : undefined,
kicker: "kicker" in item ? item.kicker || "" : undefined,
@@ -284,16 +300,16 @@ export function StructurePage({
region: "region" in item ? item.region || "" : undefined,
label: "label" in item ? item.label : undefined,
alt: "alt" in item ? item.alt : undefined,
image: "image" in item ? item.image || "" : "",
image: imageValue,
description: "description" in item ? item.description || "" : undefined,
coverImage: "coverImage" in item ? item.coverImage || "" : undefined,
coverImage: coverImageValue,
priceAmount: "priceAmount" in item ? item.priceAmount : undefined,
priceUnit: "priceUnit" in item ? item.priceUnit || "起/人" : undefined,
tags: "tags" in item ? item.tags.slice(0, 3) : undefined,
priceUnit: "priceUnit" in item ? item.priceUnit || (moduleId === "hotelGroups" ? "起/晚" : "起/人") : moduleId === "hotelGroups" ? "起/晚" : undefined,
tags: "tags" in item && Array.isArray(item.tags) ? item.tags.slice(0, 3) : moduleId === "hotelGroups" ? [] : undefined,
isHot: "isHot" in item ? item.isHot : undefined,
isActive: "isActive" in item ? item.isActive : undefined,
isActive: "isActive" in item ? item.isActive : moduleId === "hotelGroups" ? activeStatus === "published" : undefined,
sortOrder: itemSortOrder(item),
status: "status" in item && (item.status === "draft" || item.status === "published") ? item.status : undefined,
status: activeStatus,
startsAt: "startsAt" in item ? item.startsAt || "" : undefined,
endsAt: "endsAt" in item ? item.endsAt || "" : undefined,
});
@@ -943,16 +959,22 @@ function SiteItemEditor({
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
const isImageOnlyModule = moduleId === "map";
const isCampaignModule = moduleId === "campaigns";
const isHotelModule = moduleId === "hotelGroups";
const usesOfferConfig = isCampaignModule || isHotelModule;
const isMoreServicesModule = moduleId === "ctaBanners";
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
const isCardContentModule = moduleId === "vehicleOptions";
const editorHint = isImageOnlyModule
? isCreating
? `上传后会作为「${moduleLabel}」唯一展示图片`
: `保存后会替换「${moduleLabel}」前台展示图片`
: isCampaignModule
? isCreating
? `创建后会进入「${moduleLabel}」的活动列表`
: `保存后会影响${moduleLabel}」活动入口`
: usesOfferConfig
? isCampaignModule
? isCreating
? `创建后会进入${moduleLabel}活动列表`
: `保存后会影响「${moduleLabel}」活动入口`
: isCreating
? `创建后会进入「${moduleLabel}」的数据列表`
: `保存后会影响「${moduleLabel}」首页卡片`
: isMoreServicesModule
? isCreating
? "创建后会进入首页「更多服务」卡片列表"
@@ -980,7 +1002,7 @@ function SiteItemEditor({
<div className="admin-disclosure-stack">
{!isImageOnlyModule ? (
<AdminDisclosure title="展示内容">
{isCampaignModule ? (
{usesOfferConfig ? (
<>
<label>
{primaryLabel}
@@ -992,7 +1014,7 @@ function SiteItemEditor({
/>
</label>
<label>
{isHotelModule ? "酒店描述" : "活动描述"}
<Textarea
value={draft.description ?? ""}
onChange={(event) =>
@@ -1071,7 +1093,7 @@ function SiteItemEditor({
</div>
</AdminDisclosure>
) : null}
{isCampaignModule ? (
{usesOfferConfig ? (
<AdminDisclosure title="价格信息">
<div className="form-grid compact-grid">
<label>
@@ -1092,7 +1114,7 @@ function SiteItemEditor({
<label>
<Input
value={draft.priceUnit ?? "起/人"}
value={draft.priceUnit ?? (isHotelModule ? "起/晚" : "起/人")}
onChange={(event) =>
onDraft({ ...draft, priceUnit: event.target.value })
}
@@ -1101,34 +1123,40 @@ function SiteItemEditor({
</div>
</AdminDisclosure>
) : null}
{isCampaignModule ? (
<AdminDisclosure title="活动标签">
{usesOfferConfig ? (
<AdminDisclosure title={isHotelModule ? "酒店标签" : "活动标签"}>
<CampaignTagEditor
tags={draft.tags ?? []}
onChange={(tags) => onDraft({ ...draft, tags })}
/>
</AdminDisclosure>
) : null}
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : isMoreServicesModule ? "服务卡片背景图" : "资源图片"}>
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : usesOfferConfig ? isHotelModule ? "酒店封面图" : "活动封面图" : isMoreServicesModule ? "服务卡片背景图" : "资源图片"}>
<SingleImageUploader
value={isCampaignModule ? draft.coverImage : draft.image}
value={usesOfferConfig ? draft.coverImage ?? draft.image : draft.image}
onChange={(image) =>
onDraft(
isCampaignModule
? { ...draft, coverImage: image }
: { ...draft, image },
: isHotelModule
? { ...draft, coverImage: image, image }
: { ...draft, image },
)
}
group={moduleId}
/>
</AdminDisclosure>
<AdminDisclosure title={isImageOnlyModule || isCampaignModule ? "显示状态" : "排序与状态"}>
{isCampaignModule ? (
<AdminDisclosure title={isImageOnlyModule || usesOfferConfig ? "显示状态" : "排序与状态"}>
{usesOfferConfig ? (
<label className="switch-line">
<Switch
checked={draft.status === "published"}
checked={draft.status === "published" || (isHotelModule && !draft.status && Boolean(draft.isActive))}
onCheckedChange={(checked) =>
onDraft({ ...draft, status: checked ? "published" : "draft" })
onDraft(
isHotelModule
? { ...draft, status: checked ? "published" : "draft", isActive: checked }
: { ...draft, status: checked ? "published" : "draft" },
)
}
/>