diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md index 6767fa0..8fd5e3e 100644 --- a/docs/admin-module-config-api.md +++ b/docs/admin-module-config-api.md @@ -1,6 +1,6 @@ # 页面模块配置 Admin API 契约 -本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组和底部运营入口。 +本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组、特色酒店、万趣用车和更多服务。 ## 适用模块 @@ -14,9 +14,11 @@ | `themes` | 主题甄选 | 首页主题卡片和跳转 | | `campaigns` | 特价优惠 | 首页特价优惠活动元信息 | | `routeSections` | 精选线路子分组 | 首页“精选线路”按运营任务新增分组,维护标题、副文案、启用状态和关联商品 | -| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA | +| `hotelGroups` | 特色酒店 | 首页“特色酒店”卡片,维护标题、描述、封面图、启用状态和排序 | +| `vehicleOptions` | 万趣用车 | 首页“万趣用车”卡片,维护标题、描述、封面图、启用状态和排序 | +| `ctaBanners` | 更多服务 | 权益、管家、目的地和需求入口等更多服务卡片 | -商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情。 +商品本体、活动商品池和线索跟进继续走独立业务接口,不混入本契约。`routeSections` 只维护精选线路子分组和商品 ID 关联,不重复编辑商品详情;`hotelGroups` 和 `vehicleOptions` 只维护首页卡片内容,不绑定商品本体。 ## 通用约定 @@ -41,21 +43,21 @@ WonderQ-Admin 后端实现页面模块配置接口时,需要把 `map` 和 `campaigns` 作为正式模块接入,而不是只在前端展示: -- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners`。 +- 模块白名单必须包含 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`hotelGroups`、`vehicleOptions`、`ctaBanners`。 - 权限校验、模块路由、服务层分发和数据模型映射都必须识别 `map` 和 `campaigns`,否则前端会收到 `MODULE_CONFIG_FORBIDDEN` 并以 toast 展示失败原因。 - `GET /api/admin/site-config` 即使没有地图数据,也必须返回 `map: []`,不要省略 `map` 字段。 - `GET /api/admin/site-config` 即使没有特价优惠数据,也必须返回 `campaigns: []`,不要省略 `campaigns` 字段。 -- `GET /api/admin/site-config` 必须返回 `routeSections`,包含未启用分组和后台已配置的全部商品 ID。 +- `GET /api/admin/site-config` 必须返回 `routeSections`,包含未启用分组和后台已配置的全部商品 ID。`hotelGroups`、`vehicleOptions` 也必须稳定返回数组,无数据时返回 `[]`。 - `map` 只维护一张图片,只需要支持查询、创建、更新、删除,不需要排序接口。 - `campaigns` 维护活动元信息,只需要支持查询、创建、更新、删除,不需要排序接口。 -- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。 +- `routeSections` 允许按运营任务新增多个子分组;已创建分组支持更新字段、商品关联、删除和分组顺序。`hotelGroups`、`vehicleOptions` 支持新增、更新、删除和排序。 - 同一商品不能同时出现在多个 `routeSections` 子分组;更新 `productIds` 时后端需要校验互斥。 - 图片上传仍走 `POST /api/admin/media-assets/upload`,模块保存接口只接收上传结果里的 OSS `url` 字段并写入 `image`。 ## 类型定义 ```ts -type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners"; +type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners"; type SiteItemPatch = { title?: string; @@ -157,6 +159,29 @@ type RouteSection = { createdAt?: string; updatedAt?: string; }; + +type SiteCardItem = { + id: string; + title: string; + description: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +type CtaBanner = { + id: string; + alt: string; + image: string; + targetType: string | null; + targetValue: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; ``` 各模块字段要求: @@ -169,7 +194,9 @@ type RouteSection = { | `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | | `campaigns` | `title`、`slug` | `description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`startsAt`、`endsAt` | | `routeSections` | `title` | `subtitle`、`productIds`、`isActive`、`sortOrder` | -| `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | +| `hotelGroups` | `title` | `description`、`image`、`isActive`、`sortOrder` | +| `vehicleOptions` | `title` | `description`、`image`、`isActive`、`sortOrder` | +| `ctaBanners` | `alt`(服务标题) | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | 后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。 `campaigns` 创建时默认 `status="draft"`,不会进入 Public `site-config.campaigns`;只有 `status="published"` 的活动会进入 H5 Public API。 @@ -491,9 +518,67 @@ PATCH /api/admin/site-config/routeSections/reorder - 同一商品不能出现在其他精选线路子分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。 - `DELETE /api/admin/site-config/routeSections/:id` 删除分组配置并返回被删除 ID;只移除首页分组,不删除关联商品本体。 +## 特色酒店和万趣用车 `hotelGroups` / `vehicleOptions` 专用契约 + +`hotelGroups` 对应首页“特色酒店”卡片,`vehicleOptions` 对应首页“万趣用车”卡片。两者都是普通首页内容卡片,不维护商品详情和商品关联。 + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| --- | --- | --- | --- | --- | +| `id` | `string` | 后端生成 | 不允许修改 | 卡片唯一 id | +| `title` | `string` | 必填 | 可选 | 卡片标题,提交时 trim 后不能为空 | +| `description` | `string \| null` | 可选 | 可选 | 卡片描述,空字符串可归一化为 `null` | +| `image` | `string \| null` | 可选 | 可选 | 卡片封面图 OSS URL;上传仍走媒体接口 | +| `isActive` | `boolean` | 可选 | 可选 | 用户侧是否展示;未传默认 `true` | +| `sortOrder` | `number` | 可选 | 可选 | 展示顺序;未传时追加到模块末尾 | + +两类模块均复用通用接口: + +```http +POST /api/admin/site-config/hotelGroups +PATCH /api/admin/site-config/hotelGroups/:id +DELETE /api/admin/site-config/hotelGroups/:id +PATCH /api/admin/site-config/hotelGroups/reorder + +POST /api/admin/site-config/vehicleOptions +PATCH /api/admin/site-config/vehicleOptions/:id +DELETE /api/admin/site-config/vehicleOptions/:id +PATCH /api/admin/site-config/vehicleOptions/reorder +``` + +删除只删除首页卡片配置,不删除任何商品、目的地或素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。 + +## 更多服务 `ctaBanners` 专用契约 + +`ctaBanners` 对应首页“更多服务”模块,用于维护权益、管家、目的地和需求入口等服务卡片。管理端复用顶部轮播的列表式操作:新增卡片、编辑标题和背景图、删除卡片、上移/下移排序,以及控制前台启用状态。 + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| --- | --- | --- | --- | --- | +| `id` | `string` | 后端生成 | 不允许修改 | 服务卡片唯一 id | +| `alt` | `string` | 必填 | 可选 | 服务标题,展示在“更多服务”卡片上,提交时 trim 后不能为空 | +| `image` | `string \| null` | 可选 | 可选 | 服务卡片背景图 OSS URL;上传仍走媒体接口 | +| `targetType` | `string \| null` | 可选 | 可选 | 点击目标类型,例如权益、管家、目的地或需求入口 | +| `targetValue` | `string \| null` | 可选 | 可选 | 点击目标值;无额外参数时可为空 | +| `isActive` | `boolean` | 可选 | 可选 | 用户侧是否展示;未传默认 `true` | +| `sortOrder` | `number` | 可选 | 可选 | 展示顺序;未传时追加到模块末尾 | + +复用通用接口: + +```http +POST /api/admin/site-config/ctaBanners +PATCH /api/admin/site-config/ctaBanners/:id +DELETE /api/admin/site-config/ctaBanners/:id +PATCH /api/admin/site-config/ctaBanners/reorder +``` + +删除只删除首页“更多服务”卡片配置,不删除任何素材库资源。Public API 只返回启用项,并按 `sortOrder` 升序输出;无启用项时 MiniAPP 使用本地 `src/content.ts` 兜底内容。 + ## 接口列表 -以下路径由七类页面模块复用;`heroSlides`、`map`、`campaigns`、`routeSections` 的请求体和响应体以各自专用契约为准。 +以下路径由九类页面模块复用;`heroSlides`、`map`、`campaigns`、`routeSections`、`hotelGroups`、`vehicleOptions`、`ctaBanners` 的请求体和响应体以各自专用契约为准。 ### 获取完整站点配置 @@ -511,13 +596,15 @@ type SiteConfig = { themes: ThemeCard[]; campaigns: Campaign[]; routeSections: RouteSection[]; + hotelGroups: SiteCardItem[]; + vehicleOptions: SiteCardItem[]; ctaBanners: CtaBanner[]; }; ``` `map` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 `campaigns` 字段必须稳定返回数组;无数据时返回空数组 `[]`。 -`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。 +`routeSections` 字段必须稳定返回数组;无数据时返回 `[]`,由管理端通过“新增”逐个创建子分组。`hotelGroups`、`vehicleOptions` 字段也必须稳定返回数组;无数据时返回 `[]`。 ### 新增模块配置项 @@ -583,7 +670,7 @@ PATCH /api/admin/site-config/:module/reorder - 不允许混入其他模块 id。 - 后端按数组顺序写入 `sortOrder`,从 0 开始。 -`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。`routeSections` 支持排序,但 `itemIds` 必须刚好包含当前已保存的子分组 ID。 +`map` 和 `campaigns` 模块不提供排序能力。若收到 `PATCH /api/admin/site-config/map/reorder` 或 `PATCH /api/admin/site-config/campaigns/reorder`,后端应返回 `400` 或 `405`,不要创建任何排序数据。`routeSections`、`hotelGroups`、`vehicleOptions` 支持排序,但 `itemIds` 必须刚好包含当前已保存的同模块配置项 ID。 响应状态码 `200`: diff --git a/src/api.ts b/src/api.ts index 7acb908..b2841e4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -103,6 +103,28 @@ export type RouteSection = { updatedAt?: string; }; +export type HotelGroup = { + id: string; + title: string; + description: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type VehicleOption = { + id: string; + title: string; + description: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + export type SiteConfig = { heroSlides: Array<{ id: string; title: string; kicker: string | null; image: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>; destinations: Destination[]; @@ -110,10 +132,12 @@ export type SiteConfig = { themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>; campaigns: Campaign[]; routeSections: RouteSection[]; - ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>; + hotelGroups: HotelGroup[]; + vehicleOptions: VehicleOption[]; + ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean; sortOrder: number; createdAt?: string; updatedAt?: string }>; }; -export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners"; +export type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners"; export type SiteConfigItem = SiteConfig[SiteModule][number]; type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem }; diff --git a/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx b/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx index 9287581..6b2c624 100644 --- a/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx +++ b/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx @@ -1,7 +1,7 @@ import { Plus, X } from "lucide-react"; import { MutableRefObject, useEffect, useState } from "react"; -import type { ProductInput } from "@/api"; +import type { Product, ProductInput } from "@/api"; import { AdminDisclosure } from "@/components/admin/AdminDisclosure"; import { SingleImageUploader } from "@/components/admin/SingleImageUploader"; import { Button } from "@/components/ui/button"; @@ -21,32 +21,57 @@ type QuickRouteDraft = { sortWeight: number; }; -const emptyDraft: QuickRouteDraft = { - title: "", - summary: "", - destinationName: "", - priceAmount: null, - priceUnit: "起/人", - tags: ["", ""], - coverImage: "", - published: true, - sortWeight: 0, -}; +function createEmptyDraft(): QuickRouteDraft { + return { + title: "", + summary: "", + destinationName: "", + priceAmount: null, + priceUnit: "起/人", + tags: ["", ""], + coverImage: "", + published: true, + sortWeight: 0, + }; +} + +function createDraftFromProduct(product?: Product | null): QuickRouteDraft { + if (!product) return createEmptyDraft(); + + const tags = product.tags.length ? product.tags.slice(0, 3) : ["", ""]; + + return { + title: product.title, + summary: product.summary ?? "", + destinationName: product.subtitle ?? product.destination?.name ?? "", + priceAmount: product.priceAmount ?? null, + priceUnit: product.priceUnit || "起/人", + tags: tags.length === 1 ? [...tags, ""] : tags, + coverImage: product.coverImage ?? "", + published: product.status === "published", + sortWeight: product.sortWeight ?? 0, + }; +} export function RouteProductQuickCreateForm({ + product, onCreate, + onUpdate, onSubmitRef, }: { + product?: Product | null; onCreate: (input: ProductInput) => Promise; + onUpdate: (productId: string, input: ProductInput) => Promise; onSubmitRef: MutableRefObject<(() => Promise) | null>; }) { - const [draft, setDraft] = useState(emptyDraft); + const [draft, setDraft] = useState(() => createDraftFromProduct(product)); + const editing = Boolean(product); const submit = async () => { const title = draft.title.trim(); if (!title) return; - await onCreate({ + const input: ProductInput = { title, subtitle: draft.destinationName.trim(), destinationId: null, @@ -55,20 +80,31 @@ export function RouteProductQuickCreateForm({ tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3), coverImage: draft.coverImage.trim() || null, summary: draft.summary.trim(), - images: [], - detailSections: [], + images: product?.images ?? [], + detailSections: product?.detailSections ?? [], status: draft.published ? "published" : "draft", sortWeight: draft.sortWeight, - }); - setDraft(emptyDraft); + }; + + if (editing && product) { + await onUpdate(product.id, input); + return; + } + + await onCreate(input); + setDraft(createEmptyDraft()); }; + useEffect(() => { + setDraft(createDraftFromProduct(product)); + }, [product?.id, product?.updatedAt]); + useEffect(() => { onSubmitRef.current = submit; return () => { onSubmitRef.current = null; }; - }, [draft, onSubmitRef]); + }, [draft, product?.id, product?.updatedAt, onSubmitRef]); return (
@@ -153,7 +189,7 @@ export function RouteProductQuickCreateForm({ checked={draft.published} onCheckedChange={(checked) => setDraft({ ...draft, published: checked })} /> - 创建后上架 + {editing ? "保存为上架" : "创建后上架"}
diff --git a/src/components/admin/route-sections/RouteSectionProductEditor.tsx b/src/components/admin/route-sections/RouteSectionProductEditor.tsx index 5017be2..d560d2e 100644 --- a/src/components/admin/route-sections/RouteSectionProductEditor.tsx +++ b/src/components/admin/route-sections/RouteSectionProductEditor.tsx @@ -3,60 +3,39 @@ import { useRef } from "react"; import type { Product, ProductInput } from "@/api"; import { RouteProductQuickCreateForm } from "@/components/admin/route-sections/RouteProductQuickCreateForm"; -import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker"; -import type { - ProductUsage, - RouteSectionDraft, -} from "@/components/admin/route-sections/helpers"; +import type { RouteSectionDraft } from "@/components/admin/route-sections/helpers"; import { Button } from "@/components/ui/button"; export function RouteSectionProductEditor({ draft, - products, - productUsage, + product, saving, onCreateProduct, - onProductIdsChange, + onUpdateProduct, onClose, }: { draft: RouteSectionDraft; - products: Product[]; - productUsage: Map; + product?: Product | null; saving: boolean; onCreateProduct: (input: ProductInput) => Promise; - onProductIdsChange: (productIds: string[]) => Promise; + onUpdateProduct: (productId: string, input: ProductInput) => Promise; onClose: () => void; }) { const createSubmitRef = useRef<(() => Promise) | null>(null); - const updateProductIds = (productIds: string[]) => { - void onProductIdsChange(productIds); - }; - const addProduct = (productId: string) => { - if (draft.productIds.includes(productId)) return; - updateProductIds([...draft.productIds, productId]); - }; - const removeProduct = (productId: string) => { - updateProductIds(draft.productIds.filter((candidate) => candidate !== productId)); - }; - const moveProduct = (productId: string, direction: -1 | 1) => { - const currentIndex = draft.productIds.indexOf(productId); - const nextIndex = currentIndex + direction; - if (currentIndex < 0 || nextIndex < 0 || nextIndex >= draft.productIds.length) return; - - const nextProductIds = [...draft.productIds]; - [nextProductIds[currentIndex], nextProductIds[nextIndex]] = [ - nextProductIds[nextIndex], - nextProductIds[currentIndex], - ]; - updateProductIds(nextProductIds); - }; + const editing = Boolean(product); return (
-

关联线路

- {draft.title} 的首页展示线路、快捷创建和排序。 +

+ {editing ? "编辑线路" : "关联线路"} +

+ + {editing + ? `${draft.title} 的首页展示线路内容。` + : `${draft.title} 的首页展示线路,创建后会自动关联到当前分组。`} +
-
diff --git a/src/components/admin/route-sections/RouteSectionsPanel.tsx b/src/components/admin/route-sections/RouteSectionsPanel.tsx index 1e28aac..395e10b 100644 --- a/src/components/admin/route-sections/RouteSectionsPanel.tsx +++ b/src/components/admin/route-sections/RouteSectionsPanel.tsx @@ -7,12 +7,12 @@ import { createSiteConfigItem, deleteSiteConfigItem, reorderSiteConfigItems, + updateProduct, updateSiteConfigItem, } from "@/api"; import type { ProductInput } from "@/api"; import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor"; import { - buildProductUsageMap, compactRouteSectionDraft, createEmptyRouteSectionDraft, createRouteSectionDraft, @@ -46,13 +46,17 @@ export function RouteSectionsPanel({ const [draft, setDraft] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [productEditorOpen, setProductEditorOpen] = useState(false); + const [editingProductId, setEditingProductId] = useState(""); const [creating, setCreating] = useState(false); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(""); const createRequestSeen = useRef(createRequestKey); const sortedSections = useMemo( - () => [...routeSections].sort((left, right) => left.sortOrder - right.sortOrder), + () => + [...routeSections].sort( + (left, right) => left.sortOrder - right.sortOrder, + ), [routeSections], ); const productById = useMemo( @@ -62,10 +66,9 @@ export function RouteSectionsPanel({ const selectedSection = sortedSections.find((section) => section.id === selectedId) ?? sortedSections[0]; - const productUsage = useMemo( - () => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""), - [draft?.id, routeSections, selectedSection?.id], - ); + const editingProduct = editingProductId + ? (productById.get(editingProductId) ?? null) + : null; useEffect(() => { const firstSection = sortedSections[0]; @@ -75,18 +78,23 @@ export function RouteSectionsPanel({ setDraft(null); setEditorOpen(false); setProductEditorOpen(false); + setEditingProductId(""); return; } const nextSection = - sortedSections.find((section) => section.id === selectedId) ?? firstSection; + sortedSections.find((section) => section.id === selectedId) ?? + firstSection; setSelectedId(nextSection.id); setExpandedId((current) => - sortedSections.some((section) => section.id === current) ? current : nextSection.id, + sortedSections.some((section) => section.id === current) + ? current + : nextSection.id, ); setDraft(createRouteSectionDraft(nextSection)); setEditorOpen(false); setProductEditorOpen(false); + setEditingProductId(""); setCreating(false); setMessage(""); }, [routeSections]); @@ -99,6 +107,7 @@ export function RouteSectionsPanel({ setDraft(createEmptyRouteSectionDraft(sortedSections.length)); setEditorOpen(true); setProductEditorOpen(false); + setEditingProductId(""); setCreating(true); setMessage(""); onDirtyChange(false); @@ -111,14 +120,16 @@ export function RouteSectionsPanel({ setMessage(""); setEditorOpen(true); setProductEditorOpen(false); + setEditingProductId(""); setCreating(false); onDirtyChange(false); }; - const openProductEditor = (section: RouteSection) => { + const openProductEditor = (section: RouteSection, product?: Product) => { setSelectedId(section.id); setExpandedId(section.id); setDraft(createRouteSectionDraft(section)); + setEditingProductId(product?.id ?? ""); setMessage(""); setEditorOpen(false); setProductEditorOpen(true); @@ -132,6 +143,7 @@ export function RouteSectionsPanel({ } setEditorOpen(false); setProductEditorOpen(false); + setEditingProductId(""); setCreating(false); setMessage(""); onDirtyChange(false); @@ -190,40 +202,6 @@ export function RouteSectionsPanel({ } }; - const updateProductLinks = async (productIds: string[]) => { - if (!draft || saving || !draft.id) return; - - setSaving(true); - setMessage(""); - - try { - const nextDraft = { ...draft, productIds: Array.from(new Set(productIds)) }; - setDraft(nextDraft); - await updateSiteConfigItem( - "routeSections", - draft.id, - compactRouteSectionDraft(nextDraft), - ); - notify({ - tone: "success", - title: "关联线路已保存", - message: `${draft.title} 的关联线路已更新。`, - }); - onDirtyChange(false); - await onReload(); - } catch (err) { - const messageText = err instanceof Error ? err.message : "保存失败"; - setMessage(messageText); - notify({ - tone: "danger", - title: "关联线路保存失败", - message: messageText, - }); - } finally { - setSaving(false); - } - }; - const createAndLinkProduct = async (input: ProductInput) => { if (!draft || saving || !draft.id) return; @@ -263,10 +241,94 @@ export function RouteSectionsPanel({ } }; + const saveLinkedProduct = async (productId: string, input: ProductInput) => { + if (saving) return; + + setSaving(true); + setMessage(""); + + try { + const product = await updateProduct( + productId, + compactProductPayload(input), + ); + setEditingProductId(product.id); + notify({ + tone: "success", + title: "线路已保存", + message: `${product.title} 已更新。`, + }); + onDirtyChange(false); + await onReload(); + } catch (err) { + const messageText = err instanceof Error ? err.message : "保存失败"; + setMessage(messageText); + notify({ + tone: "danger", + title: "线路保存失败", + message: messageText, + }); + } finally { + setSaving(false); + } + }; + + const unlinkProductFromSection = async ( + section: RouteSection, + product: Product, + ) => { + if (saving) return; + + const confirmed = window.confirm( + `确认从「${section.title}」移除「${product.title}」?商品本体不会被删除。`, + ); + if (!confirmed) return; + + setSaving(true); + setMessage(""); + + try { + const nextDraft = { + ...createRouteSectionDraft(section), + productIds: section.productIds.filter( + (productId) => productId !== product.id, + ), + }; + await updateSiteConfigItem( + "routeSections", + section.id, + compactRouteSectionDraft(nextDraft), + ); + if (draft?.id === section.id) { + setDraft(nextDraft); + } + notify({ + tone: "success", + title: "关联线路已移除", + message: `${product.title} 已从 ${section.title} 中移除。`, + }); + onDirtyChange(false); + await onReload(); + } catch (err) { + const messageText = err instanceof Error ? err.message : "移除失败"; + setMessage(messageText); + notify({ + tone: "danger", + title: "关联线路移除失败", + message: messageText, + }); + } finally { + setSaving(false); + } + }; + const moveSection = async (section: RouteSection, direction: -1 | 1) => { - const currentIndex = sortedSections.findIndex((candidate) => candidate.id === section.id); + const currentIndex = sortedSections.findIndex( + (candidate) => candidate.id === section.id, + ); const nextIndex = currentIndex + direction; - if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return; + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) + return; const nextIds = sortedSections.map((candidate) => candidate.id); [nextIds[currentIndex], nextIds[nextIndex]] = [ @@ -317,6 +379,7 @@ export function RouteSectionsPanel({ setDraft(null); setEditorOpen(false); setProductEditorOpen(false); + setEditingProductId(""); setCreating(false); onDirtyChange(false); await onReload(); @@ -345,7 +408,10 @@ export function RouteSectionsPanel({
{sortedSections.map((section, index) => { const previewProducts = sectionProducts(section); - const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length); + const missingProductCount = Math.max( + 0, + section.productIds.length - previewProducts.length, + ); return (
- + {section.subtitle || "未填写副文案"} + +
{expandedId === section.id ? (
- {previewProducts.slice(0, 3).map((product) => ( - + {previewProducts.map((product) => ( +
{product.coverImage ? ( ) : ( )} - {product.title} - + {product.title} + + + + +
))} - {!previewProducts.length ? ( -

暂无关联线路

- ) : null} + {!previewProducts.length ?

暂无关联线路

: null} {missingProductCount ? ( -

{missingProductCount} 条已配置商品暂未从商品库返回详情

+

+ {missingProductCount}{" "} + 条已配置商品暂未从商品库返回详情 +

) : null}
) : null}