diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md index 8fd5e3e..51e2845 100644 --- a/docs/admin-module-config-api.md +++ b/docs/admin-module-config-api.md @@ -14,7 +14,7 @@ | `themes` | 主题甄选 | 首页主题卡片和跳转 | | `campaigns` | 特价优惠 | 首页特价优惠活动元信息 | | `routeSections` | 精选线路子分组 | 首页“精选线路”按运营任务新增分组,维护标题、副文案、启用状态和关联商品 | -| `hotelGroups` | 特色酒店 | 首页“特色酒店”卡片,维护标题、描述、封面图、启用状态和排序 | +| `hotelGroups` | 特色酒店 | 首页“特色酒店”卡片,维护标题、描述、价格、标签、封面图、启用状态和排序 | | `vehicleOptions` | 万趣用车 | 首页“万趣用车”卡片,维护标题、描述、封面图、启用状态和排序 | | `ctaBanners` | 更多服务 | 权益、管家、目的地和需求入口等更多服务卡片 | @@ -171,6 +171,14 @@ type SiteCardItem = { updatedAt?: string; }; +type HotelGroupItem = SiteCardItem & { + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags?: string[]; + status?: "draft" | "published"; +}; + type CtaBanner = { id: string; alt: string; @@ -194,7 +202,7 @@ type CtaBanner = { | `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | | `campaigns` | `title`、`slug` | `description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`startsAt`、`endsAt` | | `routeSections` | `title` | `subtitle`、`productIds`、`isActive`、`sortOrder` | -| `hotelGroups` | `title` | `description`、`image`、`isActive`、`sortOrder` | +| `hotelGroups` | `title` | `description`、`image`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`isActive`、`sortOrder` | | `vehicleOptions` | `title` | `description`、`image`、`isActive`、`sortOrder` | | `ctaBanners` | `alt`(服务标题) | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | @@ -518,36 +526,60 @@ PATCH /api/admin/site-config/routeSections/reorder - 同一商品不能出现在其他精选线路子分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。 - `DELETE /api/admin/site-config/routeSections/:id` 删除分组配置并返回被删除 ID;只移除首页分组,不删除关联商品本体。 -## 特色酒店和万趣用车 `hotelGroups` / `vehicleOptions` 专用契约 +## 特色酒店 `hotelGroups` 专用契约 -`hotelGroups` 对应首页“特色酒店”卡片,`vehicleOptions` 对应首页“万趣用车”卡片。两者都是普通首页内容卡片,不维护商品详情和商品关联。 +`hotelGroups` 对应首页“特色酒店”卡片。它复用“特价优惠”的数据配置体验,支持新增、编辑、删除、排序,并维护标题、描述、价格、标签、封面图、启用状态;不维护商品详情和商品关联。 字段语义: | 字段 | 类型 | 创建 | 更新 | 说明 | | --- | --- | --- | --- | --- | -| `id` | `string` | 后端生成 | 不允许修改 | 卡片唯一 id | -| `title` | `string` | 必填 | 可选 | 卡片标题,提交时 trim 后不能为空 | -| `description` | `string \| null` | 可选 | 可选 | 卡片描述,空字符串可归一化为 `null` | -| `image` | `string \| null` | 可选 | 可选 | 卡片封面图 OSS URL;上传仍走媒体接口 | -| `isActive` | `boolean` | 可选 | 可选 | 用户侧是否展示;未传默认 `true` | -| `sortOrder` | `number` | 可选 | 可选 | 展示顺序;未传时追加到模块末尾 | - -两类模块均复用通用接口: +| `id` | `string` | 后端生成 | 不允许修改 | 酒店卡片配置项 ID | +| `title` | `string` | 必填 | 可选 | 酒店卡片标题,保存时去掉首尾空格,不能为空 | +| `description` | `string \| null` | 可选 | 可选 | 酒店卡片描述,空字符串可归一为 `null` | +| `image` | `string \| null` | 可选 | 可选 | 兼容旧字段;后端应与 `coverImage` 保持一致 | +| `coverImage` | `string \| null` | 可选 | 可选 | 酒店封面图主字段,管理端上传组件写入该字段 | +| `priceAmount` | `number \| null` | 可选 | 可选 | 价格数值 | +| `priceUnit` | `string \| null` | 可选 | 可选 | 价格单位文案,默认建议 `起/晚` | +| `tags` | `string[]` | 可选 | 可选 | 标签数组,最多 3 个,保存时去掉空标签 | +| `status` | `"draft" \| "published"` | 可选 | 可选 | 发布状态;管理端“前台启用”开关同步维护该字段 | +| `isActive` | `boolean` | 可选 | 可选 | 前台启用状态;Public API 只返回 `status="published"` 且 `isActive=true` 的项 | +| `sortOrder` | `number` | 可选 | 可选 | 首页展示顺序 | ```http POST /api/admin/site-config/hotelGroups PATCH /api/admin/site-config/hotelGroups/:id DELETE /api/admin/site-config/hotelGroups/:id PATCH /api/admin/site-config/hotelGroups/reorder +``` +删除只删除首页酒店卡片配置,不删除任何商品、目的地或素材库资源。Public API 只返回 `published` 且启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。 + +## 万趣用车 `vehicleOptions` 专用契约 + +`vehicleOptions` 对应首页“万趣用车”卡片,仍是普通首页内容卡片,不维护商品详情和商品关联。 + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| --- | --- | --- | --- | --- | +| `id` | `string` | 后端生成 | 不允许修改 | 用车卡片配置项 ID | +| `title` | `string` | 必填 | 可选 | 卡片标题,保存时去掉首尾空格,不能为空 | +| `description` | `string \| null` | 可选 | 可选 | 卡片描述,空字符串可归一为 `null` | +| `image` | `string \| null` | 可选 | 可选 | 卡片封面图 URL | +| `isActive` | `boolean` | 可选 | 可选 | 前台启用状态 | +| `sortOrder` | `number` | 可选 | 可选 | 首页展示顺序 | +| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | +| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | + +```http POST /api/admin/site-config/vehicleOptions PATCH /api/admin/site-config/vehicleOptions/:id DELETE /api/admin/site-config/vehicleOptions/:id PATCH /api/admin/site-config/vehicleOptions/reorder ``` -删除只删除首页卡片配置,不删除任何商品、目的地或素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。 +删除只删除首页用车卡片配置,不删除任何商品、目的地或素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。 ## 更多服务 `ctaBanners` 专用契约 @@ -596,7 +628,7 @@ type SiteConfig = { themes: ThemeCard[]; campaigns: Campaign[]; routeSections: RouteSection[]; - hotelGroups: SiteCardItem[]; + hotelGroups: HotelGroupItem[]; vehicleOptions: SiteCardItem[]; ctaBanners: CtaBanner[]; }; diff --git a/src/api.ts b/src/api.ts index b2841e4..3fc7ab1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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; diff --git a/src/lib/admin-utils.ts b/src/lib/admin-utils.ts index b8df100..cb429d1 100644 --- a/src/lib/admin-utils.ts +++ b/src/lib/admin-utils.ts @@ -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; diff --git a/src/pages/structure/StructurePage.tsx b/src/pages/structure/StructurePage.tsx index 6c03833..e34ddef 100644 --- a/src/pages/structure/StructurePage.tsx +++ b/src/pages/structure/StructurePage.tsx @@ -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({
{!isImageOnlyModule ? ( - {isCampaignModule ? ( + {usesOfferConfig ? ( <>