diff --git a/AGENTS.md b/AGENTS.md index 0e09022..69f9559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ yarn dev 默认访问地址: ```text -http://localhost:5601 +http://localhost:5602 ``` 本地联调要求: @@ -88,7 +88,7 @@ yarn build yarn preview ``` -默认预览端口为 `5602`。 +默认预览端口为 `5603`。 ## 测试流程 diff --git a/README.md b/README.md index e608685..cb7c537 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ yarn install yarn dev ``` -访问:http://localhost:5601 +访问:http://localhost:5602 + +局域网访问可使用本机 IP,例如 `http://192.168.1.23:5602/`。该地址由 Vite dev server 提供,支持样式和组件热更新。 默认后台账号由 `WonderQ-Admin` 提供:`admin@example.com / ChangeMe123!`。 @@ -33,6 +35,6 @@ yarn dev - `src/styles.css`:Tailwind 入口、设计变量、后台布局和响应式样式。 - `public/assets/`:后台预览所需静态资源,保留 `/assets/...` 引用方式。 - `components.json`:shadcn/ui 组件配置。 -- `vite.config.ts`:开发环境将 `/api` 代理到 `http://localhost:4000`,并接入 Tailwind Vite 插件与 `@/*` 路径别名。 +- `vite.config.ts`:开发环境将 `/api` 代理到 `http://localhost:4000`,dev server 固定运行在 `5602` 并支持 HMR;生产构建预览运行在 `5603`。 本仓库已经独立,不再依赖 `WonderQ-MiniAPP` 的相对路径。 diff --git a/docs/README.md b/docs/README.md index 383d67d..295a6be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,5 +3,6 @@ 本目录存放后台管理前端相关文档。 - `admin-backend-plan.md`:从原 MiniAPP 迁出的后台管理规划,保留管理模块、页面能力和与后端 API 的协作边界。 +- `admin-module-config-api.md`:页面模块配置 CRUD 接口契约,供 WonderQ-Admin 后端实现首页轮播、目的地、主题卡和 CTA 等模块配置接口。 -后端 API 文档位于 `D:\www\znkj\WonderQ-Admin\docs`。 \ No newline at end of file +后端 API 文档位于 `D:\www\znkj\WonderQ-Admin\docs`。 diff --git a/docs/admin-module-config-api.md b/docs/admin-module-config-api.md new file mode 100644 index 0000000..3c0fd59 --- /dev/null +++ b/docs/admin-module-config-api.md @@ -0,0 +1,401 @@ +# 页面模块配置 Admin API 契约 + +本文档定义 WonderQ-Admin 后端需要为 WonderQ-Admin-UI 实现的页面模块配置 CRUD 接口。接口用于维护小程序/H5 前台页面模块中的配置数据,例如首页轮播、目的地宫格、主题卡片和底部运营入口。 + +## 适用模块 + +当前一期只纳入结构化页面配置模块: + +| module | 名称 | 用途 | +| -------------- | ------------ | ---------------------------------------------- | +| `heroSlides` | 顶部轮播 | 首页首屏轮播图、标题短文案、展示排序和启用状态 | +| `destinations` | 目的地 | 首页/目的地页展示、搜索入口和热门标记 | +| `themes` | 主题甄选 | 首页主题卡片和跳转 | +| `ctaBanners` | 底部运营入口 | 权益卡、管家入口、需求入口等 CTA | + +商品池、活动商品池和线索跟进继续走独立业务接口,不混入本契约。 + +## 通用约定 + +- 所有接口前缀为 `/api/admin`。 +- 所有接口需要校验 `Authorization: Bearer `。 +- 请求和响应均为 `application/json`。 +- 字段使用 camelCase。 +- `PATCH` 为部分更新,只修改请求体中出现的字段。 +- 删除接口返回 JSON,不能返回空 body,因为当前前端请求封装会读取 JSON。 + +错误响应保持当前前端兼容格式: + +```json +{ + "message": "模块不存在或无权限操作", + "code": "MODULE_CONFIG_FORBIDDEN", + "details": {} +} +``` + +## 类型定义 + +```ts +type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners"; + +type SiteItemPatch = { + title?: string; + kicker?: string; + name?: string; + slug?: string; + region?: string | null; + label?: string; + alt?: string; + image?: string | null; + targetType?: string | null; + targetValue?: string | null; + isHot?: boolean; + isActive?: boolean; + sortOrder?: number; +}; + +type HeroSlide = { + id: string; + title: string; + kicker: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +type HeroSlideCreateInput = { + title: string; + kicker?: string | null; + image?: string | null; + isActive?: boolean; + sortOrder?: number; +}; + +type HeroSlideUpdateInput = Partial; +``` + +各模块字段要求: + +| module | 创建必填 | 可选字段 | +| -------------- | -------- | ------------------------------------------------------------- | +| `heroSlides` | `title` | `kicker`、`image`、`isActive`、`sortOrder` | +| `destinations` | `name` | `slug`、`region`、`image`、`isHot`、`isActive`、`sortOrder` | +| `themes` | `label` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | +| `ctaBanners` | `alt` | `image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | + +后端可以在创建时补全 `id`、默认 `isActive=true`、默认 `sortOrder=当前模块最后一位`。 + +## 顶部轮播 `heroSlides` 专用契约 + +顶部轮播对应当前管理端抽屉中的 3 个区域: + +- 展示内容:`title`、`kicker` +- 资源图片:`image` +- 排序与状态:`sortOrder`、`isActive` + +字段语义: + +| 字段 | 类型 | 创建 | 更新 | 说明 | +| ----------- | ---------------- | -------- | ---------- | ---------------------------------------------------------------------- | +| `id` | `string` | 后端生成 | 不允许修改 | 顶部轮播配置项唯一 id | +| `title` | `string` | 必填 | 可选 | 前台轮播主标题,提交时需要去除首尾空格,不能为空 | +| `kicker` | `string \| null` | 可选 | 可选 | 副标题/短文案,空字符串可归一化为 `null` 或 `""`,前后端需保持响应一致 | +| `image` | `string \| null` | 可选 | 可选 | 单张轮播图地址或素材 URL;未上传时为 `null` | +| `sortOrder` | `number` | 可选 | 可选 | 展示顺序,整数;未传时追加到当前模块末尾 | +| `isActive` | `boolean` | 可选 | 可选 | 前台是否展示;未传时默认 `true` | +| `createdAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | +| `updatedAt` | `string` | 后端生成 | 后端维护 | ISO 时间字符串,可选返回 | + +顶部轮播不定义跳转能力。`heroSlides` 的创建、更新、查询响应中不要返回 `targetType`、`targetValue`;兼容期如果请求体携带这两个字段,后端可以忽略,但不要写入顶部轮播业务数据。 + +### 顶部轮播 CRUD + +新增顶部轮播: + +```http +POST /api/admin/site-config/heroSlides +``` + +请求体: + +```json +{ + "title": "新轮播", + "kicker": "贵州小包团首选", + "image": "/assets/source/hero.jpg", + "isActive": true, + "sortOrder": 0 +} +``` + +响应状态码 `201`: + +```json +{ + "id": "slide_001", + "title": "新轮播", + "kicker": "贵州小包团首选", + "image": "/assets/source/hero.jpg", + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-07-01T08:00:00.000Z", + "updatedAt": "2026-07-01T08:00:00.000Z" +} +``` + +更新顶部轮播: + +```http +PATCH /api/admin/site-config/heroSlides/:id +``` + +请求体为 `HeroSlideUpdateInput`,只提交需要修改的字段: + +```json +{ + "title": "夏日贵州小包团", + "image": null, + "isActive": false +} +``` + +响应状态码 `200`,响应体返回更新后的完整 `HeroSlide`。 + +删除顶部轮播: + +```http +DELETE /api/admin/site-config/heroSlides/:id +``` + +响应状态码 `200`: + +```json +{ + "id": "slide_001" +} +``` + +删除后后端需要重新整理 `heroSlides` 内剩余项的 `sortOrder`。 + +调整顶部轮播顺序: + +```http +PATCH /api/admin/site-config/heroSlides/reorder +``` + +请求体: + +```json +{ + "itemIds": ["slide_002", "slide_001", "slide_003"] +} +``` + +响应状态码 `200`: + +```json +{ + "items": [ + { + "id": "slide_002", + "title": "第二张", + "kicker": null, + "image": null, + "sortOrder": 0, + "isActive": true + }, + { + "id": "slide_001", + "title": "第一张", + "kicker": null, + "image": null, + "sortOrder": 1, + "isActive": true + } + ] +} +``` + +## 接口列表 + +以下路径由四类页面模块复用;`heroSlides` 的请求体和响应体以“顶部轮播专用契约”为准。 + +### 获取完整站点配置 + +```http +GET /api/admin/site-config +``` + +响应: + +```ts +type SiteConfig = { + heroSlides: HeroSlide[]; + destinations: Destination[]; + themes: ThemeCard[]; + ctaBanners: CtaBanner[]; +}; +``` + +### 新增模块配置项 + +```http +POST /api/admin/site-config/:module +``` + +请求体为 `SiteItemPatch`。响应状态码 `201`,响应体返回创建后的完整配置项: + +```json +{ + "id": "slide_001", + "title": "新轮播", + "kicker": "", + "image": null, + "isActive": true, + "sortOrder": 5 +} +``` + +### 更新模块配置项 + +```http +PATCH /api/admin/site-config/:module/:id +``` + +请求体为 `SiteItemPatch`。响应状态码 `200`,响应体返回更新后的完整配置项。 + +### 删除模块配置项 + +```http +DELETE /api/admin/site-config/:module/:id +``` + +响应状态码 `200`,响应体: + +```json +{ + "id": "slide_001" +} +``` + +删除后后端需要重新整理同模块内剩余项的 `sortOrder`。 + +### 调整模块配置顺序 + +```http +PATCH /api/admin/site-config/:module/reorder +``` + +请求体: + +```json +{ + "itemIds": ["slide_002", "slide_001", "slide_003"] +} +``` + +约束: + +- `itemIds` 必须包含该模块当前全部配置项 id。 +- 不允许重复 id。 +- 不允许混入其他模块 id。 +- 后端按数组顺序写入 `sortOrder`,从 0 开始。 + +响应状态码 `200`: + +```json +{ + "items": [ + { "id": "slide_002", "title": "第二张", "sortOrder": 0, "isActive": true }, + { "id": "slide_001", "title": "第一张", "sortOrder": 1, "isActive": true } + ] +} +``` + +如果后端使用 Express/Fastify 等路由,`/:module/reorder` 需要注册在 `/:module/:id` 之前,避免 `reorder` 被当成 id。 + +## 状态码 + +| 状态码 | 场景 | +| ------ | ------------------------------------------------ | +| `200` | 查询、更新、删除、排序成功 | +| `201` | 创建成功 | +| `400` | module 非法、请求体格式错误、排序 id 不完整 | +| `401` | 未登录或 token 无效 | +| `403` | 无权限维护页面配置 | +| `404` | 配置项不存在 | +| `409` | 删除被发布版本、商品或活动引用的配置项时发生冲突 | +| `422` | 字段校验失败,例如必填标题为空 | +| `500` | 服务端异常 | + +## 前端联调入口 + +WonderQ-Admin-UI 当前调用函数位于 `src/api.ts`: + +- `getSiteConfig()` +- `createSiteConfigItem(module, input)` +- `updateSiteConfigItem(module, id, input)` +- `deleteSiteConfigItem(module, id)` +- `reorderSiteConfigItems(module, itemIds)` + +维护地图 UI 位于 `src/pages/structure/StructurePage.tsx`。 + +## 图片素材上传 + +用于 WonderQ-Admin-UI 在维护页面模块图片时上传本地图片,并把返回的 OSS URL 写入 `image` 字段。 + +```http +POST /api/admin/media-assets/upload +Content-Type: multipart/form-data +Authorization: Bearer +``` + +表单字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | `File` | 是 | 图片文件,仅支持 JPG、PNG、WebP、GIF | +| `group` | `string` | 否 | 素材分组,只能包含字母、数字、下划线和中划线;默认 `general` | + +限制: + +- 单个文件最大 5MB。 +- 后端会校验 `Content-Type` 和文件头魔数,拒绝 SVG、非图片文件和伪装类型。 +- 后端将文件上传到 OSS,返回可用于前台展示的 URL,并写入 `MediaAsset` 素材记录。 + +响应状态码 `201`: + +```json +{ + "id": "asset_001", + "url": "https://bucket.oss-cn-example.aliyuncs.com/admin/heroSlides/2026/07/01/example.png", + "name": "hero.png", + "mimeType": "image/png", + "sizeBytes": 102400, + "group": "heroSlides", + "createdAt": "2026-07-01T08:00:00", + "updatedAt": "2026-07-01T08:00:00" +} +``` + +常见错误: + +| 状态码 | `code` | 场景 | +| --- | --- | --- | +| `400` | `MEDIA_UPLOAD_INVALID_TYPE` | 文件不是允许的图片类型 | +| `400` | `MEDIA_UPLOAD_TYPE_MISMATCH` | `Content-Type` 与文件头不一致 | +| `400` | `MEDIA_UPLOAD_INVALID_GROUP` | `group` 格式非法 | +| `413` | `MEDIA_UPLOAD_TOO_LARGE` | 文件超过 5MB | +| `503` | `MEDIA_STORAGE_NOT_CONFIGURED` | OSS 环境配置不完整 | +| `502` | `MEDIA_STORAGE_UPLOAD_FAILED` | OSS 上传失败 | + +前端封装位于 `src/api.ts`: + +```ts +uploadMediaAsset(file: File, group?: string): Promise +``` + +`src/components/admin/SingleImageUploader.tsx` 已使用该封装;结构维护页会把当前模块 id 作为 `group` 上传。 diff --git a/package.json b/package.json index 33d4abf..12960b5 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0 --port 5601", + "dev": "vite", "build": "tsc --noEmit && vite build", - "preview": "vite preview --host 0.0.0.0 --port 5602" + "preview": "vite preview" }, "dependencies": { "@radix-ui/react-select": "^2.3.2", diff --git a/src/App.tsx b/src/App.tsx index acac029..f19291b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,1373 +1,15 @@ -import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react"; -import { - AlertTriangle, - ArrowLeft, - Blocks, - ChevronRight, - CheckCircle2, - Eye, - FileText, - Home, - Image as ImageIcon, - LogOut, - MapPin, - Package, - Plus, - RefreshCcw, - Save, - Send, - Trash2, - X, - UserRoundCheck, -} from "lucide-react"; -import { - clearToken, - createProduct, - Destination, - getDestinations, - getLeads, - getProducts, - getSiteConfig, - getToken, - Lead, - login, - Product, - ProductDetailBlock, - ProductDetailSection, - ProductInput, - publishSite, - resetGuizhouContent, - setToken, - SiteConfig, - SiteItemPatch, - SiteModule, - updateLeadStatus, - updateProduct, - updateSiteConfigItem, -} from "./api"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { NativeSelect } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; +import { useState } from "react"; -type Tab = "structure" | "home" | "destinations" | "products" | "leads"; -type PageId = "home" | "destination" | "detail" | "campaign" | "demand"; -type ModuleId = SiteModule | "routeProducts" | "campaignProducts" | "leadFlow"; -type Toast = { id: string; tone: "success" | "warning" | "danger"; title: string; message: string }; - -type EditableSiteItem = SiteConfig["heroSlides"][number] | SiteConfig["destinations"][number] | SiteConfig["themes"][number] | SiteConfig["ctaBanners"][number]; - -const sidebarGroups: { id: string; label: string; items: { id: Tab; label: string; icon: typeof Home; description: string }[] }[] = [ - { - id: "workspace", - label: "工作台", - items: [{ id: "structure", label: "维护地图", icon: Blocks, description: "前台结构总览" }], - }, - { - id: "frontend", - label: "前台页面", - items: [ - { id: "home", label: "小程序首页", icon: Home, description: "轮播、主题、CTA" }, - { id: "destinations", label: "目的地页", icon: MapPin, description: "字典、搜索、商品" }, - { id: "products", label: "商品维护", icon: Package, description: "列表、卡片、详情" }, - ], - }, - { - id: "operations", - label: "业务处理", - items: [{ id: "leads", label: "需求线索", icon: UserRoundCheck, description: "表单和跟进" }], - }, -]; - -const tabs = sidebarGroups.flatMap((group) => group.items); - -const leadStatusLabels: Record = { - new: "新线索", - assigned: "已分配", - contacted: "已联系", - planning: "方案中", - won: "已成交", - invalid: "无效", -}; - -const pageSpecs: { - id: PageId; - title: string; - subtitle: string; - frontPath: string; - modules: { id: ModuleId; label: string; hint: string; frontPosition: string }[]; -}[] = [ - { - id: "home", - title: "万趣首页", - subtitle: "维护用户进入小程序后第一屏到页尾的内容顺序。", - frontPath: "前台:首页 / bottom tab「万趣」", - modules: [ - { id: "heroSlides", label: "顶部轮播", hint: "控制首屏大图、活动入口和主视觉顺序。", frontPosition: "首页第一屏" }, - { id: "destinations", label: "探索贵州", hint: "控制省内目的地宫格、热门标记和搜索入口。", frontPosition: "首页「探索贵州」" }, - { id: "themes", label: "主题甄选", hint: "控制横向主题卡和搜索跳转。", frontPosition: "首页「主题甄选」" }, - { id: "routeProducts", label: "精选线路", hint: "从商品库选择上架线路,决定首页线路池。", frontPosition: "首页精选线路" }, - { id: "ctaBanners", label: "底部运营入口", hint: "控制权益卡、服务管家入口、目的地搜索、提交需求入口。", frontPosition: "首页页尾 CTA" }, - ], - }, - { - id: "destination", - title: "目的地页", - subtitle: "维护目的地、别名和与商品的关联,影响搜索和目的地 tab。", - frontPath: "前台:bottom tab「目的地」/ 搜索弹层", - modules: [ - { id: "destinations", label: "目的地字典", hint: "维护名称、封面、热门状态和启停。", frontPosition: "目的地首页和搜索列表" }, - { id: "routeProducts", label: "目的地商品", hint: "商品绑定目的地后会进入对应结果页。", frontPosition: "搜索结果页产品列表" }, - ], - }, - { - id: "detail", - title: "商品维护", - subtitle: "维护用户点击商品卡后的详情页内容和售卖状态。", - frontPath: "前台:产品卡点击后详情页", - modules: [{ id: "routeProducts", label: "商品库", hint: "编辑标题、价格、封面、标签、详情模块和上下架。", frontPosition: "详情页、搜索页、首页商品卡" }], - }, - { - id: "campaign", - title: "活动专题", - subtitle: "维护轮播、主题卡和 CTA 指向的活动内容。", - frontPath: "前台:轮播 / 主题卡 / CTA 跳转", - modules: [ - { id: "heroSlides", label: "轮播活动入口", hint: "首屏主视觉上的活动入口。", frontPosition: "首页轮播" }, - { id: "themes", label: "主题活动入口", hint: "主题卡片进入对应活动专题。", frontPosition: "主题甄选横滑区" }, - { id: "ctaBanners", label: "营销 CTA", hint: "页尾活动和需求入口。", frontPosition: "首页底部运营区" }, - { id: "campaignProducts", label: "活动商品池", hint: "活动专题内展示的商品来自商品库。", frontPosition: "活动专题产品列表" }, - ], - }, - { - id: "demand", - title: "需求线索", - subtitle: "维护用户提交出行需求后的分配和跟进。", - frontPath: "前台:提交出行需求 / 立即咨询", - modules: [{ id: "leadFlow", label: "线索跟进", hint: "查看联系方式、目的地、来源商品并推进状态。", frontPosition: "需求表单提交后" }], - }, -]; - -function formatDate(value: string) { - return new Intl.DateTimeFormat("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(value)); -} - -function toProductInput(product?: Product | null): ProductInput { - const summary = product?.summary || product?.tags?.join(" · ") || "万趣贵州小包团定制体验"; - return { - title: product?.title ?? "", - subtitle: product?.subtitle ?? "", - destinationId: product?.destinationId ?? product?.destination?.id ?? null, - priceAmount: product?.priceAmount ?? null, - priceUnit: product?.priceUnit ?? "起/人", - tags: product?.tags ?? [], - coverImage: product?.coverImage ?? "", - summary: product?.summary ?? "", - images: product?.images?.map((image, index) => ({ - url: image.url, - alt: image.alt ?? "", - sortOrder: image.sortOrder ?? index, - })) ?? [], - detailSections: product?.detailSections?.length - ? product.detailSections - : defaultAdminDetailSections(summary, product?.destination?.name ?? "贵州省内定制"), - status: product?.status ?? "draft", - sortWeight: product?.sortWeight ?? 0, - }; -} - -function defaultAdminDetailSections(summary: string, location: string): ProductDetailSection[] { - return [ - { key: "overview", label: "行程概述", title: "小包团专属概览", blocks: [{ type: "text", text: `${summary}。万趣会按同行人、预算、酒店偏好和体力强度重排细节,保留小车小团、错峰入园与在地向导服务。` }] }, - { key: "itinerary", label: "每日行程", title: `${location} 弹性安排`, blocks: [{ type: "text", text: "默认按抵达接站、核心景点游览、特色体验、酒店休整和返程送站安排每日节奏;具体天数、停留时长和餐食可在行前由服务管家二次确认。" }] }, - { key: "service", label: "包含/不含服务", title: "费用边界清晰", blocks: [{ type: "text", text: "通常包含当地用车、行程内住宿、列明门票/体验、必要讲解和服务管家跟进;大交通、个人消费、未列明餐食和自选项目以最终方案为准。" }] }, - { key: "notice", label: "出行须知", title: "贵州山地旅行提示", blocks: [{ type: "text", text: "贵州多山多雨,建议准备防滑鞋、轻便雨具和薄外套;溶洞、漂流、徒步等体验会按天气和同行人体力调整。" }] }, - { key: "price", label: "价格区间", title: "按人数、酒店和季节报价", blocks: [{ type: "text", text: "页面价格为参考起价,节假日、旺季房态、用车车型和体验资源会影响最终报价;提交需求后由服务管家给出可执行方案。" }] }, - { key: "manager", label: "服务管家", title: "直接添加服务管家", blocks: [{ type: "text", text: "点击底部“服务管家”或拨打 18786174929,可直接添加服务管家沟通出行人数、日期、酒店偏好和预算。" }] }, - ]; -} - -const productStatusLabels: Record = { - draft: "草稿", - published: "已上架", - archived: "已归档", -}; - -const detailSectionTemplates: Pick[] = [ - { key: "overview", label: "行程概述", title: "小包团专属概览" }, - { key: "itinerary", label: "每日行程", title: "弹性安排" }, - { key: "service", label: "包含/不含服务", title: "费用边界清晰" }, - { key: "notice", label: "出行须知", title: "贵州山地旅行提示" }, - { key: "price", label: "价格区间", title: "按人数、酒店和季节报价" }, - { key: "manager", label: "服务管家", title: "直接添加服务管家" }, -]; - -function createEmptyDetailSection(index: number): ProductDetailSection { - const template = detailSectionTemplates[index % detailSectionTemplates.length]; - return { - key: template.key, - label: template.label, - title: template.title, - blocks: [{ type: "text", text: "" }], - }; -} - -function createDetailBlock(type: ProductDetailBlock["type"]): ProductDetailBlock { - return type === "image" ? { type: "image", url: "", alt: "" } : { type: "text", text: "" }; -} - -function compactProductPayload(draft: ProductInput): ProductInput { - return { - ...draft, - title: draft.title.trim(), - subtitle: draft.subtitle?.trim() || "", - priceUnit: draft.priceUnit?.trim() || "起/人", - tags: draft.tags.map((tag) => tag.trim()).filter(Boolean), - images: (draft.images ?? []) - .map((image, index) => ({ - url: image.url.trim(), - alt: image.alt?.trim() || null, - sortOrder: index, - })) - .filter((image) => image.url.length > 0), - detailSections: (draft.detailSections ?? []) - .map((section) => ({ - key: section.key.trim(), - label: section.label.trim(), - title: section.title?.trim() || null, - blocks: section.blocks - .map((block) => - block.type === "image" - ? { type: "image" as const, url: block.url.trim(), alt: block.alt?.trim() || null } - : { type: "text" as const, text: block.text.trim() }, - ) - .filter((block) => (block.type === "image" ? block.url.length > 0 : block.text.length > 0)), - })) - .filter((section) => section.key.length > 0 && section.label.length > 0 && section.blocks.length > 0), - priceAmount: draft.priceAmount === null ? undefined : Number(draft.priceAmount), - destinationId: draft.destinationId || null, - }; -} - -function itemName(item: EditableSiteItem) { - if ("title" in item) return item.title; - if ("name" in item) return item.name; - if ("label" in item) return item.label; - return item.alt; -} - -function itemMeta(item: EditableSiteItem) { - if ("kicker" in item) return item.kicker || "轮播"; - if ("aliases" in item) return item.aliases?.map((alias) => alias.alias).join(" / ") || "目的地"; - if ("targetType" in item) return item.targetType || "跳转未配置"; - return ""; -} - -function itemImage(item: EditableSiteItem) { - return item.image || ""; -} - -function moduleItems(config: SiteConfig, moduleId: SiteModule): EditableSiteItem[] { - return config[moduleId] as EditableSiteItem[]; -} - -function moduleEditable(moduleId: ModuleId): moduleId is SiteModule { - return moduleId === "heroSlides" || moduleId === "destinations" || moduleId === "themes" || moduleId === "ctaBanners"; -} - -function AdminDisclosure({ - title, - description, - meta, - children, - defaultOpen = false, -}: { - title: string; - description: string; - meta: string; - children: ReactNode; - defaultOpen?: boolean; -}) { - return ( -
- - - {title} - {description} - - - {meta} - - - -
{children}
-
- ); -} - -function ToastStack({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) { - if (!toasts.length) return null; - return ( -
- {toasts.map((toast) => ( -
- {toast.tone === "success" ? : } - - {toast.title} - {toast.message} - - -
- ))} -
- ); -} - -function LoginScreen({ onDone }: { onDone: () => void }) { - const [email, setEmail] = useState("admin@example.com"); - const [password, setPassword] = useState("ChangeMe123!"); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - setLoading(true); - setError(""); - try { - const result = await login(email, password); - setToken(result.token); - onDone(); - } catch (err) { - setError(err instanceof Error ? err.message : "登录失败"); - } finally { - setLoading(false); - } - }; - - return ( -
- - - 万趣运营后台 - 万趣小程序管理端 - 按前台页面结构维护内容、商品和线索。 - - -
- - - {error ? ( - - {error} - - ) : null} - -
-
-
-
- ); -} - -function StructureWorkbench({ - fixedPage, - onJump, - onDirtyChange, - notify, -}: { - fixedPage?: PageId; - onJump: (tab: Tab) => void; - onDirtyChange: (dirty: boolean) => void; - notify: (toast: Omit) => void; -}) { - const [config, setConfig] = useState(null); - const [products, setProducts] = useState([]); - const [pageId, setPageId] = useState(fixedPage ?? "home"); - const [moduleId, setModuleId] = useState("heroSlides"); - const [selectedId, setSelectedId] = useState(""); - const [draft, setDraft] = useState({}); - const [message, setMessage] = useState(""); - - const activePage = pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0]; - const activeModule = activePage.modules.find((module) => module.id === moduleId) ?? activePage.modules[0]; - - const load = async () => { - const [site, productResult] = await Promise.all([getSiteConfig(), getProducts()]); - setConfig(site); - setProducts(productResult.items); - const firstModule = activePage.modules[0]?.id ?? "heroSlides"; - setModuleId((current) => (activePage.modules.some((module) => module.id === current) ? current : firstModule)); - if (moduleEditable(firstModule)) { - const first = moduleItems(site, firstModule)[0]; - if (first && !selectedId) { - setSelectedId(first.id); - setDraftFromItem(first); - } - } - }; - - useEffect(() => { - load().catch((err) => setMessage(err.message)); - }, []); - - useEffect(() => { - if (!fixedPage) return; - setPageId(fixedPage); - const next = pageSpecs.find((page) => page.id === fixedPage)?.modules[0]?.id ?? "heroSlides"; - setModuleId(next); - setSelectedId(""); - }, [fixedPage]); - - useEffect(() => { - if (!config || !moduleEditable(moduleId)) return; - const first = moduleItems(config, moduleId)[0]; - if (!first) return; - setSelectedId(first.id); - setDraftFromItem(first); - }, [moduleId, config]); - - const setDraftFromItem = (item: EditableSiteItem) => { - setDraft({ - title: "title" in item ? item.title : undefined, - kicker: "kicker" in item ? item.kicker || "" : undefined, - name: "name" in item ? item.name : undefined, - label: "label" in item ? item.label : undefined, - alt: "alt" in item ? item.alt : undefined, - image: "image" in item ? item.image || "" : "", - targetType: "targetType" in item ? item.targetType || "" : undefined, - targetValue: "targetValue" in item ? item.targetValue || "" : undefined, - isActive: item.isActive, - }); - }; - - const selectPage = (next: PageId) => { - setPageId(next); - const page = pageSpecs.find((item) => item.id === next); - setModuleId(page?.modules[0]?.id ?? "heroSlides"); - setSelectedId(""); - setMessage(""); - onDirtyChange(false); - }; - - const selectItem = (item: EditableSiteItem) => { - setSelectedId(item.id); - setDraftFromItem(item); - setMessage(""); - onDirtyChange(false); - }; - - const save = async () => { - if (!config || !moduleEditable(moduleId) || !selectedId) return; - await updateSiteConfigItem(moduleId, selectedId, draft); - setMessage("已保存,前台对应模块会使用新配置。"); - onDirtyChange(false); - notify({ tone: "success", title: "内容已保存", message: `${activeModule.label} 已同步到维护数据。` }); - await load(); - }; - - const publish = async () => { - const result = await publishSite(); - setMessage(`已生成发布版本:${result.title}`); - notify({ tone: "success", title: "已生成发布版本", message: result.title }); - }; - - const resetToGuizhou = async () => { - const result = await resetGuizhouContent(); - setMessage(`已重置贵州内容:${result.products} 条商品、${result.destinations} 个目的地。`); - notify({ tone: "success", title: "贵州内容已重置", message: "后台配置、商品库和素材引用已切换为贵州省内定制游。" }); - setSelectedId(""); - setDraft({}); - onDirtyChange(false); - await load(); - }; - - const editableItems = config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : []; - const selectedItem = editableItems.find((item) => item.id === selectedId) ?? editableItems[0]; - - if (!config) return ; - - return ( -
-
-
-

{fixedPage ? activePage.title : "小程序维护地图"}

-

{fixedPage ? activePage.subtitle : "按用户看到的前台页面组织后台,逐层展开到可维护内容。"}

-
-
- - - -
-
- {message ?

{message}

: null} -
- {!fixedPage ? ( - - ) : ( -
- 当前维护页面 -

{activePage.title}

-

{activePage.frontPath}

- {activePage.subtitle} -
- )} -
-
- - - {activePage.frontPath} - {activePage.subtitle} - -
-

页面模块

- {activePage.modules.map((module, index) => ( - - ))} -
-
-

{activeModule.label}

-

{activeModule.hint}

- {moduleEditable(moduleId) ? ( -
- {editableItems.map((item) => ( - - ))} -
- ) : ( - - )} -
-
- {moduleEditable(moduleId) && selectedItem ? ( - { - setDraft(nextDraft); - onDirtyChange(true); - }} - onSave={save} - /> - ) : ( - - )} -
-
-
- ); -} - -function ReferencePanel({ moduleId, products, onJump }: { moduleId: ModuleId; products: Product[]; onJump: (tab: Tab) => void }) { - if (moduleId === "leadFlow") { - return ( -
-

这个模块由客户需求列表维护,重点是状态流转、服务管家跟进和来源追踪。

- -
- ); - } - - return ( -
-

这个模块引用商品库。编辑商品后,首页商品卡、搜索结果和详情页会同步使用同一份数据。

-
- {products.slice(0, 4).map((product) => ( - - - {product.title} - - ))} -
- -
- ); -} - -function SiteItemEditor({ - moduleId, - draft, - onDraft, - onSave, -}: { - moduleId: SiteModule; - draft: SiteItemPatch; - onDraft: (draft: SiteItemPatch) => void; - onSave: () => void; -}) { - const primaryKey = moduleId === "heroSlides" ? "title" : moduleId === "destinations" ? "name" : moduleId === "themes" ? "label" : "alt"; - const primaryLabel = moduleId === "heroSlides" ? "轮播标题" : moduleId === "destinations" ? "目的地名称" : moduleId === "themes" ? "主题名称" : "入口文案"; - - return ( -
-
- -

编辑当前内容

- 保存后会影响对应前台模块 -
- -
-
- - - {moduleId === "heroSlides" ? ( - - ) : null} - - - - {moduleId !== "destinations" ? ( -
- - -
- ) : null} -
- {draft.image ? : } -
-
- - - -
-
- ); -} - -function FrontPreview({ config, moduleId, products }: { config: SiteConfig; moduleId: ModuleId; products: Product[] }) { - return ( -
-
万趣贵州
-
- - {config.heroSlides[0]?.title} -
-
- 探索贵州 - 主题甄选 - 精选线路 -
-
- {config.destinations.slice(0, 6).map((destination) => ( - {destination.name} - ))} -
-
- {products.slice(0, 2).map((product) => ( -
- - {product.subtitle || product.title} -
- ))} -
-
- ); -} - -function ProductManager({ - destinations, - onDirtyChange, - notify, -}: { - destinations: Destination[]; - onDirtyChange: (dirty: boolean) => void; - notify: (toast: Omit) => void; -}) { - const [products, setProducts] = useState([]); - const [selected, setSelected] = useState(null); - const [draft, setDraft] = useState(toProductInput()); - const [keyword, setKeyword] = useState(""); - const [message, setMessage] = useState(""); - const [loading, setLoading] = useState(false); - const [detailOpen, setDetailOpen] = useState(false); - - const load = async () => { - setLoading(true); - setMessage(""); - try { - const result = await getProducts(keyword); - setProducts(result.items); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load().catch((err) => setMessage(err.message)); - }, []); - - const select = (product: Product) => { - setSelected(product); - setDraft(toProductInput(product)); - setMessage(""); - setDetailOpen(true); - onDirtyChange(false); - }; - - const save = async () => { - if (!draft.title.trim()) { - setMessage("请先填写商品标题。"); - notify({ tone: "warning", title: "商品未保存", message: "商品标题不能为空。" }); - return; - } - - const payload = compactProductPayload(draft); - const saved = selected ? await updateProduct(selected.id, payload) : await createProduct(payload); - setSelected(saved); - setDraft(toProductInput(saved)); - setMessage("商品已保存,对应前台商品卡、搜索结果和详情页会使用这份数据。"); - onDirtyChange(false); - notify({ tone: "success", title: "商品已保存", message: "产品卡、搜索结果和详情页会使用最新内容。" }); - await load(); - }; - - const createNew = () => { - setSelected(null); - setDraft(toProductInput()); - setDetailOpen(true); - setMessage("正在新建商品"); - onDirtyChange(true); - }; - - const updateDraft = (nextDraft: ProductInput) => { - setDraft(nextDraft); - onDirtyChange(true); - }; - - const closeDetail = () => { - setDetailOpen(false); - setSelected(null); - setMessage(""); - onDirtyChange(false); - }; - - if (!detailOpen) { - const publishedCount = products.filter((product) => product.status === "published").length; - const draftCount = products.filter((product) => product.status === "draft").length; - - return ( -
-
-
-
-

商品维护

-

先管理商品列表,再点击单个商品进入详情维护。

-
- -
- {message ?

{message}

: null} -
- - {products.length} - 当前列表 - - - {publishedCount} - 已上架 - - - {draftCount} - 草稿 - -
-
{ - event.preventDefault(); - load().catch((err) => setMessage(err.message)); - }} - > - setKeyword(event.target.value)} placeholder="搜索商品标题、短标题或标签" /> - -
-
- {products.map((product) => ( - - ))} - {!products.length ?

暂无商品,点击右上角新建商品。

: null} -
-
-
- ); - } - - return ( -
-
-
-
-

{selected ? "商品详情维护" : "新建商品"}

-

把列表卡片、销售信息、详情图文拆成可单独维护的模块。

-
-
- - -
-
- {message ?

{message}

: null} -
- -
-
- -
-