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:
duanshuwen
2026-07-02 23:05:45 +08:00
parent 50be3cbfce
commit 9fd91c84e5
14 changed files with 1732 additions and 21 deletions

View File

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

View File

@@ -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({}),
});

View File

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

View 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>
);
}

View File

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

View File

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

View 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}
</>
);
}

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

View File

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

View File

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

View File

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

View File

@@ -20,4 +20,5 @@ export type EditableSiteItem =
| SiteConfig["map"][number]
| SiteConfig["themes"][number]
| SiteConfig["campaigns"][number]
| SiteConfig["routeSections"][number]
| SiteConfig["ctaBanners"][number];