feat: add support for map module in site configuration and update related APIs
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
本目录存放后台管理前端相关文档。
|
本目录存放后台管理前端相关文档。
|
||||||
|
|
||||||
- `admin-backend-plan.md`:从原 MiniAPP 迁出的后台管理规划,保留管理模块、页面能力和与后端 API 的协作边界。
|
- `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`。
|
后端 API 文档位于 `D:\www\znkj\WonderQ-Admin\docs`。
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ flowchart LR
|
|||||||
| `home_sections` | 首页模块配置 |
|
| `home_sections` | 首页模块配置 |
|
||||||
| `hero_slides` | 首页轮播 |
|
| `hero_slides` | 首页轮播 |
|
||||||
| `destinations` | 目的地 |
|
| `destinations` | 目的地 |
|
||||||
|
| `map_images` | 贵州地图单图配置 |
|
||||||
| `destination_aliases` | 搜索别名 |
|
| `destination_aliases` | 搜索别名 |
|
||||||
| `themes` | 主题甄选 |
|
| `themes` | 主题甄选 |
|
||||||
| `products` | 线路产品 |
|
| `products` | 线路产品 |
|
||||||
@@ -237,7 +238,7 @@ leads (
|
|||||||
- `CRUD /api/admin/products`
|
- `CRUD /api/admin/products`
|
||||||
- `CRUD /api/admin/destinations`
|
- `CRUD /api/admin/destinations`
|
||||||
- `CRUD /api/admin/campaigns`
|
- `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`
|
- `CRUD /api/admin/media-assets`
|
||||||
- `GET /api/admin/leads`
|
- `GET /api/admin/leads`
|
||||||
- `PATCH /api/admin/leads/:id/status`
|
- `PATCH /api/admin/leads/:id/status`
|
||||||
@@ -256,7 +257,7 @@ leads (
|
|||||||
- 新建 `src/api/client.ts` 和 `src/api/types.ts`。
|
- 新建 `src/api/client.ts` 和 `src/api/types.ts`。
|
||||||
- 新建 `src/adapters/siteConfig.ts`,把接口数据转成当前组件需要的结构。
|
- 新建 `src/adapters/siteConfig.ts`,把接口数据转成当前组件需要的结构。
|
||||||
- 保留本地 JSON fallback,便于本地开发和接口故障降级。
|
- 保留本地 JSON fallback,便于本地开发和接口故障降级。
|
||||||
- 把 `heroSlides`、`destinations`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。
|
- 把 `heroSlides`、`destinations`、`map`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。
|
||||||
|
|
||||||
### 第二步:产品和目的地接口化
|
### 第二步:产品和目的地接口化
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 页面模块配置 Admin API 契约
|
# 页面模块配置 Admin API 契约
|
||||||
|
|
||||||
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、主题卡片和底部运营入口。
|
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片和底部运营入口。
|
||||||
|
|
||||||
## 适用模块
|
## 适用模块
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| -------------- | ------------ | ---------------------------------------------- |
|
| -------------- | ------------ | ---------------------------------------------- |
|
||||||
| `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 |
|
| `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 |
|
||||||
| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 |
|
| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 |
|
||||||
|
| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 |
|
||||||
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
||||||
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
|
| `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
|
```ts
|
||||||
type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners";
|
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners";
|
||||||
|
|
||||||
type SiteItemPatch = {
|
type SiteItemPatch = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -75,6 +86,21 @@ type HeroSlideCreateInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type HeroSlideUpdateInput = Partial<HeroSlideCreateInput>;
|
type HeroSlideUpdateInput = Partial<HeroSlideCreateInput>;
|
||||||
|
|
||||||
|
type MapImage = {
|
||||||
|
id: string;
|
||||||
|
image: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapImageCreateInput = {
|
||||||
|
image: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapImageUpdateInput = Partial<MapImageCreateInput>;
|
||||||
```
|
```
|
||||||
|
|
||||||
各模块字段要求:
|
各模块字段要求:
|
||||||
@@ -83,6 +109,7 @@ type HeroSlideUpdateInput = Partial<HeroSlideCreateInput>;
|
|||||||
| -------------- | -------- | ------------------------------------------------------------- |
|
| -------------- | -------- | ------------------------------------------------------------- |
|
||||||
| `heroSlides` | `title` | `kicker`、`image`、`isActive`、`sortOrder` |
|
| `heroSlides` | `title` | `kicker`、`image`、`isActive`、`sortOrder` |
|
||||||
| `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` |
|
| `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` |
|
||||||
|
| `map` | `image` | `isActive` |
|
||||||
| `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
| `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
||||||
| `ctaBanners` | `alt` | `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 = {
|
type SiteConfig = {
|
||||||
heroSlides: HeroSlide[];
|
heroSlides: HeroSlide[];
|
||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
|
map: MapImage[];
|
||||||
themes: ThemeCard[];
|
themes: ThemeCard[];
|
||||||
ctaBanners: CtaBanner[];
|
ctaBanners: CtaBanner[];
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||||
|
|
||||||
### 新增模块配置项
|
### 新增模块配置项
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -304,6 +398,8 @@ PATCH /api/admin/site-config/:module/reorder
|
|||||||
- 不允许混入其他模块 id。
|
- 不允许混入其他模块 id。
|
||||||
- 后端按数组顺序写入 `sortOrder`,从 0 开始。
|
- 后端按数组顺序写入 `sortOrder`,从 0 开始。
|
||||||
|
|
||||||
|
`map` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。
|
||||||
|
|
||||||
响应状态码 `200`:
|
响应状态码 `200`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
14
src/api.ts
14
src/api.ts
@@ -79,12 +79,14 @@ export type MediaAsset = {
|
|||||||
export type SiteConfig = {
|
export type SiteConfig = {
|
||||||
heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
|
heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
|
||||||
destinations: Destination[];
|
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 }>;
|
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 }>;
|
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];
|
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
||||||
|
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
||||||
|
|
||||||
export type SiteItemPatch = {
|
export type SiteItemPatch = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -211,18 +213,24 @@ export async function getSiteConfig() {
|
|||||||
return request<SiteConfig>("/api/admin/site-config");
|
return request<SiteConfig>("/api/admin/site-config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapSiteConfigItem(response: SiteConfigItemResponse) {
|
||||||
|
return "item" in response ? response.item : response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSiteConfigItem(module: SiteModule, input: SiteItemPatch) {
|
export async function createSiteConfigItem(module: SiteModule, input: SiteItemPatch) {
|
||||||
return request<SiteConfigItem>(`/api/admin/site-config/${module}`, {
|
const response = await request<SiteConfigItemResponse>(`/api/admin/site-config/${module}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
|
return unwrapSiteConfigItem(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSiteConfigItem(module: SiteModule, id: string, input: SiteItemPatch) {
|
export async function updateSiteConfigItem(module: SiteModule, id: string, input: SiteItemPatch) {
|
||||||
return request<SiteConfigItem>(`/api/admin/site-config/${module}/${id}`, {
|
const response = await request<SiteConfigItemResponse>(`/api/admin/site-config/${module}/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
|
return unwrapSiteConfigItem(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSiteConfigItem(module: SiteModule, id: string) {
|
export async function deleteSiteConfigItem(module: SiteModule, id: string) {
|
||||||
|
|||||||
@@ -129,21 +129,25 @@ export function compactProductPayload(draft: ProductInput): ProductInput {
|
|||||||
export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
||||||
if (moduleId === "heroSlides") return "title";
|
if (moduleId === "heroSlides") return "title";
|
||||||
if (moduleId === "destinations") return "name";
|
if (moduleId === "destinations") return "name";
|
||||||
|
if (moduleId === "map") return "image";
|
||||||
if (moduleId === "themes") return "label";
|
if (moduleId === "themes") return "label";
|
||||||
return "alt";
|
return "alt";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number): SiteItemPatch {
|
export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number): SiteItemPatch {
|
||||||
if (moduleId === "heroSlides") {
|
if (moduleId === "heroSlides") {
|
||||||
return { title: "", kicker: "", image: "", isActive: true, sortOrder };
|
return { title: "", kicker: "", image: "", isActive: false, sortOrder };
|
||||||
}
|
}
|
||||||
if (moduleId === "destinations") {
|
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") {
|
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 {
|
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.region = draft.region?.trim() || null;
|
||||||
payload.isHot = Boolean(draft.isHot);
|
payload.isHot = Boolean(draft.isHot);
|
||||||
}
|
}
|
||||||
|
if (moduleId === "map") {
|
||||||
|
payload.sortOrder = undefined;
|
||||||
|
}
|
||||||
if (moduleId === "themes") {
|
if (moduleId === "themes") {
|
||||||
payload.label = draft.label?.trim();
|
payload.label = draft.label?.trim();
|
||||||
}
|
}
|
||||||
@@ -193,12 +200,14 @@ export function itemName(item: EditableSiteItem) {
|
|||||||
if ("title" in item) return item.title;
|
if ("title" in item) return item.title;
|
||||||
if ("name" in item) return item.name;
|
if ("name" in item) return item.name;
|
||||||
if ("label" in item) return item.label;
|
if ("label" in item) return item.label;
|
||||||
return item.alt;
|
if ("alt" in item) return item.alt;
|
||||||
|
return "地图图片";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemMeta(item: EditableSiteItem) {
|
export function itemMeta(item: EditableSiteItem) {
|
||||||
if ("kicker" in item) return item.kicker || "轮播";
|
if ("kicker" in item) return item.kicker || "轮播";
|
||||||
if ("aliases" in item) return item.region || item.aliases?.map((alias) => alias.alias).join(" / ") || "目的地";
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +220,9 @@ export function itemSortOrder(item: EditableSiteItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function moduleItems(config: SiteConfig, moduleId: SiteModule): 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 {
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const pageSpecs: {
|
|||||||
hint: "控制省内目的地宫格、热门标记和搜索入口。",
|
hint: "控制省内目的地宫格、热门标记和搜索入口。",
|
||||||
frontPosition: "首页「探索贵州」",
|
frontPosition: "首页「探索贵州」",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "map",
|
||||||
|
label: "贵州地图",
|
||||||
|
hint: "展示贵州省内的地图信息和地点。",
|
||||||
|
frontPosition: "首页「探索贵州」",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "themes",
|
id: "themes",
|
||||||
label: "主题甄选",
|
label: "主题甄选",
|
||||||
@@ -202,6 +208,7 @@ export function StructurePage({
|
|||||||
const [draft, setDraft] = useState<SiteItemPatch>({});
|
const [draft, setDraft] = useState<SiteItemPatch>({});
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const activePage =
|
const activePage =
|
||||||
pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0];
|
pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0];
|
||||||
@@ -210,6 +217,9 @@ export function StructurePage({
|
|||||||
activePage.modules[0];
|
activePage.modules[0];
|
||||||
const editableItems =
|
const editableItems =
|
||||||
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
|
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 isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
||||||
const selectedItem = isCreatingSiteItem
|
const selectedItem = isCreatingSiteItem
|
||||||
? undefined
|
? undefined
|
||||||
@@ -327,6 +337,7 @@ export function StructurePage({
|
|||||||
|
|
||||||
const closeEditorDrawer = () => {
|
const closeEditorDrawer = () => {
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
|
setMessage("");
|
||||||
if (isCreatingSiteItem) {
|
if (isCreatingSiteItem) {
|
||||||
const first = editableItems[0];
|
const first = editableItems[0];
|
||||||
if (first) {
|
if (first) {
|
||||||
@@ -342,6 +353,7 @@ export function StructurePage({
|
|||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (
|
if (
|
||||||
|
saving ||
|
||||||
!config ||
|
!config ||
|
||||||
!moduleEditable(moduleId) ||
|
!moduleEditable(moduleId) ||
|
||||||
(!selectedId && !isCreatingSiteItem)
|
(!selectedId && !isCreatingSiteItem)
|
||||||
@@ -352,17 +364,55 @@ export function StructurePage({
|
|||||||
const primaryValue = String(payload[primaryKey] ?? "").trim();
|
const primaryValue = String(payload[primaryKey] ?? "").trim();
|
||||||
|
|
||||||
if (!primaryValue) {
|
if (!primaryValue) {
|
||||||
const messageText = "请先填写当前模块的主标题/名称。";
|
const messageText =
|
||||||
|
moduleId === "map"
|
||||||
|
? "请先上传贵州地图图片。"
|
||||||
|
: "请先填写当前模块的主标题/名称。";
|
||||||
notify({ tone: "warning", title: "内容未保存", message: messageText });
|
notify({ tone: "warning", title: "内容未保存", message: messageText });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setMessage("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saved = isCreatingSiteItem
|
let saved: EditableSiteItem;
|
||||||
? await createSiteConfigItem(moduleId, payload)
|
const existingMapItem =
|
||||||
: await updateSiteConfigItem(moduleId, selectedId, payload);
|
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);
|
setSelectedId(saved.id);
|
||||||
setDraftFromItem(saved as EditableSiteItem);
|
setDraftFromItem(saved);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
onDirtyChange(false);
|
onDirtyChange(false);
|
||||||
notify({
|
notify({
|
||||||
@@ -375,6 +425,8 @@ export function StructurePage({
|
|||||||
const messageText = err instanceof Error ? err.message : "保存失败";
|
const messageText = err instanceof Error ? err.message : "保存失败";
|
||||||
setMessage(messageText);
|
setMessage(messageText);
|
||||||
notify({ tone: "danger", title: "内容保存失败", message: messageText });
|
notify({ tone: "danger", title: "内容保存失败", message: messageText });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -541,13 +593,13 @@ export function StructurePage({
|
|||||||
<h3>{activeModule.label}</h3>
|
<h3>{activeModule.label}</h3>
|
||||||
<p>{activeModule.hint}</p>
|
<p>{activeModule.hint}</p>
|
||||||
</span>
|
</span>
|
||||||
{moduleEditable(moduleId) ? (
|
{canCreateSiteItem ? (
|
||||||
<Button
|
<Button
|
||||||
className="primary-action compact"
|
className="primary-action compact"
|
||||||
onClick={startCreateSiteItem}
|
onClick={startCreateSiteItem}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
新增
|
{isMapModule ? "上传地图" : "新增"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
@@ -569,7 +621,7 @@ export function StructurePage({
|
|||||||
<b>{itemName(item)}</b>
|
<b>{itemName(item)}</b>
|
||||||
<small>
|
<small>
|
||||||
{itemMeta(item)}
|
{itemMeta(item)}
|
||||||
{typeof itemSortOrder(item) === "number"
|
{!isMapModule && typeof itemSortOrder(item) === "number"
|
||||||
? ` · 排序 ${itemSortOrder(item)}`
|
? ` · 排序 ${itemSortOrder(item)}`
|
||||||
: ""}
|
: ""}
|
||||||
</small>
|
</small>
|
||||||
@@ -579,24 +631,28 @@ export function StructurePage({
|
|||||||
className="mapped-list-actions"
|
className="mapped-list-actions"
|
||||||
aria-label={`${itemName(item)} 操作`}
|
aria-label={`${itemName(item)} 操作`}
|
||||||
>
|
>
|
||||||
<Button
|
{!isMapModule ? (
|
||||||
variant="ghost"
|
<>
|
||||||
size="icon"
|
<Button
|
||||||
disabled={index === 0}
|
variant="ghost"
|
||||||
onClick={() => moveSiteItem(item, -1)}
|
size="icon"
|
||||||
aria-label={`上移 ${itemName(item)}`}
|
disabled={index === 0}
|
||||||
>
|
onClick={() => moveSiteItem(item, -1)}
|
||||||
<ArrowUp size={15} />
|
aria-label={`上移 ${itemName(item)}`}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<ArrowUp size={15} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
<Button
|
||||||
disabled={index === editableItems.length - 1}
|
variant="ghost"
|
||||||
onClick={() => moveSiteItem(item, 1)}
|
size="icon"
|
||||||
aria-label={`下移 ${itemName(item)}`}
|
disabled={index === editableItems.length - 1}
|
||||||
>
|
onClick={() => moveSiteItem(item, 1)}
|
||||||
<ArrowDown size={15} />
|
aria-label={`下移 ${itemName(item)}`}
|
||||||
</Button>
|
>
|
||||||
|
<ArrowDown size={15} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -610,7 +666,9 @@ export function StructurePage({
|
|||||||
))}
|
))}
|
||||||
{!editableItems.length ? (
|
{!editableItems.length ? (
|
||||||
<p className="mapped-empty">
|
<p className="mapped-empty">
|
||||||
暂无配置数据,点击新增创建第一项。
|
{isMapModule
|
||||||
|
? "暂无地图图片,点击上传地图。"
|
||||||
|
: "暂无配置数据,点击新增创建第一项。"}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,6 +700,7 @@ export function StructurePage({
|
|||||||
moduleLabel={activeModule.label}
|
moduleLabel={activeModule.label}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
isCreating={isCreatingSiteItem}
|
isCreating={isCreatingSiteItem}
|
||||||
|
saving={saving}
|
||||||
onDraft={(nextDraft) => {
|
onDraft={(nextDraft) => {
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
onDirtyChange(true);
|
onDirtyChange(true);
|
||||||
@@ -709,6 +768,7 @@ function SiteItemEditor({
|
|||||||
moduleLabel,
|
moduleLabel,
|
||||||
draft,
|
draft,
|
||||||
isCreating,
|
isCreating,
|
||||||
|
saving,
|
||||||
onDraft,
|
onDraft,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -717,6 +777,7 @@ function SiteItemEditor({
|
|||||||
moduleLabel: string;
|
moduleLabel: string;
|
||||||
draft: SiteItemPatch;
|
draft: SiteItemPatch;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
|
saving: boolean;
|
||||||
onDraft: (draft: SiteItemPatch) => void;
|
onDraft: (draft: SiteItemPatch) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -727,21 +788,27 @@ function SiteItemEditor({
|
|||||||
? "轮播标题"
|
? "轮播标题"
|
||||||
: moduleId === "destinations"
|
: moduleId === "destinations"
|
||||||
? "目的地名称"
|
? "目的地名称"
|
||||||
: moduleId === "themes"
|
: moduleId === "map"
|
||||||
? "主题名称"
|
? "地图图片"
|
||||||
: "入口文案";
|
: moduleId === "themes"
|
||||||
|
? "主题名称"
|
||||||
|
: "入口文案";
|
||||||
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
||||||
|
const isImageOnlyModule = moduleId === "map";
|
||||||
|
const editorHint = isImageOnlyModule
|
||||||
|
? isCreating
|
||||||
|
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
||||||
|
: `保存后会替换「${moduleLabel}」前台展示图片`
|
||||||
|
: isCreating
|
||||||
|
? `创建后会进入「${moduleLabel}」的数据列表`
|
||||||
|
: `保存后会影响「${moduleLabel}」前台模块`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mapped-editor">
|
<div className="mapped-editor">
|
||||||
<header className="editor-head">
|
<header className="editor-head">
|
||||||
<span>
|
<span>
|
||||||
<h3 id="site-item-editor-title">{editorTitle}</h3>
|
<h3 id="site-item-editor-title">{editorTitle}</h3>
|
||||||
<small>
|
<small>{editorHint}</small>
|
||||||
{isCreating
|
|
||||||
? `创建后会进入「${moduleLabel}」的数据列表`
|
|
||||||
: `保存后会影响「${moduleLabel}」前台模块`}
|
|
||||||
</small>
|
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -753,68 +820,50 @@ function SiteItemEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="admin-disclosure-stack">
|
<div className="admin-disclosure-stack">
|
||||||
<AdminDisclosure title="展示内容">
|
{!isImageOnlyModule ? (
|
||||||
<label>
|
<AdminDisclosure title="展示内容">
|
||||||
{primaryLabel}
|
|
||||||
<Input
|
|
||||||
value={String(draft[primaryKey] ?? "")}
|
|
||||||
onChange={(event) =>
|
|
||||||
onDraft({ ...draft, [primaryKey]: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{moduleId === "heroSlides" ? (
|
|
||||||
<label>
|
<label>
|
||||||
副标题
|
{primaryLabel}
|
||||||
<Input
|
<Input
|
||||||
value={draft.kicker ?? ""}
|
value={String(draft[primaryKey] ?? "")}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onDraft({ ...draft, kicker: event.target.value })
|
onDraft({ ...draft, [primaryKey]: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
{moduleId === "heroSlides" ? (
|
||||||
{moduleId === "destinations" ? (
|
|
||||||
<div className="form-grid compact-grid">
|
|
||||||
<label>
|
<label>
|
||||||
目的地标识
|
副标题
|
||||||
<Input
|
<Input
|
||||||
value={draft.slug ?? ""}
|
value={draft.kicker ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onDraft({ ...draft, slug: event.target.value })
|
onDraft({ ...draft, kicker: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
) : null}
|
||||||
所属区域
|
</AdminDisclosure>
|
||||||
<Input
|
) : null}
|
||||||
value={draft.region ?? ""}
|
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : "资源图片"}>
|
||||||
onChange={(event) =>
|
|
||||||
onDraft({ ...draft, region: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</AdminDisclosure>
|
|
||||||
<AdminDisclosure title="资源图片">
|
|
||||||
<SingleImageUploader
|
<SingleImageUploader
|
||||||
value={draft.image}
|
value={draft.image}
|
||||||
onChange={(image) => onDraft({ ...draft, image })}
|
onChange={(image) => onDraft({ ...draft, image })}
|
||||||
group={moduleId}
|
group={moduleId}
|
||||||
/>
|
/>
|
||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
<AdminDisclosure title="排序与状态">
|
<AdminDisclosure title={isImageOnlyModule ? "显示状态" : "排序与状态"}>
|
||||||
<label>
|
{!isImageOnlyModule ? (
|
||||||
排序值
|
<label>
|
||||||
<Input
|
排序值
|
||||||
type="number"
|
<Input
|
||||||
value={draft.sortOrder ?? 0}
|
type="number"
|
||||||
onChange={(event) =>
|
value={draft.sortOrder ?? 0}
|
||||||
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
onChange={(event) =>
|
||||||
}
|
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
||||||
/>
|
}
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
{moduleId === "destinations" ? (
|
{moduleId === "destinations" ? (
|
||||||
<label className="switch-line">
|
<label className="switch-line">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -838,12 +887,16 @@ function SiteItemEditor({
|
|||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
</div>
|
</div>
|
||||||
<footer className="drawer-editor-footer">
|
<footer className="drawer-editor-footer">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="primary-action compact" onClick={onSave}>
|
<Button
|
||||||
|
className="primary-action compact"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
<Save size={17} />
|
<Save size={17} />
|
||||||
{isCreating ? "创建" : "保存"}
|
{saving ? "保存中" : isCreating ? "创建" : "保存"}
|
||||||
</Button>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -463,11 +463,13 @@ textarea {
|
|||||||
.maintenance-grid {
|
.maintenance-grid {
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(190px, 240px) minmax(240px, 300px) minmax(
|
grid-template-columns: minmax(190px, 240px) minmax(240px, 300px) minmax(
|
||||||
320px,
|
320px,
|
||||||
1fr
|
1fr
|
||||||
);
|
);
|
||||||
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,17 +543,26 @@ textarea {
|
|||||||
|
|
||||||
.page-rail,
|
.page-rail,
|
||||||
.module-rail {
|
.module-rail {
|
||||||
align-self: start;
|
align-self: stretch;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: fit-content;
|
height: 100%;
|
||||||
max-height: calc(100vh - 190px);
|
max-height: none;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
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 {
|
.edit-rail {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
@@ -746,6 +757,7 @@ textarea {
|
|||||||
.module-data-head p {
|
.module-data-head p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #68747d;
|
color: #68747d;
|
||||||
|
font-size: 14px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
max-width: 54ch;
|
max-width: 54ch;
|
||||||
}
|
}
|
||||||
@@ -757,6 +769,16 @@ textarea {
|
|||||||
gap: 10px;
|
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 {
|
.mapped-list-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1067,7 +1089,7 @@ textarea {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
right: 18px;
|
right: 18px;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
z-index: 40;
|
z-index: 80;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: min(360px, calc(100vw - 36px));
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export type DirtyChangeHandler = (dirty: boolean) => void;
|
|||||||
export type EditableSiteItem =
|
export type EditableSiteItem =
|
||||||
| SiteConfig["heroSlides"][number]
|
| SiteConfig["heroSlides"][number]
|
||||||
| SiteConfig["destinations"][number]
|
| SiteConfig["destinations"][number]
|
||||||
|
| SiteConfig["map"][number]
|
||||||
| SiteConfig["themes"][number]
|
| SiteConfig["themes"][number]
|
||||||
| SiteConfig["ctaBanners"][number];
|
| SiteConfig["ctaBanners"][number];
|
||||||
|
|||||||
Reference in New Issue
Block a user