修复分类
This commit is contained in:
@@ -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,直接用于 `<Image src>`;未传图时为 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<HomeConfig>('/mini/home')
|
||||
}
|
||||
```
|
||||
|
||||
页面中使用(跳转需兼容空链接):
|
||||
|
||||
```tsx
|
||||
const [config, setConfig] = useState<HomeConfig>({ 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 封装自动跳登录页。
|
||||
@@ -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;
|
||||
|
||||
+111
-75
@@ -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<Category[]>([])
|
||||
/** 选中的顶级分类(null = 全部) */
|
||||
const [activeTop, setActiveTop] = useState<number | null>(null)
|
||||
/** 选中的子分类(null = 顶级分类下全部) */
|
||||
const [activeChild, setActiveChild] = useState<number | null>(null)
|
||||
/** 选中的分类ID(null = 全部;仅叶子分类可选:二级分类或无子分类的一级分类) */
|
||||
const [activeId, setActiveId] = useState<number | null>(null)
|
||||
/** 展开的一级分类ID(纯 UI 状态:有子分类的一级分类不可选中,点击只展开/收起) */
|
||||
const [expandedTop, setExpandedTop] = useState<number | null>(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() {
|
||||
</View>
|
||||
|
||||
<View className='product-body'>
|
||||
{/* ========== 左侧分类 ========== */}
|
||||
{/* ========== 左侧分类(一级为分组,叶子分类可选) ========== */}
|
||||
<ScrollView scrollY className='product-categories'>
|
||||
<View
|
||||
className={`category-item ${activeTop === null ? 'active' : ''}`}
|
||||
onClick={() => handleTopTap(null)}
|
||||
className={`category-item ${activeId === null ? 'active' : ''}`}
|
||||
onClick={handleAllTap}
|
||||
>
|
||||
<Text className='category-item__name'>全部</Text>
|
||||
</View>
|
||||
{categories.map(cat => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className={`category-item ${activeTop === cat.id ? 'active' : ''}`}
|
||||
onClick={() => handleTopTap(cat.id)}
|
||||
>
|
||||
<Text className='category-item__name'>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
{categories.map(cat => {
|
||||
const children = cat.children ?? []
|
||||
const hasChildren = children.length > 0
|
||||
return (
|
||||
<View key={cat.id}>
|
||||
<View
|
||||
className={[
|
||||
'category-item',
|
||||
// 有子分类:子分类被选中时父级高亮;无子分类:自身可选中
|
||||
hasChildren && activeParentId === cat.id && 'expanded',
|
||||
!hasChildren && activeId === cat.id && 'active',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => handleTopTap(cat)}
|
||||
>
|
||||
<Text className='category-item__name'>{cat.name}</Text>
|
||||
{hasChildren && (
|
||||
<Text className='category-item__arrow'>
|
||||
{expandedTop === cat.id ? '▾' : '▸'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* 二级分类 */}
|
||||
{expandedTop === cat.id &&
|
||||
children.map(child => (
|
||||
<View
|
||||
key={child.id}
|
||||
className={`category-item category-item--child ${
|
||||
activeId === child.id ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => handleChildTap(child.id)}
|
||||
>
|
||||
<Text className='category-item__name'>{child.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* ========== 右侧商品列表 ========== */}
|
||||
<View className='product-main'>
|
||||
{/* 子分类 chips */}
|
||||
{childCategories.length > 0 && (
|
||||
<ScrollView scrollX className='child-scroll'>
|
||||
<View
|
||||
className={`child-chip ${activeChild === null ? 'active' : ''}`}
|
||||
onClick={() => handleChildTap(null)}
|
||||
>
|
||||
<Text>全部</Text>
|
||||
</View>
|
||||
{childCategories.map(child => (
|
||||
<View
|
||||
key={child.id}
|
||||
className={`child-chip ${activeChild === child.id ? 'active' : ''}`}
|
||||
onClick={() => handleChildTap(child.id)}
|
||||
>
|
||||
<Text>{child.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 商品列表 */}
|
||||
{products.length === 0 && !loading ? (
|
||||
<Empty description='暂无商品' className='product-empty' />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** 商品分类节点(children 递归) */
|
||||
/** 商品分类节点(children 递归;叶子分类无 children 字段) */
|
||||
export interface Category {
|
||||
id: number
|
||||
parent_id: number
|
||||
name: string
|
||||
children: Category[]
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
/** 商品图片 */
|
||||
|
||||
Reference in New Issue
Block a user