feat(admin): add hotel groups and vehicle options site modules
Add support for two new standalone site content modules in the admin dashboard: hotel groups and vehicle options. This commit includes: - Added TypeScript types for HotelGroup and VehicleOption, extended SiteConfig and SiteModule enum - Updated admin utilities (primary key lookup, empty drafts, payload compaction) for the new modules - Added management UI panels and editors in the structure page for configuring the new modules - Adjusted module rail styling and interactive states for consistent UI across the admin - Updated ctaBanners type definition to include missing sortOrder, createdAt, and updatedAt fields - Updated API and admin documentation to cover the new modules' CRUD endpoints and field contracts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 页面模块配置 Admin API 契约
|
||||
|
||||
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组和底部运营入口。
|
||||
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组、特色酒店、万趣用车和更多服务。
|
||||
|
||||
## 适用模块
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
||||
| `campaigns` | 特价优惠 | 首页特价优惠活动元信息 |
|
||||
| `routeSections` | 精选线路子分组 | 首页“精选线路”按运营任务新增分组,维护标题、副文案、启用状态和关联商品 |
|
||||
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
|
||||
| `hotelGroups` | 特色酒店 | 首页“特色酒店”卡片,维护标题、描述、封面图、启用状态和排序 |
|
||||
| `vehicleOptions` | 万趣用车 | 首页“万趣用车”卡片,维护标题、描述、封面图、启用状态和排序 |
|
||||
| `ctaBanners` | 更多服务 | 权益、管家、目的地和需求入口等更多服务卡片 |
|
||||
|
||||
商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情。
|
||||
商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情;`hotelGroups` 和 `vehicleOptions` 只维护首页卡片内容,不绑定商品本体。
|
||||
|
||||
## 通用约定
|
||||
|
||||
@@ -41,21 +43,21 @@
|
||||
|
||||
WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示:
|
||||
|
||||
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners`。
|
||||
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`hotelGroups`、`vehicleOptions`、`ctaBanners`。
|
||||
- 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map` 和 `campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。
|
||||
- `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。
|
||||
- `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。
|
||||
- `GET /api/admin/site-config` 必须返回 `routeSections`,包含未启用分组和后台已配置的全部商品 ID。
|
||||
- `GET /api/admin/site-config` 必须返回 `routeSections`,包含未启用分组和后台已配置的全部商品 ID。`hotelGroups`、`vehicleOptions` 也必须稳定返回数组,无数据时返回 `[]`。
|
||||
- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||
- `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||
- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。
|
||||
- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。`hotelGroups`、`vehicleOptions` 支持新增、更新、删除和排序。
|
||||
- 同一商品不能同时出现在多个 `routeSections` 子分组;更新 `productIds` 时后端需要校验互斥。
|
||||
- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。
|
||||
|
||||
## 类型定义
|
||||
|
||||
```ts
|
||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
|
||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
|
||||
|
||||
type SiteItemPatch = {
|
||||
title?: string;
|
||||
@@ -157,6 +159,29 @@ type RouteSection = {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type SiteCardItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type CtaBanner = {
|
||||
id: string;
|
||||
alt: string;
|
||||
image: string;
|
||||
targetType: string | null;
|
||||
targetValue: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
各模块字段要求:
|
||||
@@ -169,7 +194,9 @@ type RouteSection = {
|
||||
| `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
||||
| `campaigns` | `title`、`slug` | `description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`startsAt`、`endsAt` |
|
||||
| `routeSections` | `title` | `subtitle`、`productIds`、`isActive`、`sortOrder` |
|
||||
| `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
||||
| `hotelGroups` | `title` | `description`、`image`、`isActive`、`sortOrder` |
|
||||
| `vehicleOptions` | `title` | `description`、`image`、`isActive`、`sortOrder` |
|
||||
| `ctaBanners` | `alt`(服务标题) | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` |
|
||||
|
||||
后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。
|
||||
`campaigns` 创建时默认 `status="draft"`,不会进入 Public `site-config.campaigns`;只有 `status="published"` 的活动会进入 H5 Public API。
|
||||
@@ -491,9 +518,67 @@ PATCH /api/admin/site-config/routeSections/reorder
|
||||
- 同一商品不能出现在其他精选线路子分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。
|
||||
- `DELETE /api/admin/site-config/routeSections/:id` 删除分组配置并返回被删除 ID;只移除首页分组,不删除关联商品本体。
|
||||
|
||||
## 特色酒店和万趣用车 `hotelGroups` / `vehicleOptions` 专用契约
|
||||
|
||||
`hotelGroups` 对应首页“特色酒店”卡片,`vehicleOptions` 对应首页“万趣用车”卡片。两者都是普通首页内容卡片,不维护商品详情和商品关联。
|
||||
|
||||
字段语义:
|
||||
|
||||
| 字段 | 类型 | 创建 | 更新 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | `string` | 后端生成 | 不允许修改 | 卡片唯一 id |
|
||||
| `title` | `string` | 必填 | 可选 | 卡片标题,提交时 trim 后不能为空 |
|
||||
| `description` | `string \| null` | 可选 | 可选 | 卡片描述,空字符串可归一化为 `null` |
|
||||
| `image` | `string \| null` | 可选 | 可选 | 卡片封面图 OSS URL;上传仍走媒体接口 |
|
||||
| `isActive` | `boolean` | 可选 | 可选 | 用户侧是否展示;未传默认 `true` |
|
||||
| `sortOrder` | `number` | 可选 | 可选 | 展示顺序;未传时追加到模块末尾 |
|
||||
|
||||
两类模块均复用通用接口:
|
||||
|
||||
```http
|
||||
POST /api/admin/site-config/hotelGroups
|
||||
PATCH /api/admin/site-config/hotelGroups/:id
|
||||
DELETE /api/admin/site-config/hotelGroups/:id
|
||||
PATCH /api/admin/site-config/hotelGroups/reorder
|
||||
|
||||
POST /api/admin/site-config/vehicleOptions
|
||||
PATCH /api/admin/site-config/vehicleOptions/:id
|
||||
DELETE /api/admin/site-config/vehicleOptions/:id
|
||||
PATCH /api/admin/site-config/vehicleOptions/reorder
|
||||
```
|
||||
|
||||
删除只删除首页卡片配置,不删除任何商品、目的地或素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。
|
||||
|
||||
## 更多服务 `ctaBanners` 专用契约
|
||||
|
||||
`ctaBanners` 对应首页“更多服务”模块,用于维护权益、管家、目的地和需求入口等服务卡片。管理端复用顶部轮播的列表式操作:新增卡片、编辑标题和背景图、删除卡片、上移/下移排序,以及控制前台启用状态。
|
||||
|
||||
字段语义:
|
||||
|
||||
| 字段 | 类型 | 创建 | 更新 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | `string` | 后端生成 | 不允许修改 | 服务卡片唯一 id |
|
||||
| `alt` | `string` | 必填 | 可选 | 服务标题,展示在“更多服务”卡片上,提交时 trim 后不能为空 |
|
||||
| `image` | `string \| null` | 可选 | 可选 | 服务卡片背景图 OSS URL;上传仍走媒体接口 |
|
||||
| `targetType` | `string \| null` | 可选 | 可选 | 点击目标类型,例如权益、管家、目的地或需求入口 |
|
||||
| `targetValue` | `string \| null` | 可选 | 可选 | 点击目标值;无额外参数时可为空 |
|
||||
| `isActive` | `boolean` | 可选 | 可选 | 用户侧是否展示;未传默认 `true` |
|
||||
| `sortOrder` | `number` | 可选 | 可选 | 展示顺序;未传时追加到模块末尾 |
|
||||
|
||||
复用通用接口:
|
||||
|
||||
```http
|
||||
POST /api/admin/site-config/ctaBanners
|
||||
PATCH /api/admin/site-config/ctaBanners/:id
|
||||
DELETE /api/admin/site-config/ctaBanners/:id
|
||||
PATCH /api/admin/site-config/ctaBanners/reorder
|
||||
```
|
||||
|
||||
删除只删除首页“更多服务”卡片配置,不删除任何素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。
|
||||
|
||||
## 接口列表
|
||||
|
||||
以下路径由七类页面模块复用;`heroSlides`、`map`、`campaigns`、`routeSections` 的请求体和响应体以各自专用契约为准。
|
||||
以下路径由九类页面模块复用;`heroSlides`、`map`、`campaigns`、`routeSections`、`hotelGroups`、`vehicleOptions`、`ctaBanners` 的请求体和响应体以各自专用契约为准。
|
||||
|
||||
### 获取完整站点配置
|
||||
|
||||
@@ -511,13 +596,15 @@ type SiteConfig = {
|
||||
themes: ThemeCard[];
|
||||
campaigns: Campaign[];
|
||||
routeSections: RouteSection[];
|
||||
hotelGroups: SiteCardItem[];
|
||||
vehicleOptions: SiteCardItem[];
|
||||
ctaBanners: CtaBanner[];
|
||||
};
|
||||
```
|
||||
|
||||
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||
`campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||
`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。
|
||||
`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。`hotelGroups`、`vehicleOptions` 字段也必须稳定返回数组;无数据时返回 `[]`。
|
||||
|
||||
### 新增模块配置项
|
||||
|
||||
@@ -583,7 +670,7 @@ PATCH /api/admin/site-config/:module/reorder
|
||||
- 不允许混入其他模块 id。
|
||||
- 后端按数组顺序写入 `sortOrder`,从 0 开始。
|
||||
|
||||
`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。`routeSections` 支持排序,但 `itemIds` 必须刚好包含当前已保存的子分组 ID。
|
||||
`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。`routeSections`、`hotelGroups`、`vehicleOptions` 支持排序,但 `itemIds` 必须刚好包含当前已保存的同模块配置项 ID。
|
||||
|
||||
响应状态码 `200`:
|
||||
|
||||
|
||||
28
src/api.ts
28
src/api.ts
@@ -103,6 +103,28 @@ export type RouteSection = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type HotelGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type VehicleOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
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[];
|
||||
@@ -110,10 +132,12 @@ export type SiteConfig = {
|
||||
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
|
||||
campaigns: Campaign[];
|
||||
routeSections: RouteSection[];
|
||||
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>;
|
||||
hotelGroups: HotelGroup[];
|
||||
vehicleOptions: VehicleOption[];
|
||||
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>;
|
||||
};
|
||||
|
||||
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
|
||||
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
|
||||
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
||||
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { ProductInput } from "@/api";
|
||||
import type { Product, ProductInput } from "@/api";
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,32 +21,57 @@ type QuickRouteDraft = {
|
||||
sortWeight: number;
|
||||
};
|
||||
|
||||
const emptyDraft: QuickRouteDraft = {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
function createEmptyDraft(): QuickRouteDraft {
|
||||
return {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftFromProduct(product?: Product | null): QuickRouteDraft {
|
||||
if (!product) return createEmptyDraft();
|
||||
|
||||
const tags = product.tags.length ? product.tags.slice(0, 3) : ["", ""];
|
||||
|
||||
return {
|
||||
title: product.title,
|
||||
summary: product.summary ?? "",
|
||||
destinationName: product.subtitle ?? product.destination?.name ?? "",
|
||||
priceAmount: product.priceAmount ?? null,
|
||||
priceUnit: product.priceUnit || "起/人",
|
||||
tags: tags.length === 1 ? [...tags, ""] : tags,
|
||||
coverImage: product.coverImage ?? "",
|
||||
published: product.status === "published",
|
||||
sortWeight: product.sortWeight ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function RouteProductQuickCreateForm({
|
||||
product,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onSubmitRef,
|
||||
}: {
|
||||
product?: Product | null;
|
||||
onCreate: (input: ProductInput) => Promise<void>;
|
||||
onUpdate: (productId: string, input: ProductInput) => Promise<void>;
|
||||
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(() => createDraftFromProduct(product));
|
||||
const editing = Boolean(product);
|
||||
|
||||
const submit = async () => {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
|
||||
await onCreate({
|
||||
const input: ProductInput = {
|
||||
title,
|
||||
subtitle: draft.destinationName.trim(),
|
||||
destinationId: null,
|
||||
@@ -55,20 +80,31 @@ export function RouteProductQuickCreateForm({
|
||||
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
|
||||
coverImage: draft.coverImage.trim() || null,
|
||||
summary: draft.summary.trim(),
|
||||
images: [],
|
||||
detailSections: [],
|
||||
images: product?.images ?? [],
|
||||
detailSections: product?.detailSections ?? [],
|
||||
status: draft.published ? "published" : "draft",
|
||||
sortWeight: draft.sortWeight,
|
||||
});
|
||||
setDraft(emptyDraft);
|
||||
};
|
||||
|
||||
if (editing && product) {
|
||||
await onUpdate(product.id, input);
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreate(input);
|
||||
setDraft(createEmptyDraft());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(createDraftFromProduct(product));
|
||||
}, [product?.id, product?.updatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = submit;
|
||||
return () => {
|
||||
onSubmitRef.current = null;
|
||||
};
|
||||
}, [draft, onSubmitRef]);
|
||||
}, [draft, product?.id, product?.updatedAt, onSubmitRef]);
|
||||
|
||||
return (
|
||||
<div className="admin-disclosure-stack route-product-create-form">
|
||||
@@ -153,7 +189,7 @@ export function RouteProductQuickCreateForm({
|
||||
checked={draft.published}
|
||||
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
|
||||
/>
|
||||
创建后上架
|
||||
{editing ? "保存为上架" : "创建后上架"}
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
|
||||
@@ -3,60 +3,39 @@ import { useRef } from "react";
|
||||
|
||||
import type { Product, ProductInput } from "@/api";
|
||||
import { RouteProductQuickCreateForm } from "@/components/admin/route-sections/RouteProductQuickCreateForm";
|
||||
import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker";
|
||||
import type {
|
||||
ProductUsage,
|
||||
RouteSectionDraft,
|
||||
} from "@/components/admin/route-sections/helpers";
|
||||
import type { RouteSectionDraft } from "@/components/admin/route-sections/helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function RouteSectionProductEditor({
|
||||
draft,
|
||||
products,
|
||||
productUsage,
|
||||
product,
|
||||
saving,
|
||||
onCreateProduct,
|
||||
onProductIdsChange,
|
||||
onUpdateProduct,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
products: Product[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
product?: Product | null;
|
||||
saving: boolean;
|
||||
onCreateProduct: (input: ProductInput) => Promise<void>;
|
||||
onProductIdsChange: (productIds: string[]) => Promise<void>;
|
||||
onUpdateProduct: (productId: string, input: ProductInput) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const createSubmitRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const updateProductIds = (productIds: string[]) => {
|
||||
void onProductIdsChange(productIds);
|
||||
};
|
||||
const addProduct = (productId: string) => {
|
||||
if (draft.productIds.includes(productId)) return;
|
||||
updateProductIds([...draft.productIds, productId]);
|
||||
};
|
||||
const removeProduct = (productId: string) => {
|
||||
updateProductIds(draft.productIds.filter((candidate) => candidate !== productId));
|
||||
};
|
||||
const moveProduct = (productId: string, direction: -1 | 1) => {
|
||||
const currentIndex = draft.productIds.indexOf(productId);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= draft.productIds.length) return;
|
||||
|
||||
const nextProductIds = [...draft.productIds];
|
||||
[nextProductIds[currentIndex], nextProductIds[nextIndex]] = [
|
||||
nextProductIds[nextIndex],
|
||||
nextProductIds[currentIndex],
|
||||
];
|
||||
updateProductIds(nextProductIds);
|
||||
};
|
||||
const editing = Boolean(product);
|
||||
|
||||
return (
|
||||
<div className="mapped-editor route-section-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3 id="route-section-product-editor-title">关联线路</h3>
|
||||
<small>{draft.title} 的首页展示线路、快捷创建和排序。</small>
|
||||
<h3 id="route-section-product-editor-title">
|
||||
{editing ? "编辑线路" : "关联线路"}
|
||||
</h3>
|
||||
<small>
|
||||
{editing
|
||||
? `${draft.title} 的首页展示线路内容。`
|
||||
: `${draft.title} 的首页展示线路,创建后会自动关联到当前分组。`}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -68,17 +47,11 @@ export function RouteSectionProductEditor({
|
||||
</Button>
|
||||
</header>
|
||||
<RouteProductQuickCreateForm
|
||||
product={product}
|
||||
onCreate={onCreateProduct}
|
||||
onUpdate={onUpdateProduct}
|
||||
onSubmitRef={createSubmitRef}
|
||||
/>
|
||||
<RouteSectionProductPicker
|
||||
products={products}
|
||||
productIds={draft.productIds}
|
||||
productUsage={productUsage}
|
||||
onAdd={addProduct}
|
||||
onRemove={removeProduct}
|
||||
onMove={moveProduct}
|
||||
/>
|
||||
<footer className="drawer-editor-footer">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
@@ -89,7 +62,7 @@ export function RouteSectionProductEditor({
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "创建中" : "创建并关联"}
|
||||
{saving ? (editing ? "保存中" : "创建中") : editing ? "保存线路" : "创建并关联"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
createSiteConfigItem,
|
||||
deleteSiteConfigItem,
|
||||
reorderSiteConfigItems,
|
||||
updateProduct,
|
||||
updateSiteConfigItem,
|
||||
} from "@/api";
|
||||
import type { ProductInput } from "@/api";
|
||||
import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor";
|
||||
import {
|
||||
buildProductUsageMap,
|
||||
compactRouteSectionDraft,
|
||||
createEmptyRouteSectionDraft,
|
||||
createRouteSectionDraft,
|
||||
@@ -46,13 +46,17 @@ export function RouteSectionsPanel({
|
||||
const [draft, setDraft] = useState<RouteSectionDraft | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [productEditorOpen, setProductEditorOpen] = useState(false);
|
||||
const [editingProductId, setEditingProductId] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const createRequestSeen = useRef(createRequestKey);
|
||||
|
||||
const sortedSections = useMemo(
|
||||
() => [...routeSections].sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
() =>
|
||||
[...routeSections].sort(
|
||||
(left, right) => left.sortOrder - right.sortOrder,
|
||||
),
|
||||
[routeSections],
|
||||
);
|
||||
const productById = useMemo(
|
||||
@@ -62,10 +66,9 @@ export function RouteSectionsPanel({
|
||||
const selectedSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
sortedSections[0];
|
||||
const productUsage = useMemo(
|
||||
() => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""),
|
||||
[draft?.id, routeSections, selectedSection?.id],
|
||||
);
|
||||
const editingProduct = editingProductId
|
||||
? (productById.get(editingProductId) ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const firstSection = sortedSections[0];
|
||||
@@ -75,18 +78,23 @@ export function RouteSectionsPanel({
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ?? firstSection;
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
firstSection;
|
||||
setSelectedId(nextSection.id);
|
||||
setExpandedId((current) =>
|
||||
sortedSections.some((section) => section.id === current) ? current : nextSection.id,
|
||||
sortedSections.some((section) => section.id === current)
|
||||
? current
|
||||
: nextSection.id,
|
||||
);
|
||||
setDraft(createRouteSectionDraft(nextSection));
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
}, [routeSections]);
|
||||
@@ -99,6 +107,7 @@ export function RouteSectionsPanel({
|
||||
setDraft(createEmptyRouteSectionDraft(sortedSections.length));
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(true);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
@@ -111,14 +120,16 @@ export function RouteSectionsPanel({
|
||||
setMessage("");
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const openProductEditor = (section: RouteSection) => {
|
||||
const openProductEditor = (section: RouteSection, product?: Product) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setEditingProductId(product?.id ?? "");
|
||||
setMessage("");
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(true);
|
||||
@@ -132,6 +143,7 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
@@ -190,40 +202,6 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const updateProductLinks = async (productIds: string[]) => {
|
||||
if (!draft || saving || !draft.id) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const nextDraft = { ...draft, productIds: Array.from(new Set(productIds)) };
|
||||
setDraft(nextDraft);
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
draft.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已保存",
|
||||
message: `${draft.title} 的关联线路已更新。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "保存失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "关联线路保存失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createAndLinkProduct = async (input: ProductInput) => {
|
||||
if (!draft || saving || !draft.id) return;
|
||||
|
||||
@@ -263,10 +241,94 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const saveLinkedProduct = async (productId: string, input: ProductInput) => {
|
||||
if (saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const product = await updateProduct(
|
||||
productId,
|
||||
compactProductPayload(input),
|
||||
);
|
||||
setEditingProductId(product.id);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "线路已保存",
|
||||
message: `${product.title} 已更新。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "保存失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "线路保存失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkProductFromSection = async (
|
||||
section: RouteSection,
|
||||
product: Product,
|
||||
) => {
|
||||
if (saving) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认从「${section.title}」移除「${product.title}」?商品本体不会被删除。`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const nextDraft = {
|
||||
...createRouteSectionDraft(section),
|
||||
productIds: section.productIds.filter(
|
||||
(productId) => productId !== product.id,
|
||||
),
|
||||
};
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
section.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
if (draft?.id === section.id) {
|
||||
setDraft(nextDraft);
|
||||
}
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已移除",
|
||||
message: `${product.title} 已从 ${section.title} 中移除。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "移除失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "关联线路移除失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveSection = async (section: RouteSection, direction: -1 | 1) => {
|
||||
const currentIndex = sortedSections.findIndex((candidate) => candidate.id === section.id);
|
||||
const currentIndex = sortedSections.findIndex(
|
||||
(candidate) => candidate.id === section.id,
|
||||
);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length)
|
||||
return;
|
||||
|
||||
const nextIds = sortedSections.map((candidate) => candidate.id);
|
||||
[nextIds[currentIndex], nextIds[nextIndex]] = [
|
||||
@@ -317,6 +379,7 @@ export function RouteSectionsPanel({
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
@@ -345,7 +408,10 @@ export function RouteSectionsPanel({
|
||||
<div className="route-section-card-list">
|
||||
{sortedSections.map((section, index) => {
|
||||
const previewProducts = sectionProducts(section);
|
||||
const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length);
|
||||
const missingProductCount = Math.max(
|
||||
0,
|
||||
section.productIds.length - previewProducts.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -353,54 +419,91 @@ export function RouteSectionsPanel({
|
||||
key={section.id}
|
||||
>
|
||||
<div className="route-section-accordion-head">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className="route-section-accordion-toggle"
|
||||
onClick={() =>
|
||||
setExpandedId((current) => (current === section.id ? "" : section.id))
|
||||
setExpandedId((current) =>
|
||||
current === section.id ? "" : section.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="route-section-order">
|
||||
<Route size={18} />
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="route-section-card-copy">
|
||||
<span className="route-section-card-titleline">
|
||||
<b>{section.title}</b>
|
||||
</span>
|
||||
<small>{section.subtitle || "未填写副文案"}</small>
|
||||
<span className="route-section-card-copy">
|
||||
<span className="route-section-card-titleline">
|
||||
<b>{section.title}</b>
|
||||
</span>
|
||||
</button>
|
||||
<small>{section.subtitle || "未填写副文案"}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{expandedId === section.id ? (
|
||||
<div className="route-section-accordion-body">
|
||||
<div className="route-section-preview">
|
||||
{previewProducts.slice(0, 3).map((product) => (
|
||||
<span key={product.id}>
|
||||
{previewProducts.map((product) => (
|
||||
<div
|
||||
className="route-section-preview-item"
|
||||
key={product.id}
|
||||
>
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<i />
|
||||
)}
|
||||
<b>{product.title}</b>
|
||||
</span>
|
||||
<b title={product.title}>{product.title}</b>
|
||||
<span className="route-section-preview-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="route-section-preview-edit"
|
||||
onClick={() =>
|
||||
openProductEditor(section, product)
|
||||
}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="route-section-preview-delete"
|
||||
onClick={() =>
|
||||
unlinkProductFromSection(section, product)
|
||||
}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!previewProducts.length ? (
|
||||
<p>暂无关联线路</p>
|
||||
) : null}
|
||||
{!previewProducts.length ? <p>暂无关联线路</p> : null}
|
||||
{missingProductCount ? (
|
||||
<p>{missingProductCount} 条已配置商品暂未从商品库返回详情</p>
|
||||
<p>
|
||||
{missingProductCount}{" "}
|
||||
条已配置商品暂未从商品库返回详情
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<footer className="route-section-card-actions">
|
||||
<Badge
|
||||
variant={section.isActive ? "success" : "muted"}
|
||||
className={`status-chip ${section.isActive ? "published" : "archived"}`}
|
||||
>
|
||||
{section.isActive ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant={section.isActive ? "success" : "muted"}
|
||||
className={`status-chip ${section.isActive ? "published" : "archived"}`}
|
||||
>
|
||||
{section.isActive ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={section.productIds.length > 0 ? "success" : "muted"}
|
||||
className="shrink-0"
|
||||
>
|
||||
已关联 {section.productIds.length} 条线路
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="route-section-card-action-tools">
|
||||
<div className="route-section-card-primary-actions">
|
||||
<Button
|
||||
@@ -460,9 +563,7 @@ export function RouteSectionsPanel({
|
||||
</p>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="mapped-empty route-section-message">
|
||||
{message}
|
||||
</p>
|
||||
<p className="mapped-empty route-section-message">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{editorOpen && draft ? (
|
||||
@@ -506,11 +607,10 @@ export function RouteSectionsPanel({
|
||||
>
|
||||
<RouteSectionProductEditor
|
||||
draft={draft}
|
||||
products={products}
|
||||
productUsage={productUsage}
|
||||
product={editingProduct}
|
||||
saving={saving}
|
||||
onCreateProduct={createAndLinkProduct}
|
||||
onProductIdsChange={updateProductLinks}
|
||||
onUpdateProduct={saveLinkedProduct}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
||||
if (moduleId === "themes") return "label";
|
||||
if (moduleId === "campaigns") return "title";
|
||||
if (moduleId === "routeSections") return "title";
|
||||
if (moduleId === "hotelGroups" || moduleId === "vehicleOptions") return "title";
|
||||
return "alt";
|
||||
}
|
||||
|
||||
@@ -179,6 +180,9 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
|
||||
if (moduleId === "routeSections") {
|
||||
return { title: "", subtitle: "", productIds: [], isActive: true, sortOrder };
|
||||
}
|
||||
if (moduleId === "hotelGroups" || moduleId === "vehicleOptions") {
|
||||
return { title: "", description: "", image: "", isActive: true, sortOrder };
|
||||
}
|
||||
return { alt: "", image: "", isActive: false, sortOrder };
|
||||
}
|
||||
|
||||
@@ -232,8 +236,19 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
|
||||
payload.targetType = undefined;
|
||||
payload.targetValue = undefined;
|
||||
}
|
||||
if (moduleId === "hotelGroups" || moduleId === "vehicleOptions") {
|
||||
payload.title = draft.title?.trim();
|
||||
payload.description = draft.description?.trim() || null;
|
||||
payload.productIds = undefined;
|
||||
payload.kicker = undefined;
|
||||
payload.subtitle = undefined;
|
||||
payload.targetType = undefined;
|
||||
payload.targetValue = undefined;
|
||||
}
|
||||
if (moduleId === "ctaBanners") {
|
||||
payload.alt = draft.alt?.trim();
|
||||
payload.targetType = draft.targetType?.trim() || "";
|
||||
payload.targetValue = draft.targetValue?.trim() || null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -302,5 +317,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 === "campaigns" || moduleId === "routeSections" || moduleId === "ctaBanners";
|
||||
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "hotelGroups" || moduleId === "vehicleOptions" || moduleId === "ctaBanners";
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSecti
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -53,6 +54,13 @@ import type {
|
||||
} from "@/types/admin";
|
||||
|
||||
const NEW_SITE_ITEM_ID = "__new_site_item__";
|
||||
const moreServicesTargetOptions = [
|
||||
{ value: "", label: "按默认顺序" },
|
||||
{ value: "cardBenefits", label: "万趣权益页" },
|
||||
{ value: "service", label: "服务管家" },
|
||||
{ value: "destinationPicker", label: "目的地页" },
|
||||
{ value: "demand", label: "提交需求" },
|
||||
] as const;
|
||||
|
||||
const pageSpecs: {
|
||||
id: PageId;
|
||||
@@ -108,11 +116,23 @@ const pageSpecs: {
|
||||
hint: "按运营任务新增精选线路分组,维护标题、副文案、启用状态和关联线路顺序。",
|
||||
frontPosition: "首页精选线路",
|
||||
},
|
||||
{
|
||||
id: "hotelGroups",
|
||||
label: "特色酒店",
|
||||
hint: "维护首页特色酒店卡片标题、描述、封面图、启用状态和排序。",
|
||||
frontPosition: "首页「特色酒店」",
|
||||
},
|
||||
{
|
||||
id: "vehicleOptions",
|
||||
label: "万趣用车",
|
||||
hint: "维护首页用车卡片标题、描述、封面图、启用状态和排序。",
|
||||
frontPosition: "首页「万趣用车」",
|
||||
},
|
||||
{
|
||||
id: "ctaBanners",
|
||||
label: "底部运营入口",
|
||||
hint: "控制权益卡、服务管家入口、目的地搜索、提交需求入口。",
|
||||
frontPosition: "首页页尾 CTA",
|
||||
label: "更多服务",
|
||||
hint: "维护更多服务卡片标题、背景图、启用状态和排序。",
|
||||
frontPosition: "首页「更多服务」",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -170,9 +190,9 @@ const pageSpecs: {
|
||||
},
|
||||
{
|
||||
id: "ctaBanners",
|
||||
label: "营销 CTA",
|
||||
hint: "页尾活动和需求入口。",
|
||||
frontPosition: "首页底部运营区",
|
||||
label: "更多服务入口",
|
||||
hint: "维护更多服务里的活动和需求入口。",
|
||||
frontPosition: "首页「更多服务」",
|
||||
},
|
||||
{
|
||||
id: "campaignProducts",
|
||||
@@ -232,6 +252,7 @@ export function StructurePage({
|
||||
: [];
|
||||
const isMapModule = moduleId === "map";
|
||||
const isCampaignModule = moduleId === "campaigns";
|
||||
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
|
||||
const canReorderSiteItems =
|
||||
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule;
|
||||
const canCreateSiteItem =
|
||||
@@ -602,18 +623,26 @@ export function StructurePage({
|
||||
</div>
|
||||
<h3>页面模块</h3>
|
||||
{activePage.modules.map((module, index) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={activeModule.id === module.id ? "active" : ""}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={activeModule.id === module.id}
|
||||
className={`module-rail-item${activeModule.id === module.id ? " active" : ""}`}
|
||||
key={module.id}
|
||||
onClick={() => setModuleId(module.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setModuleId(module.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<em>{index + 1}</em>
|
||||
<span>
|
||||
<b>{module.label}</b>
|
||||
<small>{module.frontPosition}</small>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="item-rail">
|
||||
@@ -722,7 +751,9 @@ export function StructurePage({
|
||||
<p className="mapped-empty">
|
||||
{isMapModule
|
||||
? "暂无地图图片,点击上传地图。"
|
||||
: "暂无配置数据,点击新增创建第一项。"}
|
||||
: moduleId === "ctaBanners"
|
||||
? "暂无更多服务卡片,点击新增创建第一项。"
|
||||
: "暂无配置数据,点击新增创建第一项。"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -898,21 +929,22 @@ function SiteItemEditor({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const primaryKey = siteItemPrimaryKey(moduleId);
|
||||
const primaryLabel =
|
||||
moduleId === "heroSlides"
|
||||
? "轮播标题"
|
||||
: moduleId === "destinations"
|
||||
? "目的地名称"
|
||||
: moduleId === "map"
|
||||
? "地图图片"
|
||||
: moduleId === "themes"
|
||||
? "主题名称"
|
||||
: moduleId === "campaigns"
|
||||
? "活动标题"
|
||||
: "入口文案";
|
||||
const primaryLabel = (() => {
|
||||
if (moduleId === "heroSlides") return "轮播标题";
|
||||
if (moduleId === "destinations") return "目的地名称";
|
||||
if (moduleId === "map") return "地图图片";
|
||||
if (moduleId === "themes") return "主题名称";
|
||||
if (moduleId === "campaigns") return "活动标题";
|
||||
if (moduleId === "hotelGroups") return "酒店标题";
|
||||
if (moduleId === "vehicleOptions") return "用车标题";
|
||||
if (moduleId === "ctaBanners") return "服务标题";
|
||||
return "入口文案";
|
||||
})();
|
||||
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
|
||||
const isImageOnlyModule = moduleId === "map";
|
||||
const isCampaignModule = moduleId === "campaigns";
|
||||
const isMoreServicesModule = moduleId === "ctaBanners";
|
||||
const isCardContentModule = moduleId === "hotelGroups" || moduleId === "vehicleOptions";
|
||||
const editorHint = isImageOnlyModule
|
||||
? isCreating
|
||||
? `上传后会作为「${moduleLabel}」唯一展示图片`
|
||||
@@ -921,6 +953,10 @@ function SiteItemEditor({
|
||||
? isCreating
|
||||
? `创建后会进入「${moduleLabel}」的活动列表`
|
||||
: `保存后会影响「${moduleLabel}」活动入口`
|
||||
: isMoreServicesModule
|
||||
? isCreating
|
||||
? "创建后会进入首页「更多服务」卡片列表"
|
||||
: "保存后会影响首页「更多服务」卡片"
|
||||
: isCreating
|
||||
? `创建后会进入「${moduleLabel}」的数据列表`
|
||||
: `保存后会影响「${moduleLabel}」前台模块`;
|
||||
@@ -988,10 +1024,53 @@ function SiteItemEditor({
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{isCardContentModule ? (
|
||||
<label>
|
||||
卡片描述
|
||||
<Textarea
|
||||
value={draft.description ?? ""}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, description: event.target.value })
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</AdminDisclosure>
|
||||
) : null}
|
||||
{isMoreServicesModule ? (
|
||||
<AdminDisclosure title="跳转配置">
|
||||
<div className="form-grid compact-grid">
|
||||
<label>
|
||||
跳转类型
|
||||
<NativeSelect
|
||||
value={draft.targetType ?? ""}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, targetType: event.target.value })
|
||||
}
|
||||
>
|
||||
{moreServicesTargetOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label>
|
||||
目标参数
|
||||
<Input
|
||||
value={draft.targetValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, targetValue: event.target.value })
|
||||
}
|
||||
placeholder="可选,如需求页目的地"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</AdminDisclosure>
|
||||
) : null}
|
||||
{isCampaignModule ? (
|
||||
<AdminDisclosure title="价格信息">
|
||||
<div className="form-grid compact-grid">
|
||||
@@ -1030,7 +1109,7 @@ function SiteItemEditor({
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
) : null}
|
||||
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : "资源图片"}>
|
||||
<AdminDisclosure title={isImageOnlyModule ? primaryLabel : isCampaignModule ? "活动封面图" : isMoreServicesModule ? "服务卡片背景图" : "资源图片"}>
|
||||
<SingleImageUploader
|
||||
value={isCampaignModule ? draft.coverImage : draft.image}
|
||||
onChange={(image) =>
|
||||
|
||||
@@ -622,7 +622,7 @@ textarea {
|
||||
}
|
||||
|
||||
.page-rail button,
|
||||
.module-rail button,
|
||||
.module-rail-item,
|
||||
.mapped-list-main {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -636,22 +636,30 @@ textarea {
|
||||
}
|
||||
|
||||
.page-rail button,
|
||||
.module-rail button {
|
||||
.module-rail-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.module-rail button {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
min-height: 56px;
|
||||
.module-rail-item {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
padding: 10px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.module-rail-item:focus-visible {
|
||||
border-color: #247d79;
|
||||
box-shadow: 0 0 0 3px rgba(36, 125, 121, 0.14);
|
||||
}
|
||||
|
||||
.page-rail button.active,
|
||||
.module-rail button.active,
|
||||
.module-rail-item.active,
|
||||
.mapped-list-main.selected {
|
||||
border-color: #247d79;
|
||||
background: #eff9f8;
|
||||
@@ -671,7 +679,7 @@ textarea {
|
||||
}
|
||||
|
||||
.page-rail button > span,
|
||||
.module-rail button > span {
|
||||
.module-rail-item > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1237,7 +1245,6 @@ textarea {
|
||||
|
||||
.route-section-order {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 2px;
|
||||
@@ -1246,6 +1253,7 @@ textarea {
|
||||
color: #176c68;
|
||||
background: #edf8f6;
|
||||
font-weight: 800;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.route-section-card-copy {
|
||||
@@ -1295,10 +1303,10 @@ textarea {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.route-section-preview span {
|
||||
.route-section-preview-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 5px 6px;
|
||||
@@ -1329,6 +1337,44 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-section-preview-actions {
|
||||
min-width: max-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.route-section-preview-edit,
|
||||
.route-section-preview-delete {
|
||||
height: 24px;
|
||||
padding: 0 3px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-section-preview-edit {
|
||||
color: #176c68;
|
||||
}
|
||||
|
||||
.route-section-preview-edit:hover,
|
||||
.route-section-preview-edit:focus-visible {
|
||||
color: #0f5f5b;
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.route-section-preview-delete,
|
||||
.route-section-preview-delete:hover,
|
||||
.route-section-preview-delete:focus-visible,
|
||||
.route-section-preview-delete:active {
|
||||
color: #c43b32;
|
||||
background: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.route-section-preview p {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
@@ -2196,7 +2242,7 @@ button:focus-visible {
|
||||
.icon-action:hover,
|
||||
.inline-action:hover,
|
||||
.page-rail button:hover,
|
||||
.module-rail button:hover,
|
||||
.module-rail-item:hover,
|
||||
.mapped-list button:hover,
|
||||
.product-table button:hover {
|
||||
background: var(--accent);
|
||||
@@ -2266,7 +2312,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
.page-rail button,
|
||||
.module-rail button,
|
||||
.module-rail-item,
|
||||
.mapped-list-main,
|
||||
.product-table button {
|
||||
border-color: var(--border);
|
||||
@@ -2275,7 +2321,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
.page-rail button.active,
|
||||
.module-rail button.active,
|
||||
.module-rail-item.active,
|
||||
.mapped-list-main.selected,
|
||||
.product-table button.selected {
|
||||
border-color: var(--primary);
|
||||
|
||||
@@ -21,4 +21,6 @@ export type EditableSiteItem =
|
||||
| SiteConfig["themes"][number]
|
||||
| SiteConfig["campaigns"][number]
|
||||
| SiteConfig["routeSections"][number]
|
||||
| SiteConfig["hotelGroups"][number]
|
||||
| SiteConfig["vehicleOptions"][number]
|
||||
| SiteConfig["ctaBanners"][number];
|
||||
|
||||
Reference in New Issue
Block a user