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 0000000..306921d Binary files /dev/null and b/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud.bin differ diff --git a/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud_bak.bin b/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud_bak.bin new file mode 100644 index 0000000..306921d Binary files /dev/null and b/%SystemDrive%/ProgramData/SogouInput/Components/Picface/Cloud/sgim_picface_cloud_bak.bin differ diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md index 6be9400..6767fa0 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 前台页面模块中的配置数据,例如首页轮播、目的地宫格、贵州地图、主题卡片、特价优惠、精选线路分组和底部运营入口。 ## 适用模块 @@ -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; + +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 ( +
+ + +