From 4a5b7003ca654ca65b4ff66adb9421cdb7d0f4ee Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Fri, 14 Aug 2026 16:21:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mini-home-api.md | 172 -------------------------------- src/pages/product/index.less | 69 ++++++++----- src/pages/product/index.tsx | 186 +++++++++++++++++++++-------------- src/types/product.ts | 4 +- 4 files changed, 155 insertions(+), 276 deletions(-) delete mode 100644 mini-home-api.md diff --git a/mini-home-api.md b/mini-home-api.md deleted file mode 100644 index e00d2fd..0000000 --- a/mini-home-api.md +++ /dev/null @@ -1,172 +0,0 @@ -# 小程序首页接口文档 - -> 适用端:微信小程序(h5 仓库,Taro) -> 后台配置入口:PC 后台「客户端配置」菜单(首页轮播图 / 宫格导航 / 促销推荐卡片) -> 更新日期:2026-08-14 - -## 通用约定 - -| 项 | 说明 | -|---|---| -| 根地址 | `BASE_URL`(见 `src/utils/request.ts`,如 `http://localhost:8000/index.php`) | -| 认证 | 请求头 `Authorization: Bearer {token}`,token 来自登录接口,本地存储 key `auth_token` | -| 响应包络 | `{ success: boolean, data: T, msg?: string, showType?: number }` | -| 失败处理 | `success=false` 时 `msg` 为中文错误信息;HTTP 401 表示登录过期,需重新登录 | - -## GET /mini/home - -首页配置聚合接口:一次返回轮播图、宫格导航、促销推荐卡片三组数据,均为**启用状态(status=1)**且按 `sort` 升序(越小越靠前)。 - -- **权限**:需登录(Bearer token) -- **请求参数**:无 - -### 响应示例 - -```json -{ - "success": true, - "msg": "ok", - "data": { - "banners": [ - { - "id": 1, - "title": "新鲜直采", - "image_id": 123, - "link": "/pages/goods/detail?id=1", - "sort": 0, - "image_url": "http://localhost:8000/storage/uploads/2026/08/14/xxx.jpg" - } - ], - "navs": [ - { - "id": 1, - "name": "蔬菜专区", - "image_id": 124, - "link": "/pages/category/index?id=1", - "sort": 0, - "image_url": "http://localhost:8000/storage/uploads/2026/08/14/yyy.png" - } - ], - "promos": [ - { - "id": 1, - "title": "限时特惠", - "sub_title": "全场 8 折起", - "image_id": 125, - "link": "/pages/promo/detail?id=1", - "sort": 0, - "image_url": "http://localhost:8000/storage/uploads/2026/08/14/zzz.jpg" - } - ] - } -} -``` - -### 字段说明 - -**banners(轮播图)** - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | number | 轮播图 ID | -| title | string | 标题(后台维护,可用于无障碍/占位) | -| image_id | number | 图片文件 ID(sys_file),一般无需使用 | -| **image_url** | string \| null | 轮播图片完整 URL,直接用于 ``;未传图时为 null | -| link | string | 小程序页面跳转路径,**空字符串表示点击不跳转** | -| sort | number | 排序值(已按此升序返回,前端无需再排) | - -**navs(宫格导航)** - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | number | 导航 ID | -| name | string | 导航名称(宫格文字) | -| **image_url** | string \| null | 导航图标完整 URL | -| link | string | 小程序页面跳转路径,空字符串不跳转 | -| sort | number | 排序值 | - -**promos(促销推荐卡片)** - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | number | 卡片 ID | -| title | string | 卡片标题 | -| sub_title | string | 副标题/促销文案,可能为空字符串 | -| **image_url** | string \| null | 卡片图片完整 URL | -| link | string | 小程序页面跳转路径,空字符串不跳转 | -| sort | number | 排序值 | - -### 前端接入示例 - -`src/services/home.ts`(新增): - -```ts -import { get } from '@/utils/request' - -/** 首页轮播图项 */ -export interface HomeBanner { - id: number - title: string - image_id: number - image_url: string | null - link: string - sort: number -} - -/** 首页宫格导航项 */ -export interface HomeNav { - id: number - name: string - image_id: number - image_url: string | null - link: string - sort: number -} - -/** 首页促销推荐卡片 */ -export interface HomePromo { - id: number - title: string - sub_title: string - image_id: number - image_url: string | null - link: string - sort: number -} - -/** 首页配置聚合数据 */ -export interface HomeConfig { - banners: HomeBanner[] - navs: HomeNav[] - promos: HomePromo[] -} - -/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */ -export function getHomeConfigApi() { - return get('/mini/home') -} -``` - -页面中使用(跳转需兼容空链接): - -```tsx -const [config, setConfig] = useState({ banners: [], navs: [], promos: [] }) - -useEffect(() => { - getHomeConfigApi().then(res => setConfig(res.data)) -}, []) - -/** 统一跳转:link 为空不跳转 */ -function handleLink(link: string) { - if (!link) return - Taro.navigateTo({ url: link }) -} -``` - -### 注意事项 - -1. **三组数据均可能为空数组**(后台未配置或全部停用),页面需做空态处理。 -2. `image_url` 可能为 `null`(后台未上传图片),渲染前判空。 -3. `link` 为小程序内部页面路径(以 `/` 开头),用 `Taro.navigateTo` 跳转;若目标为 tabBar 页面需改用 `Taro.switchTab`(建议后台配置时避免填 tabBar 路径)。 -4. 数据实时生效:后台修改后,小程序下次进入首页请求即为最新内容,无缓存。 -5. 接口需登录后调用;未登录(401)会由 request 封装自动跳登录页。 diff --git a/src/pages/product/index.less b/src/pages/product/index.less index c42b070..8f19a17 100644 --- a/src/pages/product/index.less +++ b/src/pages/product/index.less @@ -5,7 +5,6 @@ background: #f7f8fa; .product-search { - padding: 16rpx 24rpx; flex-shrink: 0; } @@ -17,22 +16,29 @@ // ===== 左侧分类 ===== .product-categories { - width: 180rpx; + width: 200rpx; height: 100%; background: #fff; flex-shrink: 0; + border-right: 1rpx solid #ebedf0; } .category-item { - padding: 28rpx 16rpx; + padding: 30rpx 16rpx 30rpx 28rpx; display: flex; align-items: center; - justify-content: center; position: relative; + color: #323233; + + &.expanded { + color: #ee0a24; + font-weight: 500; + } &.active { - background: #f7f8fa; + background: rgba(238, 10, 36, 0.06); color: #ee0a24; + font-weight: 500; &::before { content: ''; @@ -47,10 +53,38 @@ } } + &--child { + padding: 24rpx 16rpx 24rpx 48rpx; + color: #646566; + + &.active::before { + height: 24rpx; + } + } + &__name { + flex: 1; + min-width: 0; font-size: 26rpx; line-height: 1.4; - text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &--child &__name { + font-size: 24rpx; + } + + &__arrow { + margin-left: 8rpx; + font-size: 20rpx; + color: #c8c9cc; + } + + &.expanded &__arrow, + &.active &__arrow { + color: #ee0a24; } } @@ -58,31 +92,11 @@ .product-main { flex: 1; min-width: 0; - padding: 16rpx 20rpx 40rpx; + padding: 20rpx 20rpx 40rpx; overflow-y: auto; box-sizing: border-box; } - .child-scroll { - white-space: nowrap; - margin-bottom: 16rpx; - } - - .child-chip { - display: inline-flex; - padding: 10rpx 24rpx; - margin-right: 12rpx; - border-radius: 999rpx; - background: #fff; - font-size: 24rpx; - color: #646566; - - &.active { - background: #ee0a24; - color: #fff; - } - } - .product-empty { padding-top: 120rpx; } @@ -93,6 +107,7 @@ border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); &__image { width: 160rpx; diff --git a/src/pages/product/index.tsx b/src/pages/product/index.tsx index 263eaa0..af5cfae 100644 --- a/src/pages/product/index.tsx +++ b/src/pages/product/index.tsx @@ -1,9 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Taro, { useDidShow, useReachBottom } from '@tarojs/taro' import { View, Text, Image, ScrollView } from '@tarojs/components' import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui' import useCartStore from '@/stores/cart/useCartStore' import { getCategoriesApi, getProductListApi } from '@/services/product' +import type { ProductListParams } from '@/services/product' import { getProductCover } from '@/types/product' import type { Category, Product } from '@/types/product' import './index.less' @@ -18,10 +19,10 @@ export default function ProductPage() { /** 分类树 */ const [categories, setCategories] = useState([]) - /** 选中的顶级分类(null = 全部) */ - const [activeTop, setActiveTop] = useState(null) - /** 选中的子分类(null = 顶级分类下全部) */ - const [activeChild, setActiveChild] = useState(null) + /** 选中的分类ID(null = 全部;仅叶子分类可选:二级分类或无子分类的一级分类) */ + const [activeId, setActiveId] = useState(null) + /** 展开的一级分类ID(纯 UI 状态:有子分类的一级分类不可选中,点击只展开/收起) */ + const [expandedTop, setExpandedTop] = useState(null) /** 已提交的搜索词(onSearch 才生效) */ const [searchKey, setSearchKey] = useState('') /** 输入框内容 */ @@ -32,7 +33,10 @@ export default function ProductPage() { const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) const [finished, setFinished] = useState(false) - const listLoadingRef = useRef(false) + /** 请求序号:并发时仅采用最后一次请求的结果,避免分类快速切换时旧响应覆盖新数据 */ + const reqSeqRef = useRef(0) + /** 是否有请求进行中(仅用于避免"加载更多"并发) */ + const loadingRef = useRef(false) /** 加购弹层 */ const [showPopup, setShowPopup] = useState(false) @@ -40,31 +44,30 @@ export default function ProductPage() { const [qty, setQty] = useState(1) const addingRef = useRef(false) - /** 选中的顶级分类的子分类 */ - const childCategories = useMemo(() => { - const top = categories.find(c => c.id === activeTop) - return top?.children ?? [] - }, [categories, activeTop]) + /** 当前选中二级分类所属的一级分类ID(用于父级高亮) */ + const activeParentId = useMemo(() => { + if (activeId == null) return null + return categories.find(c => (c.children ?? []).some(ch => ch.id === activeId))?.id ?? null + }, [categories, activeId]) - /** 当前生效的分类ID(子分类优先) */ - const effectiveCategoryId = useMemo( - () => activeChild ?? activeTop ?? undefined, - [activeChild, activeTop], - ) + /** 当前生效的分类ID */ + const effectiveCategoryId = activeId ?? undefined /** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */ const fetchList = useCallback( async (pageNum: number, reset: boolean, keywordOverride?: string) => { - if (listLoadingRef.current) return - listLoadingRef.current = true + if (!reset && loadingRef.current) return + const seq = ++reqSeqRef.current + loadingRef.current = true setLoading(true) try { - const res = await getProductListApi({ - category_id: effectiveCategoryId, - keyword: keywordOverride ?? (searchKey || undefined), - page: pageNum, - pageSize: PAGE_SIZE, - }) + // undefined 会被序列化成字符串 "undefined" 拼进 URL,导致后端过滤为空,只传有值参数 + const params: ProductListParams = { page: pageNum, pageSize: PAGE_SIZE } + if (effectiveCategoryId != null) params.category_id = effectiveCategoryId + const keyword = keywordOverride ?? (searchKey || undefined) + if (keyword) params.keyword = keyword + const res = await getProductListApi(params) + if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应 const { data, total: totalCount } = res.data setProducts(prev => (reset ? data : [...prev, ...data])) setPage(pageNum) @@ -72,13 +75,25 @@ export default function ProductPage() { } catch { // 错误已由 request 层 toast } finally { - listLoadingRef.current = false - setLoading(false) + if (seq === reqSeqRef.current) { + loadingRef.current = false + setLoading(false) + } } }, [effectiveCategoryId, searchKey], ) + /** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */ + const firstEffectRef = useRef(true) + useEffect(() => { + if (firstEffectRef.current) { + firstEffectRef.current = false + return + } + fetchList(1, true) + }, [fetchList]) + /** 分类树(加载完成后处理首页跳转带入的分类) */ const loadCategories = useCallback(async () => { try { @@ -93,14 +108,21 @@ export default function ProductPage() { // noop } if (pending) { + const parentOfChild = res.data.find(c => (c.children ?? []).some(ch => ch.id === pending)) const top = res.data.find(c => c.id === pending) - const topOfChild = res.data.find(c => c.children.some(ch => ch.id === pending)) - if (top) { - setActiveTop(pending) - setActiveChild(null) - } else if (topOfChild) { - setActiveTop(topOfChild.id) - setActiveChild(pending) + if (parentOfChild) { + // 带入的是二级分类:展开父级并选中 + setExpandedTop(parentOfChild.id) + setActiveId(pending) + } else if (top) { + const children = top.children ?? [] + if (children.length > 0) { + // 有子分类的一级分类不可选中:展开并默认选中第一个子分类 + setExpandedTop(top.id) + setActiveId(children[0].id) + } else { + setActiveId(top.id) + } } } } catch { @@ -126,20 +148,28 @@ export default function ProductPage() { }) useReachBottom(() => { - if (!finished && !listLoadingRef.current) { + if (!finished) { fetchList(page + 1, false) } }) - /** 切换顶级分类 */ - const handleTopTap = useCallback((id: number | null) => { - setActiveTop(id) - setActiveChild(null) + /** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */ + const handleTopTap = useCallback((cat: Category) => { + if ((cat.children ?? []).length > 0) { + setExpandedTop(prev => (prev === cat.id ? null : cat.id)) + return + } + setActiveId(cat.id) }, []) - /** 切换子分类 */ - const handleChildTap = useCallback((id: number | null) => { - setActiveChild(id) + /** 选中二级分类 */ + const handleChildTap = useCallback((id: number) => { + setActiveId(id) + }, []) + + /** 选中"全部" */ + const handleAllTap = useCallback(() => { + setActiveId(null) }, []) /** 提交搜索 */ @@ -183,7 +213,6 @@ export default function ProductPage() { value={inputKey} placeholder='搜索品名/规格' shape='round' - background='#f7f8fa' onChange={e => setInputKey(String(e.detail))} onSearch={handleSearch} onClear={handleClear} @@ -191,48 +220,55 @@ export default function ProductPage() { - {/* ========== 左侧分类 ========== */} + {/* ========== 左侧分类(一级为分组,叶子分类可选) ========== */} handleTopTap(null)} + className={`category-item ${activeId === null ? 'active' : ''}`} + onClick={handleAllTap} > 全部 - {categories.map(cat => ( - handleTopTap(cat.id)} - > - {cat.name} - - ))} + {categories.map(cat => { + const children = cat.children ?? [] + const hasChildren = children.length > 0 + return ( + + handleTopTap(cat)} + > + {cat.name} + {hasChildren && ( + + {expandedTop === cat.id ? '▾' : '▸'} + + )} + + {/* 二级分类 */} + {expandedTop === cat.id && + children.map(child => ( + handleChildTap(child.id)} + > + {child.name} + + ))} + + ) + })} {/* ========== 右侧商品列表 ========== */} - {/* 子分类 chips */} - {childCategories.length > 0 && ( - - handleChildTap(null)} - > - 全部 - - {childCategories.map(child => ( - handleChildTap(child.id)} - > - {child.name} - - ))} - - )} - {/* 商品列表 */} {products.length === 0 && !loading ? ( diff --git a/src/types/product.ts b/src/types/product.ts index dc701b0..d42ab9f 100644 --- a/src/types/product.ts +++ b/src/types/product.ts @@ -1,9 +1,9 @@ -/** 商品分类节点(children 递归) */ +/** 商品分类节点(children 递归;叶子分类无 children 字段) */ export interface Category { id: number parent_id: number name: string - children: Category[] + children?: Category[] } /** 商品图片 */