diff --git a/docs/README.md b/docs/README.md index 78b3844..adfca04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,6 @@ 本目录存放后台管理前端相关文档。 - `admin-backend-plan.md`:从原 MiniAPP 迁出的后台管理规划,保留管理模块、页面能力和与后端 API 的协作边界。 -- `admin-module-config-api.md`:页面模块配置 CRUD 接口契约,供 WonderQ-Admin 后端实现首页轮播、目的地、贵州地图、主题卡和 CTA 等模块配置接口。 +- `admin-module-config-api.md`:页面模块配置 CRUD 接口契约,供 WonderQ-Admin 后端实现首页轮播、目的地、贵州地图、主题卡、特价优惠和 CTA 等模块配置接口。 后端 API 文档位于 `D:\www\znkj\WonderQ-Admin\docs`。 diff --git a/docs/admin-backend-plan.md b/docs/admin-backend-plan.md index b3999ff..701b962 100644 --- a/docs/admin-backend-plan.md +++ b/docs/admin-backend-plan.md @@ -238,7 +238,7 @@ leads ( - `CRUD /api/admin/products` - `CRUD /api/admin/destinations` - `CRUD /api/admin/campaigns` -- `CRUD /api/admin/site-config/:module`:页面模块配置,覆盖 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners` +- `CRUD /api/admin/site-config/:module`:页面模块配置,覆盖 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`ctaBanners` - `CRUD /api/admin/media-assets` - `GET /api/admin/leads` - `PATCH /api/admin/leads/:id/status` @@ -257,7 +257,7 @@ leads ( - 新建 `src/api/client.ts` 和 `src/api/types.ts`。 - 新建 `src/adapters/siteConfig.ts`,把接口数据转成当前组件需要的结构。 - 保留本地 JSON fallback,便于本地开发和接口故障降级。 -- 把 `heroSlides`、`destinations`、`map`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。 +- 把 `heroSlides`、`destinations`、`map`、`themeCards`、`campaigns`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。 ### 第二步:产品和目的地接口化 diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md index 7ce72e9..6be9400 100644 --- a/docs/admin-module-config-api.md +++ b/docs/admin-module-config-api.md @@ -1,6 +1,6 @@ # 页面模块配置 Admin API 契约 -本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片和底部运营入口。 +本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠和底部运营入口。 ## 适用模块 @@ -12,6 +12,7 @@ | `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 | | `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 | | `themes` | 主题甄选 | 首页主题卡片和跳转 | +| `campaigns` | 特价优惠 | 首页特价优惠活动元信息 | | `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA | 商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。 @@ -37,18 +38,20 @@ ## 后端实现重点 -WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 作为正式模块接入,而不是只在前端展示: +WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示: -- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`。 -- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。 +- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`ctaBanners`。 +- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map` 和 `campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。 - `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。 +- `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。 - `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。 +- `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。 - 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。 ## 类型定义 ```ts -type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners"; +type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners"; type SiteItemPatch = { title?: string; @@ -59,11 +62,16 @@ type SiteItemPatch = { label?: string; alt?: string; image?: string | null; + description?: string | null; + coverImage?: string | null; targetType?: string | null; targetValue?: string | null; isHot?: boolean; isActive?: boolean; sortOrder?: number; + status?: "draft" | "published"; + startsAt?: string | null; + endsAt?: string | null; }; type HeroSlide = { @@ -101,6 +109,37 @@ type MapImageCreateInput = { }; type MapImageUpdateInput = Partial; + +type Campaign = { + id: string; + slug: string; + title: string; + description: string | null; + coverImage: string | null; + priceAmount: number | null; + priceUnit: string | null; + tags: string[]; + status: "draft" | "published"; + startsAt: string | null; + endsAt: string | null; + createdAt?: string; + updatedAt?: string; +}; + +type CampaignCreateInput = { + title: string; + slug: string; + description?: string | null; + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags?: string[]; + status?: "draft" | "published"; + startsAt?: string | null; + endsAt?: string | null; +}; + +type CampaignUpdateInput = Partial; ``` 各模块字段要求: @@ -111,9 +150,12 @@ type MapImageUpdateInput = Partial; | `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` | | `map` | `image` | `isActive` | | `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | +| `campaigns` | `title`、`slug` | `description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`startsAt`、`endsAt` | | `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | 后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。 +`campaigns` 创建时默认 `status="draft"`,不会进入 Public `site-config.campaigns`;只有 `status="published"` 的活动会进入 H5 Public API。 +Admin UI 面向运营只展示活动标题、活动描述、封面图和前台启用状态;`slug` 是接口必填技术标识,前端可在新建时自动生成,编辑时复用原值。 ## 顶部轮播 `heroSlides` 专用契约 @@ -310,9 +352,71 @@ PATCH /api/admin/site-config/map/:id DELETE /api/admin/site-config/map/:id ``` +## 特价优惠 `campaigns` 专用契约 + +特价优惠模块对应 H5 Public API 的 `site-config.campaigns`,只维护活动元信息,不维护活动商品关联列表。 + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| --- | --- | --- | --- | --- | +| `id` | `string` | 后端生成 | 不允许修改 | 活动唯一 id | +| `slug` | `string` | 必填 | 可选 | 活动标识,需要全局唯一 | +| `title` | `string` | 必填 | 可选 | 活动标题,后台列表主标题 | +| `description` | `string \| null` | 可选 | 可选 | 活动描述,后台列表副文案 | +| `coverImage` | `string \| null` | 可选 | 可选 | 活动封面图 OSS URL | +| `priceAmount` | `number \| null` | 可选 | 可选 | 参考起价,单位按 `priceUnit` 展示 | +| `priceUnit` | `string \| null` | 可选 | 可选 | 价格单位文案,默认 `起/人` | +| `tags` | `string[]` | 可选 | 可选 | 活动卡片标签,最多 3 个 | +| `status` | `"draft" \| "published"` | 可选 | 可选 | 新建默认 `draft`;Public API 只返回 `published` | +| `startsAt` | `string \| null` | 可选 | 可选 | 活动开始时间 | +| `endsAt` | `string \| null` | 可选 | 可选 | 活动结束时间 | +| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | +| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | + +### 特价优惠 CRUD + +新增特价优惠: + +```http +POST /api/admin/site-config/campaigns +``` + +请求体: + +```json +{ + "slug": "classic-deal", + "title": "经典打卡特惠", + "description": "经典首游活动", + "coverImage": "https://bucket.oss-cn-example.aliyuncs.com/admin/campaigns/2026/07/02/classic.webp", + "priceAmount": 162500, + "priceUnit": "起/人", + "tags": ["臻藏旅位", "赛事庆典"], + "status": "draft", + "startsAt": null, + "endsAt": null +} +``` + +更新特价优惠: + +```http +PATCH /api/admin/site-config/campaigns/:id +``` + +删除特价优惠: + +```http +DELETE /api/admin/site-config/campaigns/:id +``` + +特价优惠不定义 `sortOrder`、`isActive`、`targetType` 和 `targetValue`;兼容期如果请求体携带这些字段,后端可以忽略,但不要写入 `Campaign` 业务数据。 +`tags` 保存前需要 trim 并过滤空字符串;有效标签超过 3 个时返回 `422 MODULE_CONFIG_VALIDATION_ERROR`,`details` 为 `{ "field": "tags", "max": 3 }`。 + ## 接口列表 -以下路径由五类页面模块复用;`heroSlides`、`map` 的请求体和响应体以各自专用契约为准。 +以下路径由六类页面模块复用;`heroSlides`、`map`、`campaigns` 的请求体和响应体以各自专用契约为准。 ### 获取完整站点配置 @@ -328,11 +432,13 @@ type SiteConfig = { destinations: Destination[]; map: MapImage[]; themes: ThemeCard[]; + campaigns: Campaign[]; ctaBanners: CtaBanner[]; }; ``` `map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 +`campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 ### 新增模块配置项 @@ -398,7 +504,7 @@ PATCH /api/admin/site-config/:module/reorder - 不允许混入其他模块 id。 - 后端按数组顺序写入 `sortOrder`,从 0 开始。 -`map` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。 +`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。 响应状态码 `200`: @@ -441,7 +547,7 @@ WonderQ-Admin-UI 当前调用函数位于 `src/api.ts`: ## 图片素材上传 -用于 WonderQ-Admin-UI 在维护页面模块图片时上传本地图片,并把返回的 OSS URL 写入 `image` 字段。 +用于 WonderQ-Admin-UI 在维护页面模块图片时上传本地图片,并把返回的 OSS URL 写入对应模块图片字段,例如 `image` 或 `coverImage`。 ```http POST /api/admin/media-assets/upload diff --git a/src/api.ts b/src/api.ts index 6f36f76..094b9c6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -76,15 +76,32 @@ export type MediaAsset = { updatedAt?: string; }; +export type Campaign = { + id: string; + slug: string; + title: string; + description: string | null; + coverImage: string | null; + priceAmount: number | null; + priceUnit: string | null; + tags: string[]; + status: "draft" | "published"; + startsAt: string | null; + endsAt: string | null; + createdAt?: string; + updatedAt?: string; +}; + export type SiteConfig = { heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>; destinations: Destination[]; map: Array<{ id: string; image: string | null; isActive: boolean; createdAt?: string; updatedAt?: string }>; themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>; + campaigns: Campaign[]; ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>; }; -export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners"; +export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners"; export type SiteConfigItem = SiteConfig[SiteModule][number]; type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem }; @@ -97,11 +114,19 @@ export type SiteItemPatch = { label?: string; alt?: string; image?: string | null; + description?: string | null; + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags?: string[]; targetType?: string | null; targetValue?: string | null; isHot?: boolean; isActive?: boolean; sortOrder?: number; + status?: "draft" | "published"; + startsAt?: string | null; + endsAt?: string | null; }; export type ProductInput = { diff --git a/src/lib/admin-utils.ts b/src/lib/admin-utils.ts index 2760407..8ec2b2e 100644 --- a/src/lib/admin-utils.ts +++ b/src/lib/admin-utils.ts @@ -131,9 +131,23 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch { if (moduleId === "destinations") return "name"; if (moduleId === "map") return "image"; if (moduleId === "themes") return "label"; + if (moduleId === "campaigns") return "title"; return "alt"; } +function createCampaignSlug(title: string) { + const slug = title + .trim() + .normalize("NFKD") + .toLowerCase() + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + + return slug || `campaign-${Date.now().toString(36)}`; +} + export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number): SiteItemPatch { if (moduleId === "heroSlides") { return { title: "", kicker: "", image: "", isActive: false, sortOrder }; @@ -147,6 +161,20 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number if (moduleId === "themes") { return { label: "", image: "", isActive: false, sortOrder }; } + if (moduleId === "campaigns") { + return { + title: "", + slug: "", + description: "", + coverImage: "", + priceAmount: null, + priceUnit: "起/人", + tags: [], + status: "draft", + startsAt: "", + endsAt: "", + }; + } return { alt: "", image: "", isActive: false, sortOrder }; } @@ -174,6 +202,23 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc if (moduleId === "themes") { payload.label = draft.label?.trim(); } + if (moduleId === "campaigns") { + payload.title = draft.title?.trim(); + payload.slug = draft.slug?.trim() || createCampaignSlug(payload.title || ""); + payload.description = draft.description?.trim() || null; + payload.coverImage = draft.coverImage?.trim() || null; + 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 = draft.status === "published" ? "published" : "draft"; + payload.startsAt = undefined; + payload.endsAt = undefined; + payload.image = undefined; + payload.isActive = undefined; + payload.sortOrder = undefined; + payload.targetType = undefined; + payload.targetValue = undefined; + } if (moduleId === "ctaBanners") { payload.alt = draft.alt?.trim(); } @@ -204,17 +249,35 @@ export function itemName(item: EditableSiteItem) { return "地图图片"; } -export function itemMeta(item: EditableSiteItem) { +export function itemMeta(item: EditableSiteItem): string { if ("kicker" in item) return item.kicker || "轮播"; if ("aliases" in item) return item.region || item.aliases?.map((alias) => alias.alias).join(" / ") || "目的地"; + if ("priceAmount" in item) { + const priceUnit = "priceUnit" in item && typeof item.priceUnit === "string" ? item.priceUnit : ""; + const description = "description" in item && typeof item.description === "string" ? item.description : ""; + const status = "status" in item && typeof item.status === "string" ? item.status : ""; + return typeof item.priceAmount === "number" ? `¥${item.priceAmount}${priceUnit}` : description || status; + } + if ("description" in item) { + const description = typeof item.description === "string" ? item.description : ""; + const status = "status" in item && typeof item.status === "string" ? item.status : ""; + return description || status; + } if (!("title" in item) && !("name" in item) && !("label" in item) && !("alt" in item)) return "地图素材"; return ""; } export function itemImage(item: EditableSiteItem) { + if ("coverImage" in item) return item.coverImage || ""; return item.image || ""; } +export function itemTags(item: EditableSiteItem) { + return "tags" in item && Array.isArray(item.tags) + ? item.tags.filter((tag): tag is string => typeof tag === "string").slice(0, 3) + : []; +} + export function itemSortOrder(item: EditableSiteItem) { return "sortOrder" in item ? item.sortOrder : undefined; } @@ -224,5 +287,5 @@ export function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableS } export function moduleEditable(moduleId: ModuleId): moduleId is SiteModule { - return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "ctaBanners"; + return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "ctaBanners"; } diff --git a/src/pages/structure/StructurePage.tsx b/src/pages/structure/StructurePage.tsx index ebc9c12..6e8392e 100644 --- a/src/pages/structure/StructurePage.tsx +++ b/src/pages/structure/StructurePage.tsx @@ -29,6 +29,7 @@ import { SingleImageUploader } from "@/components/admin/SingleImageUploader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import { compactSiteItemPayload, createEmptySiteItemDraft, @@ -36,6 +37,7 @@ import { itemMeta, itemName, itemSortOrder, + itemTags, moduleEditable, moduleItems, siteItemPrimaryKey, @@ -93,6 +95,12 @@ const pageSpecs: { hint: "控制横向主题卡和搜索跳转。", frontPosition: "首页「主题甄选」", }, + { + id: "campaigns", + label: "特价优惠", + hint: "维护活动标题、封面和发布状态,供首页特价优惠入口使用。", + frontPosition: "首页「特价优惠」", + }, { id: "routeProducts", label: "精选线路", @@ -218,6 +226,9 @@ export function StructurePage({ const editableItems = config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : []; const isMapModule = moduleId === "map"; + const isCampaignModule = moduleId === "campaigns"; + const canReorderSiteItems = + moduleEditable(moduleId) && !isMapModule && !isCampaignModule; const canCreateSiteItem = moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0); const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID; @@ -246,9 +257,17 @@ export function StructurePage({ label: "label" in item ? item.label : undefined, alt: "alt" in item ? item.alt : undefined, image: "image" in item ? item.image || "" : "", + description: "description" in item ? item.description || "" : undefined, + coverImage: "coverImage" in item ? item.coverImage || "" : undefined, + priceAmount: "priceAmount" in item ? item.priceAmount : undefined, + priceUnit: "priceUnit" in item ? item.priceUnit || "起/人" : undefined, + tags: "tags" in item ? item.tags.slice(0, 3) : undefined, isHot: "isHot" in item ? item.isHot : undefined, - isActive: item.isActive, + isActive: "isActive" in item ? item.isActive : undefined, sortOrder: itemSortOrder(item), + status: "status" in item && (item.status === "draft" || item.status === "published") ? item.status : undefined, + startsAt: "startsAt" in item ? item.startsAt || "" : undefined, + endsAt: "endsAt" in item ? item.endsAt || "" : undefined, }); }; @@ -367,11 +386,12 @@ export function StructurePage({ const messageText = moduleId === "map" ? "请先上传贵州地图图片。" + : moduleId === "campaigns" + ? "请先填写特价优惠标题。" : "请先填写当前模块的主标题/名称。"; notify({ tone: "warning", title: "内容未保存", message: messageText }); return; } - setSaving(true); setMessage(""); @@ -621,17 +641,24 @@ export function StructurePage({ {itemName(item)} {itemMeta(item)} - {!isMapModule && typeof itemSortOrder(item) === "number" + {canReorderSiteItems && typeof itemSortOrder(item) === "number" ? ` · 排序 ${itemSortOrder(item)}` : ""} + {itemTags(item).length ? ( + + {itemTags(item).map((tag) => ( + {tag} + ))} + + ) : null}
- {!isMapModule ? ( + {canReorderSiteItems ? ( <> + + + ))} + +

最多维护 3 个标签,保存时会自动去掉空标签。

+
+ ); +} + function ReferencePanel({ moduleId, products, @@ -792,13 +880,20 @@ function SiteItemEditor({ ? "地图图片" : moduleId === "themes" ? "主题名称" + : moduleId === "campaigns" + ? "活动标题" : "入口文案"; const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`; const isImageOnlyModule = moduleId === "map"; + const isCampaignModule = moduleId === "campaigns"; const editorHint = isImageOnlyModule ? isCreating ? `上传后会作为「${moduleLabel}」唯一展示图片` : `保存后会替换「${moduleLabel}」前台展示图片` + : isCampaignModule + ? isCreating + ? `创建后会进入「${moduleLabel}」的活动列表` + : `保存后会影响「${moduleLabel}」活动入口` : isCreating ? `创建后会进入「${moduleLabel}」的数据列表` : `保存后会影响「${moduleLabel}」前台模块`; @@ -822,68 +917,152 @@ function SiteItemEditor({
{!isImageOnlyModule ? ( - - {moduleId === "heroSlides" ? ( + {isCampaignModule ? ( + <> + +