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:
duanshuwen
2026-07-03 20:26:27 +08:00
parent 9fd91c84e5
commit 86a12dd1a0
9 changed files with 553 additions and 191 deletions

View File

@@ -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`

View File

@@ -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 };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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) =>

View File

@@ -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);

View File

@@ -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];