feat(admin): add route sections admin management module
This commit adds the complete admin workflow for managing homepage curated route subgroups: - Add `RouteSection` type and extend `SiteConfig`/`SiteModule` to support the new module - Create all required admin components: panel, editors, product picker, and utility functions - Update admin utilities and API types to handle routeSections CRUD operations - Update documentation to include the new routeSections API contract - Add supporting CSS styles for the new UI elements
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# 页面模块配置 Admin API 契约
|
||||
|
||||
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠和底部运营入口。
|
||||
本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组和底部运营入口。
|
||||
|
||||
## 适用模块
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
| `map` | 贵州地图 | 首页「探索贵州」区域内的地图图片素材 |
|
||||
| `themes` | 主题甄选 | 首页主题卡片和跳转 |
|
||||
| `campaigns` | 特价优惠 | 首页特价优惠活动元信息 |
|
||||
| `routeSections` | 精选线路子分组 | 首页“精选线路”按运营任务新增分组,维护标题、副文案、启用状态和关联商品 |
|
||||
| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA |
|
||||
|
||||
商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。
|
||||
商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情。
|
||||
|
||||
## 通用约定
|
||||
|
||||
@@ -40,21 +41,25 @@
|
||||
|
||||
WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示:
|
||||
|
||||
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`ctaBanners`。
|
||||
- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`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。
|
||||
- `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||
- `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。
|
||||
- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。
|
||||
- 同一商品不能同时出现在多个 `routeSections` 子分组;更新 `productIds` 时后端需要校验互斥。
|
||||
- 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。
|
||||
|
||||
## 类型定义
|
||||
|
||||
```ts
|
||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners";
|
||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
|
||||
|
||||
type SiteItemPatch = {
|
||||
title?: string;
|
||||
subtitle?: string | null;
|
||||
kicker?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
@@ -69,6 +74,7 @@ type SiteItemPatch = {
|
||||
isHot?: boolean;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
productIds?: string[];
|
||||
status?: "draft" | "published";
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
@@ -140,6 +146,17 @@ type CampaignCreateInput = {
|
||||
};
|
||||
|
||||
type CampaignUpdateInput = Partial<CampaignCreateInput>;
|
||||
|
||||
type RouteSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
productIds: string[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
各模块字段要求:
|
||||
@@ -151,6 +168,7 @@ type CampaignUpdateInput = Partial<CampaignCreateInput>;
|
||||
| `map` | `image` | `isActive` |
|
||||
| `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` |
|
||||
|
||||
后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。
|
||||
@@ -414,9 +432,68 @@ 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 }`。
|
||||
|
||||
## 精选线路子分组 `routeSections` 专用契约
|
||||
|
||||
`routeSections` 对应首页“精选线路”的运营分组。运营可按任务新增分组,并维护分组标题、副文案、启用状态、分组顺序、关联线路商品和商品顺序。
|
||||
|
||||
字段语义:
|
||||
|
||||
| 字段 | 类型 | 更新 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | 不允许修改 | 系统生成分组 ID |
|
||||
| `title` | `string` | 可选 | 分组标题,不能为空 |
|
||||
| `subtitle` | `string \| null` | 可选 | 分组副文案 |
|
||||
| `productIds` | `string[]` | 可选 | 关联商品 ID,按数组顺序展示 |
|
||||
| `isActive` | `boolean` | 可选 | 用户侧是否展示该分组 |
|
||||
| `sortOrder` | `number` | 可选 | 分组展示顺序 |
|
||||
|
||||
更新分组:
|
||||
|
||||
```http
|
||||
POST /api/admin/site-config/routeSections
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "经典人文打卡线路",
|
||||
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"
|
||||
}
|
||||
```
|
||||
|
||||
响应状态码 `201`,返回创建后的子分组。`id` 由后端生成;精选线路分组按运营任务动态新增,不再限制为固定三组,也不再使用 `routes` / `routes-outdoor` / `routes-mix` 作为固定槽位。
|
||||
|
||||
```http
|
||||
PATCH /api/admin/site-config/routeSections/:id
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "经典人文打卡线路",
|
||||
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
|
||||
"isActive": true,
|
||||
"productIds": ["product-uuid-1", "product-uuid-2"]
|
||||
}
|
||||
```
|
||||
|
||||
调整分组顺序:
|
||||
|
||||
```http
|
||||
PATCH /api/admin/site-config/routeSections/reorder
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `productIds` 中的商品必须存在。
|
||||
- 同一商品不能出现在其他精选线路子分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。
|
||||
- `DELETE /api/admin/site-config/routeSections/:id` 删除分组配置并返回被删除 ID;只移除首页分组,不删除关联商品本体。
|
||||
|
||||
## 接口列表
|
||||
|
||||
以下路径由六类页面模块复用;`heroSlides`、`map`、`campaigns` 的请求体和响应体以各自专用契约为准。
|
||||
以下路径由七类页面模块复用;`heroSlides`、`map`、`campaigns`、`routeSections` 的请求体和响应体以各自专用契约为准。
|
||||
|
||||
### 获取完整站点配置
|
||||
|
||||
@@ -433,12 +510,14 @@ type SiteConfig = {
|
||||
map: MapImage[];
|
||||
themes: ThemeCard[];
|
||||
campaigns: Campaign[];
|
||||
routeSections: RouteSection[];
|
||||
ctaBanners: CtaBanner[];
|
||||
};
|
||||
```
|
||||
|
||||
`map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||
`campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。
|
||||
`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。
|
||||
|
||||
### 新增模块配置项
|
||||
|
||||
@@ -504,7 +583,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`,不要创建任何排序数据。
|
||||
`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。`routeSections` 支持排序,但 `itemIds` 必须刚好包含当前已保存的子分组 ID。
|
||||
|
||||
响应状态码 `200`:
|
||||
|
||||
|
||||
18
src/api.ts
18
src/api.ts
@@ -92,21 +92,34 @@ export type Campaign = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type RouteSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
productIds: string[];
|
||||
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[];
|
||||
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 }>;
|
||||
campaigns: Campaign[];
|
||||
routeSections: RouteSection[];
|
||||
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>;
|
||||
};
|
||||
|
||||
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "ctaBanners";
|
||||
export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
|
||||
export type SiteConfigItem = SiteConfig[SiteModule][number];
|
||||
type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem };
|
||||
|
||||
export type SiteItemPatch = {
|
||||
title?: string;
|
||||
subtitle?: string | null;
|
||||
kicker?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
@@ -124,6 +137,7 @@ export type SiteItemPatch = {
|
||||
isHot?: boolean;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
productIds?: string[];
|
||||
status?: "draft" | "published";
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
@@ -290,7 +304,7 @@ export async function publishSite() {
|
||||
}
|
||||
|
||||
export async function resetGuizhouContent() {
|
||||
return request<{ heroSlides: number; destinations: number; themes: number; ctaBanners: number; products: number }>("/api/admin/reset-guizhou-content", {
|
||||
return request<{ heroSlides: number; destinations: number; themes: number; ctaBanners: number; routeSections?: number; products: number }>("/api/admin/reset-guizhou-content", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { ProductInput } from "@/api";
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type QuickRouteDraft = {
|
||||
title: string;
|
||||
summary: string;
|
||||
destinationName: string;
|
||||
priceAmount: number | null;
|
||||
priceUnit: string;
|
||||
tags: string[];
|
||||
coverImage: string;
|
||||
published: boolean;
|
||||
sortWeight: number;
|
||||
};
|
||||
|
||||
const emptyDraft: QuickRouteDraft = {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
|
||||
export function RouteProductQuickCreateForm({
|
||||
onCreate,
|
||||
onSubmitRef,
|
||||
}: {
|
||||
onCreate: (input: ProductInput) => Promise<void>;
|
||||
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
|
||||
|
||||
const submit = async () => {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
|
||||
await onCreate({
|
||||
title,
|
||||
subtitle: draft.destinationName.trim(),
|
||||
destinationId: null,
|
||||
priceAmount: draft.priceAmount,
|
||||
priceUnit: draft.priceUnit.trim() || "起/人",
|
||||
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
|
||||
coverImage: draft.coverImage.trim() || null,
|
||||
summary: draft.summary.trim(),
|
||||
images: [],
|
||||
detailSections: [],
|
||||
status: draft.published ? "published" : "draft",
|
||||
sortWeight: draft.sortWeight,
|
||||
});
|
||||
setDraft(emptyDraft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = submit;
|
||||
return () => {
|
||||
onSubmitRef.current = null;
|
||||
};
|
||||
}, [draft, onSubmitRef]);
|
||||
|
||||
return (
|
||||
<div className="admin-disclosure-stack route-product-create-form">
|
||||
<AdminDisclosure title="展示内容">
|
||||
<label>
|
||||
线路标题
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
线路描述
|
||||
<Textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => setDraft({ ...draft, summary: event.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
目标地点
|
||||
<Input
|
||||
value={draft.destinationName}
|
||||
onChange={(event) => setDraft({ ...draft, destinationName: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="价格信息">
|
||||
<div className="form-grid compact-grid">
|
||||
<label>
|
||||
价格
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.priceAmount ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
priceAmount: event.target.value ? Number(event.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
价格单位
|
||||
<Input
|
||||
value={draft.priceUnit}
|
||||
onChange={(event) => setDraft({ ...draft, priceUnit: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="线路标签">
|
||||
<QuickTagEditor
|
||||
tags={draft.tags}
|
||||
onChange={(tags) => setDraft({ ...draft, tags })}
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="线路封面图">
|
||||
<SingleImageUploader
|
||||
value={draft.coverImage}
|
||||
onChange={(coverImage) => setDraft({ ...draft, coverImage: coverImage ?? "" })}
|
||||
group="route-section-products"
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="显示状态">
|
||||
<label>
|
||||
排序值
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.sortWeight}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, sortWeight: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-line">
|
||||
<Switch
|
||||
checked={draft.published}
|
||||
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
|
||||
/>
|
||||
创建后上架
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickTagEditor({
|
||||
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>
|
||||
);
|
||||
}
|
||||
107
src/components/admin/route-sections/RouteSectionEditor.tsx
Normal file
107
src/components/admin/route-sections/RouteSectionEditor.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import type { RouteSectionDraft } from "@/components/admin/route-sections/helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function RouteSectionEditor({
|
||||
draft,
|
||||
mode = "edit",
|
||||
saving,
|
||||
onDraft,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
mode?: "create" | "edit";
|
||||
saving: boolean;
|
||||
onDraft: (draft: RouteSectionDraft) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isCreating = mode === "create";
|
||||
|
||||
return (
|
||||
<div className="mapped-editor route-section-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3 id="route-section-editor-title">
|
||||
{isCreating ? "新增精选线路子分组" : "编辑精选线路子分组"}
|
||||
</h3>
|
||||
<small>
|
||||
{isCreating
|
||||
? "先保存标题和副文案,保存后可继续配置关联线路。"
|
||||
: "标题、副文案、启用状态和关联线路会同步到用户侧首页。"}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭精选线路编辑抽屉"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="admin-disclosure-stack">
|
||||
<AdminDisclosure title="分组内容">
|
||||
<label>
|
||||
分组标题
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => onDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
副文案
|
||||
<Textarea
|
||||
value={draft.subtitle}
|
||||
onChange={(event) => onDraft({ ...draft, subtitle: event.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
<AdminDisclosure title="排序与状态">
|
||||
<label>
|
||||
分组顺序
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.sortOrder}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-line">
|
||||
<Switch
|
||||
checked={draft.isActive}
|
||||
onCheckedChange={(checked) => onDraft({ ...draft, isActive: checked })}
|
||||
/>
|
||||
用户侧启用
|
||||
</label>
|
||||
<p className="field-help">
|
||||
{isCreating
|
||||
? "按填写的顺序保存;关闭启用后,后台可继续维护,用户侧暂不展示。"
|
||||
: "按运营任务维护精选线路分组;停用后用户侧不展示,后台仍保留配置。"}
|
||||
</p>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
<footer className="drawer-editor-footer">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={onSave}
|
||||
disabled={saving || !draft.title.trim()}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
|
||||
export function RouteSectionProductEditor({
|
||||
draft,
|
||||
products,
|
||||
productUsage,
|
||||
saving,
|
||||
onCreateProduct,
|
||||
onProductIdsChange,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
products: Product[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
saving: boolean;
|
||||
onCreateProduct: (input: ProductInput) => Promise<void>;
|
||||
onProductIdsChange: (productIds: string[]) => 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);
|
||||
};
|
||||
|
||||
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>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭关联线路抽屉"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<RouteProductQuickCreateForm
|
||||
onCreate={onCreateProduct}
|
||||
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}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={() => createSubmitRef.current?.()}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "创建中" : "创建并关联"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ArrowDown, ArrowUp, Lock, Minus, Plus } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Product } from "@/api";
|
||||
import type { ProductUsage } from "@/components/admin/route-sections/helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { productStatusLabels } from "@/lib/admin-utils";
|
||||
|
||||
export function RouteSectionProductPicker({
|
||||
products,
|
||||
productIds,
|
||||
productUsage,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: {
|
||||
products: Product[];
|
||||
productIds: string[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
onAdd: (productId: string) => void;
|
||||
onRemove: (productId: string) => void;
|
||||
onMove: (productId: string, direction: -1 | 1) => void;
|
||||
}) {
|
||||
const productById = useMemo(
|
||||
() => new Map(products.map((product) => [product.id, product])),
|
||||
[products],
|
||||
);
|
||||
const selectedIdSet = useMemo(() => new Set(productIds), [productIds]);
|
||||
const selectedProducts = productIds
|
||||
.map((productId) => productById.get(productId))
|
||||
.filter((product): product is Product => Boolean(product));
|
||||
const availableProducts = products.filter((product) => product.status === "published");
|
||||
|
||||
return (
|
||||
<div className="route-product-picker">
|
||||
<section className="route-product-block">
|
||||
<div className="route-product-list selected">
|
||||
{selectedProducts.map((product, index) => (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
trailing={
|
||||
<div className="mapped-list-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(product.id, -1)}
|
||||
aria-label={`上移 ${product.title}`}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === selectedProducts.length - 1}
|
||||
onClick={() => onMove(product.id, 1)}
|
||||
aria-label={`下移 ${product.title}`}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(product.id)}
|
||||
aria-label={`移除 ${product.title}`}
|
||||
>
|
||||
<Minus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="route-product-block">
|
||||
<div className="route-product-list">
|
||||
{availableProducts.map((product) => {
|
||||
const usage = productUsage.get(product.id);
|
||||
const selected = selectedIdSet.has(product.id);
|
||||
const disabled = Boolean(usage) || selected;
|
||||
const disabledText =
|
||||
usage
|
||||
? `已在「${usage.sectionTitle}」`
|
||||
: selected
|
||||
? "已添加"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
disabledText={disabledText}
|
||||
trailing={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onAdd(product.id)}
|
||||
>
|
||||
{disabledText ? <Lock size={14} /> : <Plus size={14} />}
|
||||
{disabledText || "添加"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({
|
||||
product,
|
||||
disabledText,
|
||||
trailing,
|
||||
}: {
|
||||
product: Product;
|
||||
disabledText?: string;
|
||||
trailing: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="route-product-row">
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<span className="image-placeholder" />
|
||||
)}
|
||||
<span className="route-product-main">
|
||||
<b>{product.title}</b>
|
||||
<small>
|
||||
{product.destination?.name || "未绑定目的地"}
|
||||
{typeof product.priceAmount === "number"
|
||||
? ` / ¥${product.priceAmount}${product.priceUnit}`
|
||||
: ""}
|
||||
</small>
|
||||
<span className="route-product-tags">
|
||||
<Badge
|
||||
className={`status-chip ${product.status}`}
|
||||
variant={product.status === "published" ? "success" : product.status === "draft" ? "warning" : "muted"}
|
||||
>
|
||||
{productStatusLabels[product.status]}
|
||||
</Badge>
|
||||
{product.tags.slice(0, 2).map((tag) => (
|
||||
<em key={tag}>{tag}</em>
|
||||
))}
|
||||
{disabledText ? <em className="muted">{disabledText}</em> : null}
|
||||
</span>
|
||||
</span>
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
src/components/admin/route-sections/RouteSectionsPanel.tsx
Normal file
521
src/components/admin/route-sections/RouteSectionsPanel.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { ArrowDown, ArrowUp, Link2, Pencil, Route, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Product, RouteSection } from "@/api";
|
||||
import {
|
||||
createProduct,
|
||||
createSiteConfigItem,
|
||||
deleteSiteConfigItem,
|
||||
reorderSiteConfigItems,
|
||||
updateSiteConfigItem,
|
||||
} from "@/api";
|
||||
import type { ProductInput } from "@/api";
|
||||
import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor";
|
||||
import {
|
||||
buildProductUsageMap,
|
||||
compactRouteSectionDraft,
|
||||
createEmptyRouteSectionDraft,
|
||||
createRouteSectionDraft,
|
||||
type RouteSectionDraft,
|
||||
} from "@/components/admin/route-sections/helpers";
|
||||
import { RouteSectionProductEditor } from "@/components/admin/route-sections/RouteSectionProductEditor";
|
||||
import { compactProductPayload } from "@/lib/admin-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DirtyChangeHandler, Notify } from "@/types/admin";
|
||||
|
||||
export function RouteSectionsPanel({
|
||||
routeSections,
|
||||
products,
|
||||
moduleLabel,
|
||||
onDirtyChange,
|
||||
notify,
|
||||
onReload,
|
||||
createRequestKey,
|
||||
}: {
|
||||
routeSections: RouteSection[];
|
||||
products: Product[];
|
||||
moduleLabel: string;
|
||||
onDirtyChange: DirtyChangeHandler;
|
||||
notify: Notify;
|
||||
onReload: () => Promise<unknown>;
|
||||
createRequestKey: number;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [expandedId, setExpandedId] = useState("");
|
||||
const [draft, setDraft] = useState<RouteSectionDraft | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [productEditorOpen, setProductEditorOpen] = useState(false);
|
||||
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],
|
||||
);
|
||||
const productById = useMemo(
|
||||
() => new Map(products.map((product) => [product.id, product])),
|
||||
[products],
|
||||
);
|
||||
const selectedSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
sortedSections[0];
|
||||
const productUsage = useMemo(
|
||||
() => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""),
|
||||
[draft?.id, routeSections, selectedSection?.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const firstSection = sortedSections[0];
|
||||
if (!firstSection) {
|
||||
setSelectedId("");
|
||||
setExpandedId("");
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ?? firstSection;
|
||||
setSelectedId(nextSection.id);
|
||||
setExpandedId((current) =>
|
||||
sortedSections.some((section) => section.id === current) ? current : nextSection.id,
|
||||
);
|
||||
setDraft(createRouteSectionDraft(nextSection));
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
}, [routeSections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (createRequestSeen.current === createRequestKey) return;
|
||||
createRequestSeen.current = createRequestKey;
|
||||
|
||||
setSelectedId("");
|
||||
setDraft(createEmptyRouteSectionDraft(sortedSections.length));
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(true);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
}, [createRequestKey, notify, onDirtyChange, sortedSections.length]);
|
||||
|
||||
const openEditor = (section: RouteSection) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setMessage("");
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const openProductEditor = (section: RouteSection) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setMessage("");
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(true);
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
if (!creating && selectedSection) {
|
||||
setDraft(createRouteSectionDraft(selectedSection));
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const updateDraft = (nextDraft: RouteSectionDraft) => {
|
||||
setDraft(nextDraft);
|
||||
onDirtyChange(true);
|
||||
};
|
||||
|
||||
const saveSection = async () => {
|
||||
if (!draft || saving) return;
|
||||
|
||||
const payload = compactRouteSectionDraft(draft);
|
||||
if (!payload.title) {
|
||||
notify({
|
||||
tone: "warning",
|
||||
title: "内容未保存",
|
||||
message: "请先填写精选线路子分组标题。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const saved = creating
|
||||
? await createSiteConfigItem("routeSections", payload)
|
||||
: await updateSiteConfigItem("routeSections", draft.id, payload);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: creating ? "子分组已新增" : "精选线路已保存",
|
||||
message: creating
|
||||
? `${draft.title} 已保存,可继续配置关联线路。`
|
||||
: `${draft.title} 的标题、状态和关联商品已更新。`,
|
||||
});
|
||||
if ("id" in saved) {
|
||||
setSelectedId(saved.id);
|
||||
setExpandedId(saved.id);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditorOpen(false);
|
||||
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 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;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const product = await createProduct(compactProductPayload(input));
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
productIds: Array.from(new Set([...draft.productIds, product.id])),
|
||||
};
|
||||
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
draft.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
setDraft(nextDraft);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已创建",
|
||||
message: `${product.title} 已创建并加入 ${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 moveSection = async (section: RouteSection, direction: -1 | 1) => {
|
||||
const currentIndex = sortedSections.findIndex((candidate) => candidate.id === section.id);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return;
|
||||
|
||||
const nextIds = sortedSections.map((candidate) => candidate.id);
|
||||
[nextIds[currentIndex], nextIds[nextIndex]] = [
|
||||
nextIds[nextIndex],
|
||||
nextIds[currentIndex],
|
||||
];
|
||||
|
||||
try {
|
||||
await reorderSiteConfigItems("routeSections", nextIds);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "精选线路顺序已更新",
|
||||
message: `${moduleLabel} 已按新的分组顺序保存。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "排序失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "精选线路排序失败",
|
||||
message: messageText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSection = async (section: RouteSection) => {
|
||||
if (saving) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认删除「${section.title}」?删除后该分组不会在首页展示,已创建的线路商品不会被删除。`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
await deleteSiteConfigItem("routeSections", section.id);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "精选线路已删除",
|
||||
message: `${section.title} 已从精选线路分组中移除。`,
|
||||
});
|
||||
setSelectedId("");
|
||||
setExpandedId("");
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
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 sectionProducts = (section: RouteSection) =>
|
||||
section.productIds
|
||||
.map((productId) => productById.get(productId))
|
||||
.filter((product): product is Product => Boolean(product));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="route-section-console">
|
||||
{sortedSections.length ? (
|
||||
<div className="route-section-card-list">
|
||||
{sortedSections.map((section, index) => {
|
||||
const previewProducts = sectionProducts(section);
|
||||
const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`route-section-accordion ${expandedId === section.id ? "open" : ""}`}
|
||||
key={section.id}
|
||||
>
|
||||
<div className="route-section-accordion-head">
|
||||
<button
|
||||
type="button"
|
||||
className="route-section-accordion-toggle"
|
||||
onClick={() =>
|
||||
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>
|
||||
</button>
|
||||
</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}>
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<i />
|
||||
)}
|
||||
<b>{product.title}</b>
|
||||
</span>
|
||||
))}
|
||||
{!previewProducts.length ? (
|
||||
<p>暂无关联线路</p>
|
||||
) : null}
|
||||
{missingProductCount ? (
|
||||
<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="route-section-card-action-tools">
|
||||
<div className="route-section-card-primary-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-delete-text"
|
||||
onClick={() => deleteSection(section)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-edit-text"
|
||||
onClick={() => openEditor(section)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-link-text"
|
||||
onClick={() => openProductEditor(section)}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
关联线路
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mapped-list-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveSection(section, -1)}
|
||||
aria-label={`上移 ${section.title}`}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === sortedSections.length - 1}
|
||||
onClick={() => moveSection(section, 1)}
|
||||
aria-label={`下移 ${section.title}`}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mapped-empty route-section-message">
|
||||
暂无已保存子分组,点击标题右侧“新增”创建第一个。
|
||||
</p>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="mapped-empty route-section-message">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{editorOpen && draft ? (
|
||||
<div className="edit-drawer-layer">
|
||||
<button
|
||||
className="edit-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="关闭精选线路编辑抽屉"
|
||||
onClick={closeEditor}
|
||||
/>
|
||||
<section
|
||||
className="edit-rail edit-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="route-section-editor-title"
|
||||
>
|
||||
<RouteSectionEditor
|
||||
draft={draft}
|
||||
mode={creating ? "create" : "edit"}
|
||||
saving={saving}
|
||||
onDraft={updateDraft}
|
||||
onSave={saveSection}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{productEditorOpen && draft ? (
|
||||
<div className="edit-drawer-layer">
|
||||
<button
|
||||
className="edit-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="关闭关联线路抽屉"
|
||||
onClick={closeEditor}
|
||||
/>
|
||||
<section
|
||||
className="edit-rail edit-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="route-section-product-editor-title"
|
||||
>
|
||||
<RouteSectionProductEditor
|
||||
draft={draft}
|
||||
products={products}
|
||||
productUsage={productUsage}
|
||||
saving={saving}
|
||||
onCreateProduct={createAndLinkProduct}
|
||||
onProductIdsChange={updateProductLinks}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/components/admin/route-sections/helpers.ts
Normal file
63
src/components/admin/route-sections/helpers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RouteSection, SiteItemPatch } from "@/api";
|
||||
|
||||
export type RouteSectionDraft = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
productIds: string[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type ProductUsage = {
|
||||
sectionId: string;
|
||||
sectionTitle: string;
|
||||
};
|
||||
|
||||
export function createRouteSectionDraft(section: RouteSection): RouteSectionDraft {
|
||||
return {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
subtitle: section.subtitle ?? "",
|
||||
productIds: [...section.productIds],
|
||||
isActive: section.isActive,
|
||||
sortOrder: section.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyRouteSectionDraft(sortOrder: number): RouteSectionDraft {
|
||||
return {
|
||||
id: "",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
productIds: [],
|
||||
isActive: true,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactRouteSectionDraft(draft: RouteSectionDraft): SiteItemPatch {
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
subtitle: draft.subtitle.trim(),
|
||||
productIds: Array.from(new Set(draft.productIds.filter(Boolean))),
|
||||
isActive: draft.isActive,
|
||||
sortOrder: draft.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductUsageMap(routeSections: RouteSection[], currentSectionId: string) {
|
||||
const usage = new Map<string, ProductUsage>();
|
||||
|
||||
routeSections.forEach((section) => {
|
||||
if (section.id === currentSectionId) return;
|
||||
section.productIds.forEach((productId) => {
|
||||
usage.set(productId, {
|
||||
sectionId: section.id,
|
||||
sectionTitle: section.title,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return usage;
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export function siteItemPrimaryKey(moduleId: SiteModule): keyof SiteItemPatch {
|
||||
if (moduleId === "map") return "image";
|
||||
if (moduleId === "themes") return "label";
|
||||
if (moduleId === "campaigns") return "title";
|
||||
if (moduleId === "routeSections") return "title";
|
||||
return "alt";
|
||||
}
|
||||
|
||||
@@ -175,6 +176,9 @@ export function createEmptySiteItemDraft(moduleId: SiteModule, sortOrder: number
|
||||
endsAt: "",
|
||||
};
|
||||
}
|
||||
if (moduleId === "routeSections") {
|
||||
return { title: "", subtitle: "", productIds: [], isActive: true, sortOrder };
|
||||
}
|
||||
return { alt: "", image: "", isActive: false, sortOrder };
|
||||
}
|
||||
|
||||
@@ -219,6 +223,15 @@ export function compactSiteItemPayload(moduleId: SiteModule, draft: SiteItemPatc
|
||||
payload.targetType = undefined;
|
||||
payload.targetValue = undefined;
|
||||
}
|
||||
if (moduleId === "routeSections") {
|
||||
payload.title = draft.title?.trim();
|
||||
payload.subtitle = draft.subtitle?.trim() || "";
|
||||
payload.productIds = Array.from(new Set((draft.productIds ?? []).filter(Boolean)));
|
||||
payload.image = undefined;
|
||||
payload.kicker = undefined;
|
||||
payload.targetType = undefined;
|
||||
payload.targetValue = undefined;
|
||||
}
|
||||
if (moduleId === "ctaBanners") {
|
||||
payload.alt = draft.alt?.trim();
|
||||
}
|
||||
@@ -258,6 +271,7 @@ export function itemMeta(item: EditableSiteItem): string {
|
||||
const status = "status" in item && typeof item.status === "string" ? item.status : "";
|
||||
return typeof item.priceAmount === "number" ? `¥${item.priceAmount}${priceUnit}` : description || status;
|
||||
}
|
||||
if ("productIds" in item) return `${item.productIds.length} 条线路 / ${item.subtitle || "精选线路子分组"}`;
|
||||
if ("description" in item) {
|
||||
const description = typeof item.description === "string" ? item.description : "";
|
||||
const status = "status" in item && typeof item.status === "string" ? item.status : "";
|
||||
@@ -269,7 +283,8 @@ export function itemMeta(item: EditableSiteItem): string {
|
||||
|
||||
export function itemImage(item: EditableSiteItem) {
|
||||
if ("coverImage" in item) return item.coverImage || "";
|
||||
return item.image || "";
|
||||
if ("image" in item) return item.image || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function itemTags(item: EditableSiteItem) {
|
||||
@@ -287,5 +302,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 === "ctaBanners";
|
||||
return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "map" || moduleId === "themes" || moduleId === "campaigns" || moduleId === "routeSections" || moduleId === "ctaBanners";
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { Product, SiteConfig, SiteItemPatch, SiteModule } from "@/api";
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import { EmptyState } from "@/components/admin/EmptyState";
|
||||
import { RouteSectionsPanel } from "@/components/admin/route-sections/RouteSectionsPanel";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -102,9 +103,9 @@ const pageSpecs: {
|
||||
frontPosition: "首页「特价优惠」",
|
||||
},
|
||||
{
|
||||
id: "routeProducts",
|
||||
id: "routeSections",
|
||||
label: "精选线路",
|
||||
hint: "从商品库选择上架线路,决定首页线路池。",
|
||||
hint: "按运营任务新增精选线路分组,维护标题、副文案、启用状态和关联线路顺序。",
|
||||
frontPosition: "首页精选线路",
|
||||
},
|
||||
{
|
||||
@@ -217,20 +218,25 @@ export function StructurePage({
|
||||
const [message, setMessage] = useState("");
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [routeSectionCreateRequest, setRouteSectionCreateRequest] = useState(0);
|
||||
|
||||
const activePage =
|
||||
pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0];
|
||||
const activeModule =
|
||||
activePage.modules.find((module) => module.id === moduleId) ??
|
||||
activePage.modules[0];
|
||||
const isRouteSectionsModule = moduleId === "routeSections";
|
||||
const editableItems =
|
||||
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
|
||||
config && moduleEditable(moduleId) && !isRouteSectionsModule
|
||||
? moduleItems(config, moduleId)
|
||||
: [];
|
||||
const isMapModule = moduleId === "map";
|
||||
const isCampaignModule = moduleId === "campaigns";
|
||||
const canReorderSiteItems =
|
||||
moduleEditable(moduleId) && !isMapModule && !isCampaignModule;
|
||||
moduleEditable(moduleId) && !isRouteSectionsModule && !isMapModule && !isCampaignModule;
|
||||
const canCreateSiteItem =
|
||||
moduleEditable(moduleId) && (!isMapModule || editableItems.length === 0);
|
||||
moduleEditable(moduleId) && !isRouteSectionsModule && (!isMapModule || editableItems.length === 0);
|
||||
const showRouteSectionCreateButton = isRouteSectionsModule;
|
||||
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
|
||||
const selectedItem = isCreatingSiteItem
|
||||
? undefined
|
||||
@@ -239,6 +245,7 @@ export function StructurePage({
|
||||
const showEditorDrawer =
|
||||
editorOpen &&
|
||||
moduleEditable(moduleId) &&
|
||||
!isRouteSectionsModule &&
|
||||
(isCreatingSiteItem || Boolean(selectedItem));
|
||||
const gridClassName = [
|
||||
"maintenance-grid",
|
||||
@@ -271,7 +278,7 @@ export function StructurePage({
|
||||
});
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const load = async (): Promise<SiteConfig> => {
|
||||
const [site, productResult] = await Promise.all([
|
||||
getSiteConfig(),
|
||||
getProducts(),
|
||||
@@ -291,6 +298,7 @@ export function StructurePage({
|
||||
setDraftFromItem(first);
|
||||
}
|
||||
}
|
||||
return site;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -309,7 +317,7 @@ export function StructurePage({
|
||||
}, [fixedPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !moduleEditable(moduleId)) {
|
||||
if (!config || !moduleEditable(moduleId) || isRouteSectionsModule) {
|
||||
setSelectedId("");
|
||||
setDraft({});
|
||||
setEditorOpen(false);
|
||||
@@ -338,7 +346,7 @@ export function StructurePage({
|
||||
};
|
||||
|
||||
const startCreateSiteItem = () => {
|
||||
if (!moduleEditable(moduleId)) return;
|
||||
if (!moduleEditable(moduleId) || isRouteSectionsModule) return;
|
||||
setSelectedId(NEW_SITE_ITEM_ID);
|
||||
setDraft(createEmptySiteItemDraft(moduleId, editableItems.length));
|
||||
setMessage("");
|
||||
@@ -375,6 +383,7 @@ export function StructurePage({
|
||||
saving ||
|
||||
!config ||
|
||||
!moduleEditable(moduleId) ||
|
||||
isRouteSectionsModule ||
|
||||
(!selectedId && !isCreatingSiteItem)
|
||||
)
|
||||
return;
|
||||
@@ -451,7 +460,7 @@ export function StructurePage({
|
||||
};
|
||||
|
||||
const removeSiteItem = async (item: EditableSiteItem) => {
|
||||
if (!moduleEditable(moduleId)) return;
|
||||
if (!moduleEditable(moduleId) || isRouteSectionsModule) return;
|
||||
const confirmed = window.confirm(
|
||||
`确认删除「${itemName(item)}」吗?删除后需要后端同步移除对应模块配置。`,
|
||||
);
|
||||
@@ -477,7 +486,7 @@ export function StructurePage({
|
||||
};
|
||||
|
||||
const moveSiteItem = async (item: EditableSiteItem, direction: -1 | 1) => {
|
||||
if (!moduleEditable(moduleId)) return;
|
||||
if (!moduleEditable(moduleId) || isRouteSectionsModule) return;
|
||||
const currentIndex = editableItems.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
@@ -621,9 +630,27 @@ export function StructurePage({
|
||||
<Plus size={16} />
|
||||
{isMapModule ? "上传地图" : "新增"}
|
||||
</Button>
|
||||
) : showRouteSectionCreateButton ? (
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={() => setRouteSectionCreateRequest((current) => current + 1)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
新增
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
{moduleEditable(moduleId) ? (
|
||||
{isRouteSectionsModule ? (
|
||||
<RouteSectionsPanel
|
||||
routeSections={config.routeSections}
|
||||
products={products}
|
||||
moduleLabel={activeModule.label}
|
||||
onDirtyChange={onDirtyChange}
|
||||
notify={notify}
|
||||
onReload={load}
|
||||
createRequestKey={routeSectionCreateRequest}
|
||||
/>
|
||||
) : moduleEditable(moduleId) ? (
|
||||
<div className="mapped-list">
|
||||
{editableItems.map((item, index) => (
|
||||
<div className="mapped-list-row" key={item.id}>
|
||||
|
||||
412
src/styles.css
412
src/styles.css
@@ -1111,6 +1111,418 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-section-console {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.route-section-card-list {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.route-section-accordion {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce3e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.route-section-accordion:hover {
|
||||
border-color: #b8d5d1;
|
||||
background: #fbfefd;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.route-section-accordion.open {
|
||||
border-color: #247d79;
|
||||
background: #f8fdfc;
|
||||
}
|
||||
|
||||
.route-section-accordion-head {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.route-section-accordion-toggle {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.route-section-accordion-toggle:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.route-section-accordion-toggle:hover .route-section-card-titleline b {
|
||||
color: #176c68;
|
||||
}
|
||||
|
||||
.route-section-delete-text,
|
||||
.route-section-edit-text,
|
||||
.route-section-link-text {
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
color: #176c68;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-section-delete-text {
|
||||
color: #c43b32;
|
||||
}
|
||||
|
||||
.route-section-edit-text:hover,
|
||||
.route-section-link-text:hover {
|
||||
color: #0f5f5b;
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.route-section-delete-text:hover,
|
||||
.route-section-delete-text:focus-visible,
|
||||
.route-section-delete-text:active {
|
||||
color: #c43b32;
|
||||
background: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.route-section-delete-text:focus-visible {
|
||||
outline: 2px solid rgba(196, 59, 50, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.route-section-edit-text:focus-visible,
|
||||
.route-section-link-text:focus-visible {
|
||||
color: #0f5f5b;
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.route-section-edit-text:active,
|
||||
.route-section-link-text:active {
|
||||
color: #0b4d49;
|
||||
}
|
||||
|
||||
.route-section-order {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 2px;
|
||||
border: 1px solid #cfe5e2;
|
||||
border-radius: 6px;
|
||||
color: #176c68;
|
||||
background: #edf8f6;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-section-card-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.route-section-card-titleline {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.route-section-card-titleline b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
color: #1f2a31;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-section-card-copy small {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
color: #68747d;
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.route-section-accordion .status-chip {
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.route-section-accordion-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-left: 74px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.route-section-preview {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.route-section-preview span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #edf2f4;
|
||||
border-radius: 6px;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.route-section-preview img,
|
||||
.route-section-preview i {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
background: #d7dee3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.route-section-preview i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.route-section-preview b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #34414a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-section-preview p {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border: 1px dashed #d4dee5;
|
||||
border-radius: 6px;
|
||||
color: #68747d;
|
||||
background: #f8fafb;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.route-section-card-actions {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e4ecef;
|
||||
}
|
||||
|
||||
.route-section-card-action-tools,
|
||||
.route-section-card-primary-actions {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.route-section-card-action-tools {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.route-section-card-primary-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.route-section-card-actions .mapped-list-actions {
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
margin-left: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.route-section-card-actions .mapped-list-actions button,
|
||||
.route-section-card-actions .mapped-list-actions button:last-child {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: #7f8c95;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.route-section-card-actions .mapped-list-actions button:not(:disabled):hover {
|
||||
color: #1f2a31;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.route-section-card-actions .mapped-list-actions button:disabled {
|
||||
color: #c0c8ce;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.route-section-message {
|
||||
border-color: #efcfc7;
|
||||
color: #9c3d2f;
|
||||
background: #fff8f6;
|
||||
}
|
||||
|
||||
.route-product-picker {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.route-product-block {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.route-product-block header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.route-product-block header span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.route-product-block header b {
|
||||
color: #1f2a31;
|
||||
}
|
||||
|
||||
.route-product-block header small {
|
||||
color: #68747d;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.route-product-search {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.route-product-search svg {
|
||||
color: #68747d;
|
||||
}
|
||||
|
||||
.route-product-list {
|
||||
max-height: 340px;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.route-product-list.selected {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.route-product-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid #dbe3e8;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.route-product-row img,
|
||||
.route-product-row .image-placeholder {
|
||||
width: 56px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.route-product-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.route-product-main b,
|
||||
.route-product-main small {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-product-main small {
|
||||
color: #68747d;
|
||||
}
|
||||
|
||||
.route-product-tags {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.route-product-tags em {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #d8e3e8;
|
||||
border-radius: 4px;
|
||||
color: #46656a;
|
||||
background: #f7fafb;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-product-tags em.muted {
|
||||
color: #68747d;
|
||||
background: #eef2f4;
|
||||
}
|
||||
|
||||
.admin-toast-stack {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
|
||||
@@ -20,4 +20,5 @@ export type EditableSiteItem =
|
||||
| SiteConfig["map"][number]
|
||||
| SiteConfig["themes"][number]
|
||||
| SiteConfig["campaigns"][number]
|
||||
| SiteConfig["routeSections"][number]
|
||||
| SiteConfig["ctaBanners"][number];
|
||||
|
||||
Reference in New Issue
Block a user