diff --git a/docs/README.md b/docs/README.md index 295a6be..78b3844 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 6fe523d..b3999ff 100644 --- a/docs/admin-backend-plan.md +++ b/docs/admin-backend-plan.md @@ -162,6 +162,7 @@ flowchart LR | `home_sections` | 首页模块配置 | | `hero_slides` | 首页轮播 | | `destinations` | 目的地 | +| `map_images` | 贵州地图单图配置 | | `destination_aliases` | 搜索别名 | | `themes` | 主题甄选 | | `products` | 线路产品 | @@ -237,7 +238,7 @@ leads ( - `CRUD /api/admin/products` - `CRUD /api/admin/destinations` - `CRUD /api/admin/campaigns` -- `CRUD /api/admin/home-config` +- `CRUD /api/admin/site-config/:module`:页面模块配置,覆盖 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners` - `CRUD /api/admin/media-assets` - `GET /api/admin/leads` - `PATCH /api/admin/leads/:id/status` @@ -256,7 +257,7 @@ leads ( - 新建 `src/api/client.ts` 和 `src/api/types.ts`。 - 新建 `src/adapters/siteConfig.ts`,把接口数据转成当前组件需要的结构。 - 保留本地 JSON fallback,便于本地开发和接口故障降级。 -- 把 `heroSlides`、`destinations`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。 +- 把 `heroSlides`、`destinations`、`map`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。 ### 第二步:产品和目的地接口化 diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md index 3c0fd59..7ce72e9 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 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片和底部运营入口。 ## 适用模块 @@ -10,6 +10,7 @@ | -------------- | ------------ | ---------------------------------------------- | | `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 | | `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 | +| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 | | `themes` | 主题甄选 | 首页主题卡片和跳转 | | `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA | @@ -34,10 +35,20 @@ } ``` +## 后端实现重点 + +WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 作为正式模块接入,而不是只在前端展示: + +- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`。 +- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。 +- `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。 +- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。 +- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。 + ## 类型定义 ```ts -type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners"; +type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners"; type SiteItemPatch = { title?: string; @@ -75,6 +86,21 @@ type HeroSlideCreateInput = { }; type HeroSlideUpdateInput = Partial; + +type MapImage = { + id: string; + image: string | null; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + +type MapImageCreateInput = { + image: string; + isActive?: boolean; +}; + +type MapImageUpdateInput = Partial; ``` 各模块字段要求: @@ -83,6 +109,7 @@ type HeroSlideUpdateInput = Partial; | -------------- | -------- | ------------------------------------------------------------- | | `heroSlides` | `title` | `kicker`、`image`、`isActive`、`sortOrder` | | `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` | +| `map` | `image` | `isActive` | | `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | | `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | @@ -219,9 +246,73 @@ PATCH /api/admin/site-config/heroSlides/reorder } ``` +## 贵州地图 `map` 专用契约 + +贵州地图模块对应当前管理端抽屉中的 2 个区域: + +- 地图图片:`image` +- 显示状态:`isActive` + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| ----------- | ---------------- | -------- | ---------- | ----------------------------------------- | +| `id` | `string` | 后端生成 | 不允许修改 | 地图图片配置项唯一 id | +| `image` | `string \| null` | 必填 | 可选 | 单张地图图片地址或素材 URL,创建时不能为空 | +| `isActive` | `boolean` | 可选 | 可选 | 前台是否展示;未传时默认 `true` | +| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | +| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | + +贵州地图只维护一张图片,不提供排序能力。`map` 的查询响应返回数组是为了复用现有站点配置结构,但最多返回 1 项。创建第二张地图图片时,后端应返回 409 或改为更新当前唯一图片,具体以后端实现保持一致。贵州地图不定义标题、文案和跳转能力。`map` 的创建、更新、查询响应中不要返回 `title`、`name`、`label`、`alt`、`targetType`、`targetValue`、`sortOrder`。 + +后端推荐策略:`POST /api/admin/site-config/map` 在不存在地图图片时创建;已存在时返回 `409 MAP_IMAGE_ALREADY_EXISTS`,或直接更新当前唯一图片。无论选择哪种策略,都要保证 `PATCH /api/admin/site-config/map/:id` 可以按 id 更新当前图片。 + +### 贵州地图 CRUD + +新增贵州地图图片: + +```http +POST /api/admin/site-config/map +``` + +请求体: + +```json +{ + "image": "https://bucket.oss-cn-example.aliyuncs.com/admin/map/2026/07/01/guizhou-map.webp", + "isActive": false +} +``` + +响应状态码 `201`: + +```json +{ + "id": "map_001", + "image": "https://bucket.oss-cn-example.aliyuncs.com/admin/map/2026/07/01/guizhou-map.webp", + "isActive": false, + "createdAt": "2026-07-01T08:00:00.000Z", + "updatedAt": "2026-07-01T08:00:00.000Z" +} +``` + +更新贵州地图图片: + +```http +PATCH /api/admin/site-config/map/:id +``` + +请求体为 `MapImageUpdateInput`,只提交需要修改的字段。 + +删除贵州地图图片: + +```http +DELETE /api/admin/site-config/map/:id +``` + ## 接口列表 -以下路径由四类页面模块复用;`heroSlides` 的请求体和响应体以“顶部轮播专用契约”为准。 +以下路径由五类页面模块复用;`heroSlides`、`map` 的请求体和响应体以各自专用契约为准。 ### 获取完整站点配置 @@ -235,11 +326,14 @@ GET /api/admin/site-config type SiteConfig = { heroSlides: HeroSlide[]; destinations: Destination[]; + map: MapImage[]; themes: ThemeCard[]; ctaBanners: CtaBanner[]; }; ``` +`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 + ### 新增模块配置项 ```http @@ -304,6 +398,8 @@ PATCH /api/admin/site-config/:module/reorder - 不允许混入其他模块 id。 - 后端按数组顺序写入 `sortOrder`,从 0 开始。 +`map` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。 + 响应状态码 `200`: ```json diff --git a/src/api.ts b/src/api.ts index 9ee5bbc..6f36f76 100644 --- a/src/api.ts +++ b/src/api.ts @@ -79,12 +79,14 @@ export type MediaAsset = { 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 }>; ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>; }; -export type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners"; +export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners"; export type SiteConfigItem = SiteConfig[SiteModule][number]; +type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem }; export type SiteItemPatch = { title?: string; @@ -211,18 +213,24 @@ export async function getSiteConfig() { return request("/api/admin/site-config"); } +function unwrapSiteConfigItem(response: SiteConfigItemResponse) { + return "item" in response ? response.item : response; +} + export async function createSiteConfigItem(module: SiteModule, input: SiteItemPatch) { - return request(`/api/admin/site-config/${module}`, { + const response = await request(`/api/admin/site-config/${module}`, { method: "POST", body: JSON.stringify(input), }); + return unwrapSiteConfigItem(response); } export async function updateSiteConfigItem(module: SiteModule, id: string, input: SiteItemPatch) { - return request(`/api/admin/site-config/${module}/${id}`, { + const response = await request(`/api/admin/site-config/${module}/${id}`, { method: "PATCH", body: JSON.stringify(input), }); + return unwrapSiteConfigItem(response); } export async function deleteSiteConfigItem(module: SiteModule, id: string) { diff --git a/src/lib/admin-utils.ts b/src/lib/admin-utils.ts index 8b1edaa..2760407 100644 --- a/src/lib/admin-utils.ts +++ b/src/lib/admin-utils.ts @@ -129,21 +129,25 @@ export function compactProductPayload(draft: ProductInput): ProductInput { export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch { if (moduleId === "heroSlides") return "title"; if (moduleId === "destinations") return "name"; + if (moduleId === "map") return "image"; if (moduleId === "themes") return "label"; return "alt"; } export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number): SiteItemPatch { if (moduleId === "heroSlides") { - return { title: "", kicker: "", image: "", isActive: true, sortOrder }; + return { title: "", kicker: "", image: "", isActive: false, sortOrder }; } if (moduleId === "destinations") { - return { name: "新目的地", slug: "", region: "", image: "", isHot: false, isActive: true, sortOrder }; + return { name: "", image: "", isActive: false, sortOrder }; + } + if (moduleId === "map") { + return { image: "", isActive: false }; } if (moduleId === "themes") { - return { label: "新主题", image: "", isActive: true, sortOrder }; + return { label: "", image: "", isActive: false, sortOrder }; } - return { alt: "新运营入口", image: "", isActive: true, sortOrder }; + return { alt: "", image: "", isActive: false, sortOrder }; } export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatch): SiteItemPatch { @@ -164,6 +168,9 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc payload.region = draft.region?.trim() || null; payload.isHot = Boolean(draft.isHot); } + if (moduleId === "map") { + payload.sortOrder = undefined; + } if (moduleId === "themes") { payload.label = draft.label?.trim(); } @@ -193,12 +200,14 @@ export function itemName(item: EditableSiteItem) { if ("title" in item) return item.title; if ("name" in item) return item.name; if ("label" in item) return item.label; - return item.alt; + if ("alt" in item) return item.alt; + return "地图图片"; } export function itemMeta(item: EditableSiteItem) { if ("kicker" in item) return item.kicker || "轮播"; if ("aliases" in item) return item.region || item.aliases?.map((alias) => alias.alias).join(" / ") || "目的地"; + if (!("title" in item) && !("name" in item) && !("label" in item) && !("alt" in item)) return "地图素材"; return ""; } @@ -211,9 +220,9 @@ export function itemSortOrder(item: EditableSiteItem) { } export function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableSiteItem[] { - return config[moduleId] as EditableSiteItem[]; + return (config[moduleId] ?? []) as EditableSiteItem[]; } export function moduleEditable(moduleId: ModuleId): moduleId is SiteModule { - return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "themes" || moduleId === "ctaBanners"; + return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "ctaBanners"; } diff --git a/src/pages/structure/StructurePage.tsx b/src/pages/structure/StructurePage.tsx index 9ccfb35..ebc9c12 100644 --- a/src/pages/structure/StructurePage.tsx +++ b/src/pages/structure/StructurePage.tsx @@ -81,6 +81,12 @@ const pageSpecs: { hint: "控制省内目的地宫格、热门标记和搜索入口。", frontPosition: "首页「探索贵州」", }, + { + id: "map", + label: "贵州地图", + hint: "展示贵州省内的地图信息和地点。", + frontPosition: "首页「探索贵州」", + }, { id: "themes", label: "主题甄选", @@ -202,6 +208,7 @@ export function StructurePage({ const [draft, setDraft] = useState({}); const [message, setMessage] = useState(""); const [editorOpen, setEditorOpen] = useState(false); + const [saving, setSaving] = useState(false); const activePage = pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0]; @@ -210,6 +217,9 @@ export function StructurePage({ activePage.modules[0]; const editableItems = config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : []; + const isMapModule = moduleId === "map"; + const canCreateSiteItem = + moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0); const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID; const selectedItem = isCreatingSiteItem ? undefined @@ -327,6 +337,7 @@ export function StructurePage({ const closeEditorDrawer = () => { setEditorOpen(false); + setMessage(""); if (isCreatingSiteItem) { const first = editableItems[0]; if (first) { @@ -342,6 +353,7 @@ export function StructurePage({ const save = async () => { if ( + saving || !config || !moduleEditable(moduleId) || (!selectedId && !isCreatingSiteItem) @@ -352,17 +364,55 @@ export function StructurePage({ const primaryValue = String(payload[primaryKey] ?? "").trim(); if (!primaryValue) { - const messageText = "请先填写当前模块的主标题/名称。"; + const messageText = + moduleId === "map" + ? "请先上传贵州地图图片。" + : "请先填写当前模块的主标题/名称。"; notify({ tone: "warning", title: "内容未保存", message: messageText }); return; } + setSaving(true); + setMessage(""); + try { - const saved = isCreatingSiteItem - ? await createSiteConfigItem(moduleId, payload) - : await updateSiteConfigItem(moduleId, selectedId, payload); + let saved: EditableSiteItem; + const existingMapItem = + moduleId === "map" ? moduleItems(config, "map")[0] : undefined; + + try { + saved = + moduleId === "map" && existingMapItem + ? ((await updateSiteConfigItem( + moduleId, + existingMapItem.id, + payload, + )) as EditableSiteItem) + : isCreatingSiteItem + ? ((await createSiteConfigItem(moduleId, payload)) as EditableSiteItem) + : ((await updateSiteConfigItem( + moduleId, + selectedId, + payload, + )) as EditableSiteItem); + } catch (err) { + if (moduleId !== "map" || !isCreatingSiteItem) throw err; + + const latestConfig = await getSiteConfig().catch(() => null); + const latestMapItem = latestConfig + ? moduleItems(latestConfig, "map")[0] + : undefined; + if (!latestMapItem) throw err; + + saved = (await updateSiteConfigItem( + moduleId, + latestMapItem.id, + payload, + )) as EditableSiteItem; + } + setSelectedId(saved.id); - setDraftFromItem(saved as EditableSiteItem); + setDraftFromItem(saved); setMessage(""); onDirtyChange(false); notify({ @@ -375,6 +425,8 @@ export function StructurePage({ const messageText = err instanceof Error ? err.message : "保存失败"; setMessage(messageText); notify({ tone: "danger", title: "内容保存失败", message: messageText }); + } finally { + setSaving(false); } }; @@ -541,13 +593,13 @@ export function StructurePage({

{activeModule.label}

{activeModule.hint}

- {moduleEditable(moduleId) ? ( + {canCreateSiteItem ? ( ) : null} @@ -569,7 +621,7 @@ export function StructurePage({ {itemName(item)} {itemMeta(item)} - {typeof itemSortOrder(item) === "number" + {!isMapModule && typeof itemSortOrder(item) === "number" ? ` · 排序 ${itemSortOrder(item)}` : ""} @@ -579,24 +631,28 @@ export function StructurePage({ className="mapped-list-actions" aria-label={`${itemName(item)} 操作`} > - - + {!isMapModule ? ( + <> + + + + ) : null} - diff --git a/src/styles.css b/src/styles.css index 4ce46bf..40ee4a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -463,11 +463,13 @@ textarea { .maintenance-grid { grid-row: 3; min-height: 0; + height: 100%; display: grid; grid-template-columns: minmax(190px, 240px) minmax(240px, 300px) minmax( 320px, 1fr ); + align-items: stretch; gap: 14px; } @@ -541,17 +543,26 @@ textarea { .page-rail, .module-rail { - align-self: start; + align-self: stretch; display: grid; align-content: start; gap: 10px; - height: fit-content; - max-height: calc(100vh - 190px); + height: 100%; + max-height: none; overflow-x: hidden; overflow-y: auto; } -.item-rail, +.item-rail { + align-self: stretch; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + align-content: stretch; + height: 100%; + max-height: none; + overflow: hidden; +} + .edit-rail { align-self: start; height: fit-content; @@ -746,6 +757,7 @@ textarea { .module-data-head p { margin: 0; color: #68747d; + font-size: 14px; line-height: 1.55; max-width: 54ch; } @@ -757,6 +769,16 @@ textarea { gap: 10px; } +.item-rail > .mapped-list { + min-height: 0; + align-content: start; + grid-auto-rows: max-content; + gap: 8px; + overflow-x: hidden; + overflow-y: auto; + padding-right: 4px; +} + .mapped-list-row { min-width: 0; display: grid; @@ -1067,7 +1089,7 @@ textarea { position: fixed; right: 18px; bottom: 18px; - z-index: 40; + z-index: 80; display: grid; gap: 10px; width: min(360px, calc(100vw - 36px)); diff --git a/src/types/admin.ts b/src/types/admin.ts index caa8c5b..ff4662b 100644 --- a/src/types/admin.ts +++ b/src/types/admin.ts @@ -17,5 +17,6 @@ export type DirtyChangeHandler = (dirty: boolean) => void; export type EditableSiteItem = | SiteConfig["heroSlides"][number] | SiteConfig["destinations"][number] + | SiteConfig["map"][number] | SiteConfig["themes"][number] | SiteConfig["ctaBanners"][number];