feat: add campaigns module with CRUD operations and UI integration
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`。
|
||||||
|
|||||||
@@ -238,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/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`
|
- `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`
|
||||||
@@ -257,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`、`map`、`themeCards`、`sectionHeaders`、`bottomCtas` 从静态 import 改成接口加载。
|
- 把 `heroSlides`、`destinations`、`map`、`themeCards`、`campaigns`、`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 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠和底部运营入口。
|
||||||
|
|
||||||
## 适用模块
|
## 适用模块
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 |
|
| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 |
|
||||||
| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 |
|
| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 |
|
||||||
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
||||||
|
| `campaigns` | 特价优惠 | 首页特价优惠活动元信息 |
|
||||||
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
|
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
|
||||||
|
|
||||||
商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。
|
商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。
|
||||||
@@ -37,18 +38,20 @@
|
|||||||
|
|
||||||
## 后端实现重点
|
## 后端实现重点
|
||||||
|
|
||||||
WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 作为正式模块接入,而不是只在前端展示:
|
WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示:
|
||||||
|
|
||||||
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`。
|
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`ctaBanners`。
|
||||||
- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。
|
- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map` 和 `campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。
|
||||||
- `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。
|
- `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。
|
||||||
|
- `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。
|
||||||
- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。
|
- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||||
|
- `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||||
- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。
|
- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。
|
||||||
|
|
||||||
## 类型定义
|
## 类型定义
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "ctaBanners";
|
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners";
|
||||||
|
|
||||||
type SiteItemPatch = {
|
type SiteItemPatch = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -59,11 +62,16 @@ type SiteItemPatch = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
coverImage?: string | null;
|
||||||
targetType?: string | null;
|
targetType?: string | null;
|
||||||
targetValue?: string | null;
|
targetValue?: string | null;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
status?: "draft" | "published";
|
||||||
|
startsAt?: string | null;
|
||||||
|
endsAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeroSlide = {
|
type HeroSlide = {
|
||||||
@@ -101,6 +109,37 @@ type MapImageCreateInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type MapImageUpdateInput = Partial<MapImageCreateInput>;
|
type MapImageUpdateInput = Partial<MapImageCreateInput>;
|
||||||
|
|
||||||
|
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<CampaignCreateInput>;
|
||||||
```
|
```
|
||||||
|
|
||||||
各模块字段要求:
|
各模块字段要求:
|
||||||
@@ -111,9 +150,12 @@ type MapImageUpdateInput = Partial<MapImageCreateInput>;
|
|||||||
| `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` |
|
| `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` |
|
||||||
| `map` | `image` | `isActive` |
|
| `map` | `image` | `isActive` |
|
||||||
| `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
| `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` |
|
| `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
||||||
|
|
||||||
后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。
|
后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。
|
||||||
|
`campaigns` 创建时默认 `status="draft"`,不会进入 Public `site-config.campaigns`;只有 `status="published"` 的活动会进入 H5 Public API。
|
||||||
|
Admin UI 面向运营只展示活动标题、活动描述、封面图和前台启用状态;`slug` 是接口必填技术标识,前端可在新建时自动生成,编辑时复用原值。
|
||||||
|
|
||||||
## 顶部轮播 `heroSlides` 专用契约
|
## 顶部轮播 `heroSlides` 专用契约
|
||||||
|
|
||||||
@@ -310,9 +352,71 @@ PATCH /api/admin/site-config/map/:id
|
|||||||
DELETE /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[];
|
destinations: Destination[];
|
||||||
map: MapImage[];
|
map: MapImage[];
|
||||||
themes: ThemeCard[];
|
themes: ThemeCard[];
|
||||||
|
campaigns: Campaign[];
|
||||||
ctaBanners: CtaBanner[];
|
ctaBanners: CtaBanner[];
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||||
|
`campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||||
|
|
||||||
### 新增模块配置项
|
### 新增模块配置项
|
||||||
|
|
||||||
@@ -398,7 +504,7 @@ PATCH /api/admin/site-config/:module/reorder
|
|||||||
- 不允许混入其他模块 id。
|
- 不允许混入其他模块 id。
|
||||||
- 后端按数组顺序写入 `sortOrder`,从 0 开始。
|
- 后端按数组顺序写入 `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`:
|
响应状态码 `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
|
```http
|
||||||
POST /api/admin/media-assets/upload
|
POST /api/admin/media-assets/upload
|
||||||
|
|||||||
27
src/api.ts
27
src/api.ts
@@ -76,15 +76,32 @@ export type MediaAsset = {
|
|||||||
updatedAt?: string;
|
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 = {
|
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 }>;
|
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 }>;
|
||||||
|
campaigns: Campaign[];
|
||||||
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" | "map" | "themes" | "ctaBanners";
|
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners";
|
||||||
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
||||||
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
||||||
|
|
||||||
@@ -97,11 +114,19 @@ export type SiteItemPatch = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
coverImage?: string | null;
|
||||||
|
priceAmount?: number | null;
|
||||||
|
priceUnit?: string | null;
|
||||||
|
tags?: string[];
|
||||||
targetType?: string | null;
|
targetType?: string | null;
|
||||||
targetValue?: string | null;
|
targetValue?: string | null;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
status?: "draft" | "published";
|
||||||
|
startsAt?: string | null;
|
||||||
|
endsAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProductInput = {
|
export type ProductInput = {
|
||||||
|
|||||||
@@ -131,9 +131,23 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
|||||||
if (moduleId === "destinations") return "name";
|
if (moduleId === "destinations") return "name";
|
||||||
if (moduleId === "map") return "image";
|
if (moduleId === "map") return "image";
|
||||||
if (moduleId === "themes") return "label";
|
if (moduleId === "themes") return "label";
|
||||||
|
if (moduleId === "campaigns") return "title";
|
||||||
return "alt";
|
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 {
|
export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number): SiteItemPatch {
|
||||||
if (moduleId === "heroSlides") {
|
if (moduleId === "heroSlides") {
|
||||||
return { title: "", kicker: "", image: "", isActive: false, sortOrder };
|
return { title: "", kicker: "", image: "", isActive: false, sortOrder };
|
||||||
@@ -147,6 +161,20 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
|
|||||||
if (moduleId === "themes") {
|
if (moduleId === "themes") {
|
||||||
return { label: "", image: "", isActive: false, sortOrder };
|
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 };
|
return { alt: "", image: "", isActive: false, sortOrder };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +202,23 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
|
|||||||
if (moduleId === "themes") {
|
if (moduleId === "themes") {
|
||||||
payload.label = draft.label?.trim();
|
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") {
|
if (moduleId === "ctaBanners") {
|
||||||
payload.alt = draft.alt?.trim();
|
payload.alt = draft.alt?.trim();
|
||||||
}
|
}
|
||||||
@@ -204,17 +249,35 @@ export function itemName(item: EditableSiteItem) {
|
|||||||
return "地图图片";
|
return "地图图片";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemMeta(item: EditableSiteItem) {
|
export function itemMeta(item: EditableSiteItem): string {
|
||||||
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 ("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 "地图素材";
|
if (!("title" in item) && !("name" in item) && !("label" in item) && !("alt" in item)) return "地图素材";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemImage(item: EditableSiteItem) {
|
export function itemImage(item: EditableSiteItem) {
|
||||||
|
if ("coverImage" in item) return item.coverImage || "";
|
||||||
return item.image || "";
|
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) {
|
export function itemSortOrder(item: EditableSiteItem) {
|
||||||
return "sortOrder" in item ? item.sortOrder : undefined;
|
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 {
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
compactSiteItemPayload,
|
compactSiteItemPayload,
|
||||||
createEmptySiteItemDraft,
|
createEmptySiteItemDraft,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
itemMeta,
|
itemMeta,
|
||||||
itemName,
|
itemName,
|
||||||
itemSortOrder,
|
itemSortOrder,
|
||||||
|
itemTags,
|
||||||
moduleEditable,
|
moduleEditable,
|
||||||
moduleItems,
|
moduleItems,
|
||||||
siteItemPrimaryKey,
|
siteItemPrimaryKey,
|
||||||
@@ -93,6 +95,12 @@ const pageSpecs: {
|
|||||||
hint: "控制横向主题卡和搜索跳转。",
|
hint: "控制横向主题卡和搜索跳转。",
|
||||||
frontPosition: "首页「主题甄选」",
|
frontPosition: "首页「主题甄选」",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "campaigns",
|
||||||
|
label: "特价优惠",
|
||||||
|
hint: "维护活动标题、封面和发布状态,供首页特价优惠入口使用。",
|
||||||
|
frontPosition: "首页「特价优惠」",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "routeProducts",
|
id: "routeProducts",
|
||||||
label: "精选线路",
|
label: "精选线路",
|
||||||
@@ -218,6 +226,9 @@ export function StructurePage({
|
|||||||
const editableItems =
|
const editableItems =
|
||||||
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
|
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
|
||||||
const isMapModule = moduleId === "map";
|
const isMapModule = moduleId === "map";
|
||||||
|
const isCampaignModule = moduleId === "campaigns";
|
||||||
|
const canReorderSiteItems =
|
||||||
|
moduleEditable(moduleId) && !isMapModule && !isCampaignModule;
|
||||||
const canCreateSiteItem =
|
const canCreateSiteItem =
|
||||||
moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0);
|
moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0);
|
||||||
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
||||||
@@ -246,9 +257,17 @@ export function StructurePage({
|
|||||||
label: "label" in item ? item.label : undefined,
|
label: "label" in item ? item.label : undefined,
|
||||||
alt: "alt" in item ? item.alt : undefined,
|
alt: "alt" in item ? item.alt : undefined,
|
||||||
image: "image" in item ? item.image || "" : "",
|
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,
|
isHot: "isHot" in item ? item.isHot : undefined,
|
||||||
isActive: item.isActive,
|
isActive: "isActive" in item ? item.isActive : undefined,
|
||||||
sortOrder: itemSortOrder(item),
|
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 =
|
const messageText =
|
||||||
moduleId === "map"
|
moduleId === "map"
|
||||||
? "请先上传贵州地图图片。"
|
? "请先上传贵州地图图片。"
|
||||||
|
: moduleId === "campaigns"
|
||||||
|
? "请先填写特价优惠标题。"
|
||||||
: "请先填写当前模块的主标题/名称。";
|
: "请先填写当前模块的主标题/名称。";
|
||||||
notify({ tone: "warning", title: "内容未保存", message: messageText });
|
notify({ tone: "warning", title: "内容未保存", message: messageText });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
|
||||||
@@ -621,17 +641,24 @@ export function StructurePage({
|
|||||||
<b>{itemName(item)}</b>
|
<b>{itemName(item)}</b>
|
||||||
<small>
|
<small>
|
||||||
{itemMeta(item)}
|
{itemMeta(item)}
|
||||||
{!isMapModule && typeof itemSortOrder(item) === "number"
|
{canReorderSiteItems && typeof itemSortOrder(item) === "number"
|
||||||
? ` · 排序 ${itemSortOrder(item)}`
|
? ` · 排序 ${itemSortOrder(item)}`
|
||||||
: ""}
|
: ""}
|
||||||
</small>
|
</small>
|
||||||
|
{itemTags(item).length ? (
|
||||||
|
<span className="mapped-list-tags">
|
||||||
|
{itemTags(item).map((tag) => (
|
||||||
|
<em key={tag}>{tag}</em>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
className="mapped-list-actions"
|
className="mapped-list-actions"
|
||||||
aria-label={`${itemName(item)} 操作`}
|
aria-label={`${itemName(item)} 操作`}
|
||||||
>
|
>
|
||||||
{!isMapModule ? (
|
{canReorderSiteItems ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -715,6 +742,67 @@ export function StructurePage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CampaignTagEditor({
|
||||||
|
tags,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tags: string[];
|
||||||
|
onChange: (tags: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const normalizedTags = tags.slice(0, 3);
|
||||||
|
const updateTag = (index: number, value: string) => {
|
||||||
|
onChange(
|
||||||
|
normalizedTags.map((tag, tagIndex) =>
|
||||||
|
tagIndex === index ? value : tag,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const removeTag = (index: number) => {
|
||||||
|
onChange(normalizedTags.filter((_, tagIndex) => tagIndex !== index));
|
||||||
|
};
|
||||||
|
const addTag = () => {
|
||||||
|
if (normalizedTags.length >= 3) return;
|
||||||
|
onChange([...normalizedTags, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tag-editor compact-tag-editor">
|
||||||
|
{normalizedTags.map((tag, index) => (
|
||||||
|
<label className="tag-input-row" key={`${index}-${tag}`}>
|
||||||
|
标签 {index + 1}
|
||||||
|
<span>
|
||||||
|
<Input
|
||||||
|
value={tag}
|
||||||
|
onChange={(event) => updateTag(index, event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="icon-action danger-icon"
|
||||||
|
onClick={() => removeTag(index)}
|
||||||
|
aria-label={`删除标签 ${index + 1}`}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="inline-action"
|
||||||
|
onClick={addTag}
|
||||||
|
disabled={normalizedTags.length >= 3}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
添加标签
|
||||||
|
</Button>
|
||||||
|
<p className="field-help">最多维护 3 个标签,保存时会自动去掉空标签。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReferencePanel({
|
function ReferencePanel({
|
||||||
moduleId,
|
moduleId,
|
||||||
products,
|
products,
|
||||||
@@ -792,13 +880,20 @@ function SiteItemEditor({
|
|||||||
? "地图图片"
|
? "地图图片"
|
||||||
: moduleId === "themes"
|
: moduleId === "themes"
|
||||||
? "主题名称"
|
? "主题名称"
|
||||||
|
: moduleId === "campaigns"
|
||||||
|
? "活动标题"
|
||||||
: "入口文案";
|
: "入口文案";
|
||||||
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
||||||
const isImageOnlyModule = moduleId === "map";
|
const isImageOnlyModule = moduleId === "map";
|
||||||
|
const isCampaignModule = moduleId === "campaigns";
|
||||||
const editorHint = isImageOnlyModule
|
const editorHint = isImageOnlyModule
|
||||||
? isCreating
|
? isCreating
|
||||||
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
||||||
: `保存后会替换「${moduleLabel}」前台展示图片`
|
: `保存后会替换「${moduleLabel}」前台展示图片`
|
||||||
|
: isCampaignModule
|
||||||
|
? isCreating
|
||||||
|
? `创建后会进入「${moduleLabel}」的活动列表`
|
||||||
|
: `保存后会影响「${moduleLabel}」活动入口`
|
||||||
: isCreating
|
: isCreating
|
||||||
? `创建后会进入「${moduleLabel}」的数据列表`
|
? `创建后会进入「${moduleLabel}」的数据列表`
|
||||||
: `保存后会影响「${moduleLabel}」前台模块`;
|
: `保存后会影响「${moduleLabel}」前台模块`;
|
||||||
@@ -822,6 +917,30 @@ function SiteItemEditor({
|
|||||||
<div className="admin-disclosure-stack">
|
<div className="admin-disclosure-stack">
|
||||||
{!isImageOnlyModule ? (
|
{!isImageOnlyModule ? (
|
||||||
<AdminDisclosure title="展示内容">
|
<AdminDisclosure title="展示内容">
|
||||||
|
{isCampaignModule ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{primaryLabel}
|
||||||
|
<Input
|
||||||
|
value={draft.title ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onDraft({ ...draft, title: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
活动描述
|
||||||
|
<Textarea
|
||||||
|
value={draft.description ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onDraft({ ...draft, description: event.target.value })
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<label>
|
<label>
|
||||||
{primaryLabel}
|
{primaryLabel}
|
||||||
<Input
|
<Input
|
||||||
@@ -842,16 +961,74 @@ function SiteItemEditor({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
) : null}
|
) : null}
|
||||||
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : "资源图片"}>
|
{isCampaignModule ? (
|
||||||
|
<AdminDisclosure title="价格信息">
|
||||||
|
<div className="form-grid compact-grid">
|
||||||
|
<label>
|
||||||
|
价格
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft.priceAmount ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onDraft({
|
||||||
|
...draft,
|
||||||
|
priceAmount: event.target.value
|
||||||
|
? Number(event.target.value)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
价格单位
|
||||||
|
<Input
|
||||||
|
value={draft.priceUnit ?? "起/人"}
|
||||||
|
onChange={(event) =>
|
||||||
|
onDraft({ ...draft, priceUnit: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</AdminDisclosure>
|
||||||
|
) : null}
|
||||||
|
{isCampaignModule ? (
|
||||||
|
<AdminDisclosure title="活动标签">
|
||||||
|
<CampaignTagEditor
|
||||||
|
tags={draft.tags ?? []}
|
||||||
|
onChange={(tags) => onDraft({ ...draft, tags })}
|
||||||
|
/>
|
||||||
|
</AdminDisclosure>
|
||||||
|
) : null}
|
||||||
|
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : "资源图片"}>
|
||||||
<SingleImageUploader
|
<SingleImageUploader
|
||||||
value={draft.image}
|
value={isCampaignModule ? draft.coverImage : draft.image}
|
||||||
onChange={(image) => onDraft({ ...draft, image })}
|
onChange={(image) =>
|
||||||
|
onDraft(
|
||||||
|
isCampaignModule
|
||||||
|
? { ...draft, coverImage: image }
|
||||||
|
: { ...draft, image },
|
||||||
|
)
|
||||||
|
}
|
||||||
group={moduleId}
|
group={moduleId}
|
||||||
/>
|
/>
|
||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
<AdminDisclosure title={isImageOnlyModule ? "显示状态" : "排序与状态"}>
|
<AdminDisclosure title={isImageOnlyModule || isCampaignModule ? "显示状态" : "排序与状态"}>
|
||||||
|
{isCampaignModule ? (
|
||||||
|
<label className="switch-line">
|
||||||
|
<Switch
|
||||||
|
checked={draft.status === "published"}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onDraft({ ...draft, status: checked ? "published" : "draft" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
前台启用
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{!isImageOnlyModule ? (
|
{!isImageOnlyModule ? (
|
||||||
<label>
|
<label>
|
||||||
排序值
|
排序值
|
||||||
@@ -884,6 +1061,8 @@ function SiteItemEditor({
|
|||||||
/>
|
/>
|
||||||
前台启用
|
前台启用
|
||||||
</label>
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AdminDisclosure>
|
</AdminDisclosure>
|
||||||
</div>
|
</div>
|
||||||
<footer className="drawer-editor-footer">
|
<footer className="drawer-editor-footer">
|
||||||
|
|||||||
@@ -794,6 +794,32 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapped-list-main > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapped-list-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapped-list-tags em {
|
||||||
|
max-width: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid #84c9c4;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #247d79;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.mapped-list-actions {
|
.mapped-list-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1418,6 +1444,10 @@ textarea {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-tag-editor .inline-action {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-input-row span {
|
.tag-input-row span {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 38px;
|
grid-template-columns: minmax(0, 1fr) 38px;
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ export type EditableSiteItem =
|
|||||||
| SiteConfig["destinations"][number]
|
| SiteConfig["destinations"][number]
|
||||||
| SiteConfig["map"][number]
|
| SiteConfig["map"][number]
|
||||||
| SiteConfig["themes"][number]
|
| SiteConfig["themes"][number]
|
||||||
|
| SiteConfig["campaigns"][number]
|
||||||
| SiteConfig["ctaBanners"][number];
|
| SiteConfig["ctaBanners"][number];
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:4000",
|
target: "http://localhost:4001",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user