From 9fd91c84e505153e0483132f50f8d41e78f0cd2f Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 2 Jul 2026 23:05:45 +0800 Subject: [PATCH] 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 --- .../Picface/Cloud/sgim_picface_cloud.bin | Bin 0 -> 172152 bytes .../Picface/Cloud/sgim_picface_cloud_bak.bin | Bin 0 -> 172152 bytes docs/admin-module-config-api.md | 91 ++- src/api.ts | 18 +- .../RouteProductQuickCreateForm.tsx | 215 ++++++++ .../route-sections/RouteSectionEditor.tsx | 107 ++++ .../RouteSectionProductEditor.tsx | 97 ++++ .../RouteSectionProductPicker.tsx | 160 ++++++ .../route-sections/RouteSectionsPanel.tsx | 521 ++++++++++++++++++ .../admin/route-sections/helpers.ts | 63 +++ src/lib/admin-utils.ts | 19 +- src/pages/structure/StructurePage.tsx | 49 +- src/styles.css | 412 ++++++++++++++ src/types/admin.ts | 1 + 14 files changed, 1732 insertions(+), 21 deletions(-) create mode 100644 %SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud.bin create mode 100644 %SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud_bak.bin create mode 100644 src/components/admin/route-sections/RouteProductQuickCreateForm.tsx create mode 100644 src/components/admin/route-sections/RouteSectionEditor.tsx create mode 100644 src/components/admin/route-sections/RouteSectionProductEditor.tsx create mode 100644 src/components/admin/route-sections/RouteSectionProductPicker.tsx create mode 100644 src/components/admin/route-sections/RouteSectionsPanel.tsx create mode 100644 src/components/admin/route-sections/helpers.ts diff --git a/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud.bin b/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud.bin new file mode 100644 index 0000000000000000000000000000000000000000..306921d17ea0219b7c0867ebd380f5d568685811 GIT binary patch literal 172152 zcmeIup$&jQ3; + +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; | `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`: diff --git a/src/api.ts b/src/api.ts index 094b9c6..7acb908 100644 --- a/src/api.ts +++ b/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({}), }); diff --git a/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx b/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx new file mode 100644 index 0000000..9287581 --- /dev/null +++ b/src/components/admin/route-sections/RouteProductQuickCreateForm.tsx @@ -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; + onSubmitRef: MutableRefObject<(() => Promise) | null>; +}) { + const [draft, setDraft] = useState(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 ( +
+ + +