feat(catalog): connect product pages to backend

This commit is contained in:
lai_hong
2026-09-13 11:09:57 +08:00
parent a13e380009
commit 2968a4b20f
23 changed files with 315 additions and 123 deletions
+58 -89
View File
@@ -1,96 +1,57 @@
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import './index.scss'
import { CATEGORIES } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
// 成品展示配图(从 img 里取对应实物图)
const SHOWCASE_LIST = [
{
title: '毕业纪念笔记本',
desc: '全班名字组成校徽',
priceText: '¥45 起',
categoryId: 'notebook-large',
image: assetUrl('/img/book_small/The1.jpg')
},
{
title: '铜质杯垫',
desc: '金属质感桌面艺术',
priceText: '¥25 起',
categoryId: 'coaster',
image: assetUrl('/img/cup/The1.jpg')
},
{
title: '竹制笔盒',
desc: '自然竹纹文房雅器',
priceText: '¥75 起',
categoryId: 'penbox',
image: assetUrl('/img/penbox/The1.jpg')
},
{
title: '书本型灯',
desc: '温暖光影点亮心意',
priceText: '¥45 起',
categoryId: 'booklamp',
image: assetUrl('/img/booklight/The1.jpg')
},
{
title: '情侣定制礼',
desc: '两个人的名字交织',
priceText: '¥45 起',
categoryId: 'notebook-large',
image: assetUrl('/img/book_big/The1.jpg')
},
{
title: '企业年会礼',
desc: '员工名字组成Logo',
priceText: '¥75 起',
categoryId: 'penbox',
image: assetUrl('/img/penbox/The2.jpg')
}
]
// 品类对应的实物照片映射(首页卡片顶部大图)
const PRODUCT_IMG_MAP: Record<string, string> = {
'notebook-small': assetUrl('/img/book_small/The1.jpg'),
'notebook-large': assetUrl('/img/book_big/The1.jpg'),
'coaster': assetUrl('/img/cup/The1.jpg'),
'penbox': assetUrl('/img/penbox/The1.jpg'),
'booklamp': assetUrl('/img/booklight/The1.jpg')
}
import type { ProductCategory } from '../../types'
import { fetchProducts } from '../../utils/api/product'
import { toProductCategory } from '../../utils/productAdapter'
export default function Index() {
const { theme, resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [searchKey, setSearchKey] = useState('')
const [products, setProducts] = useState<ProductCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const loadProducts = useCallback(async () => {
setLoading(true)
setError('')
try {
const result = await fetchProducts({ page: 1, pageSize: 100 })
setProducts(result.list.map(toProductCategory))
} catch (e) {
setError(e instanceof Error ? e.message : '商品加载失败,请重试')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadProducts()
}, [loadProducts])
const navigateToProduct = (categoryId: string) => {
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
}
const navigateFromShowcase = (item: { categoryId?: string; title: string }) => {
if (item.categoryId) {
navigateToProduct(item.categoryId)
return
}
Taro.showToast({
title: `${item.title}”定制通道即将开放`,
icon: 'none'
})
const navigateFromShowcase = (item: ProductCategory) => {
navigateToProduct(item.id)
}
const filteredCategories = searchKey.trim()
? CATEGORIES.filter(
? products.filter(
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
)
: CATEGORIES
: products
const showcaseProducts = products.slice(0, 5)
const handleSearch = (e: any) => {
setSearchKey(e.detail.value)
@@ -133,26 +94,32 @@ export default function Index() {
{/* 定制成品展示 */}
<View className='showcase-section'>
<Text className='home-section-title'></Text>
<Swiper
className='showcase-swiper'
circular
autoplay
interval={3000}
duration={500}
indicatorDots
>
{SHOWCASE_LIST.map((item, idx) => (
<SwiperItem key={idx} className='showcase-swiper-item'>
{showcaseProducts.length > 0 ? (
<Swiper
className='showcase-swiper'
circular
autoplay
interval={3000}
duration={500}
indicatorDots
>
{showcaseProducts.map(item => (
<SwiperItem key={item.id} className='showcase-swiper-item'>
<View className='hero-block'>
<Image className='hero-media' src={item.image} mode='aspectFill' />
<Image className='hero-media' src={item.images[0] || item.iconImg || assetUrl('/icon/四角星.svg')} mode='aspectFill' />
<View className='glass-overlay'>
<Text className='hero-title'>{item.title}</Text>
<Text className='hero-title'>{item.name}</Text>
<Text className='hero-desc'>{item.desc}</Text>
</View>
</View>
</SwiperItem>
))}
</Swiper>
</Swiper>
) : (
<View className='empty-category'>
<Text className='empty-text'>{loading ? '正在加载商品…' : error || '暂无商品'}</Text>
</View>
)}
</View>
{/* 热门品类 */}
@@ -168,7 +135,7 @@ export default function Index() {
<View className='media-card-image'>
<Image
className='media-card-img'
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
src={cat.images[0] || cat.iconImg || assetUrl('/icon/四角星.svg')}
mode='aspectFill'
/>
</View>
@@ -183,8 +150,10 @@ export default function Index() {
{filteredCategories.length === 0 && (
<View className='empty-category'>
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
<Text className='empty-text'></Text>
<Text className='empty-sub'></Text>
<Text className='empty-text'>{error || (loading ? '正在加载商品…' : '未找到匹配的品类')}</Text>
<Text className='empty-sub' onTap={error ? loadProducts : undefined}>
{error ? '点击重新加载' : '尝试搜索“笔记本”、“杯垫”等'}
</Text>
</View>
)}
</View>
@@ -193,19 +162,19 @@ export default function Index() {
<View className='recommend-section'>
<Text className='home-section-title'></Text>
<View className='home-grid'>
{SHOWCASE_LIST.slice(0, 4).map((item, idx) => (
{showcaseProducts.slice(0, 4).map(item => (
<View
key={idx}
key={item.id}
className='media-card'
onTap={() => navigateFromShowcase(item)}
>
<View className='media-card-image'>
<Image className='media-card-img' src={item.image} mode='aspectFill' />
<Image className='media-card-img' src={item.images[0] || item.iconImg || assetUrl('/icon/四角星.svg')} mode='aspectFill' />
</View>
<View className='media-card-body'>
<Text className='media-card-title'>{item.title}</Text>
<Text className='media-card-title'>{item.name}</Text>
<Text className='media-card-desc'>{item.desc}</Text>
<Text className='media-card-price'>{item.priceText}</Text>
<Text className='media-card-price'>¥{item.price} </Text>
</View>
</View>
))}
+39 -5
View File
@@ -1,8 +1,7 @@
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { addDesign, getDesignList, setDesignList } from '../../utils/store'
import { createDesign } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
@@ -12,18 +11,53 @@ import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import BottomActionBar from '../../components/BottomActionBar'
import { getProductIconImg } from '../../utils/productConfig'
import type { ProductCategory } from '../../types'
import { fetchProduct } from '../../utils/api/product'
import { toProductCategory } from '../../utils/productAdapter'
export default function ProductPage() {
const { theme, resolvedTheme } = useThemeContext()
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [showModal, setShowModal] = useState(false)
const [quantity, setQuantity] = useState(1)
const [product, setProduct] = useState<ProductCategory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const productId = params && params.id ? params.id : ''
const product = getProductById(productId)
const loadProduct = useCallback(async () => {
if (!productId) {
setError('缺少商品 ID')
setLoading(false)
return
}
setLoading(true)
setError('')
try {
setProduct(toProductCategory(await fetchProduct(productId)))
} catch (e) {
setError(e instanceof Error ? e.message : '商品加载失败,请重试')
} finally {
setLoading(false)
}
}, [productId])
useEffect(() => {
loadProduct()
}, [loadProduct])
if (loading) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<View className='product-page'><Text></Text></View>
</View>
)
}
if (!product) {
return (
@@ -41,7 +75,7 @@ export default function ProductPage() {
<Text className='back-arrow'></Text>
</View>
<View className='nav-placeholder' />
<Text className='nav-title'></Text>
<Text className='nav-title'>{error || '商品未找到'}</Text>
<View className='nav-placeholder' />
</View>
</View>
+37 -4
View File
@@ -2,8 +2,7 @@ import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { ProductCategory } from '../../../types'
import { fetchProduct, type ProductDto } from '../../../utils/api/product'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import { useThemeContext } from '../../../context/ThemeContext'
@@ -19,6 +18,9 @@ export default function ShopDetailPage() {
const { resolvedTheme } = useThemeContext()
const [winHeight, setWinHeight] = useState(667)
const [progress, setProgress] = useState(0)
const [product, setProduct] = useState<ProductDto | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const safe = useSafeArea()
// 顶部为沉浸式商品图,状态栏始终使用白字。
useStatusBar(resolvedTheme, { mode: 'light' })
@@ -26,7 +28,27 @@ export default function ShopDetailPage() {
const routerParams = Taro.getCurrentInstance().router
const routerParamsValues = routerParams ? routerParams.params : undefined
const productId = routerParamsValues ? routerParamsValues.id : ''
const product: ProductCategory | undefined = PRODUCTS.find(p => p.id === productId)
const loadProduct = useCallback(async () => {
if (!productId) {
setError('缺少商品 ID')
setLoading(false)
return
}
setLoading(true)
setError('')
try {
setProduct(await fetchProduct(productId))
} catch (e) {
setError(e instanceof Error ? e.message : '商品加载失败,请重试')
} finally {
setLoading(false)
}
}, [productId])
useEffect(() => {
loadProduct()
}, [loadProduct])
useEffect(() => {
try {
@@ -108,12 +130,23 @@ export default function ShopDetailPage() {
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
if (loading) {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<Text style={{ color: '#fff', padding: '40rpx' }}></Text>
</View>
)
}
if (!product) {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
<Text style={{ color: '#fff', padding: '40rpx' }}></Text>
<View style={{ padding: '40rpx' }} onTap={loadProduct}>
<Text style={{ color: '#fff' }}>{error || '商品不存在'}</Text>
</View>
</View>
)
}
+12
View File
@@ -95,6 +95,18 @@
font-variant: tabular-nums;
}
.shop-feedback {
display: block;
padding: 48rpx 24rpx;
text-align: center;
color: var(--text-secondary);
font-size: 26rpx;
}
.shop-feedback-action {
color: var(--accent-primary);
}
/* ============================================================
Responsive: container center on wide screens
============================================================ */
+34 -3
View File
@@ -1,17 +1,38 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useCallback, useEffect, useState } from 'react'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import './index.scss'
import { PRODUCTS } from '../../utils/productConfig'
import { fetchProducts, type ProductDto } from '../../utils/api/product'
export default function ShopPage() {
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [products, setProducts] = useState<ProductDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const loadProducts = useCallback(async () => {
setLoading(true)
setError('')
try {
const result = await fetchProducts({ page: 1, pageSize: 100 })
setProducts(result.list)
} catch (e) {
setError(e instanceof Error ? e.message : '商品加载失败,请重试')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadProducts()
}, [loadProducts])
const openDetail = (productId: string) => {
Taro.navigateTo({ url: `/pages/shop/detail/index?id=${productId}` })
@@ -28,7 +49,7 @@ export default function ShopPage() {
</View>
<View className="shop-grid">
{PRODUCTS.map(product => {
{products.map(product => {
const imageSrc = product.images[0] || product.iconImg || ''
return (
<View
@@ -46,7 +67,7 @@ export default function ShopPage() {
</View>
<View className="shop-card-body">
<Text className="shop-card-name">{product.name}</Text>
<Text className="shop-card-desc">{product.desc || ''}</Text>
<Text className="shop-card-desc">{product.subtitle || product.description || ''}</Text>
<Text className="shop-card-price">¥{product.price}</Text>
</View>
</View>
@@ -54,6 +75,16 @@ export default function ShopPage() {
})}
</View>
{loading && <Text className="shop-feedback"></Text>}
{!loading && error && (
<View className="shop-feedback shop-feedback-action" onTap={loadProducts}>
<Text>{error}</Text>
</View>
)}
{!loading && !error && products.length === 0 && (
<Text className="shop-feedback"></Text>
)}
</View>
</View>
)
+4
View File
@@ -44,6 +44,10 @@ export interface ProductCategory {
specs?: [string, string][] // 规格参数键值对
mask: MaskConfig
images: string[] // 商品实物照片路径列表(相对页面引用的路径)
/** 服务端商品目录字段;本地兜底数据可不填。 */
status?: 'ON_SALE'
sort?: number
categoryId?: string
}
/** 贴纸物品 */
+76 -9
View File
@@ -1,16 +1,83 @@
import http from '../request'
import type { MaskConfig } from '../../types'
/** 商品列表 */
export async function fetchProducts() {
return http.get('/api/products', { auth: false })
export interface ProductDto {
id: string
name: string
categoryId?: string
price: number
originalPrice?: number
leadTime: string
subtitle?: string
description?: string
story?: string
scene?: string
tags: string[]
specs: [string, string][]
tone: [number, number, number]
mask: MaskConfig
images: string[]
iconImg?: string
status: 'ON_SALE'
sort: number
}
/** 商品详情 */
export async function fetchProduct(id: string) {
return http.get(`/api/products/${id}`, { auth: false })
export interface CategoryDto {
id: string
name: string
parentId?: string | null
sort: number
}
/** 分类列表 */
export async function fetchCategories() {
return http.get('/api/categories', { auth: false })
export interface ProductListParams {
page?: number
pageSize?: number
categoryId?: string
keyword?: string
}
export interface ProductListResult {
list: ProductDto[]
total: number
page: number
pageSize: number
}
function toNumber(value: number | string | undefined): number | undefined {
if (value === undefined) return undefined
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : undefined
}
function normalizeProduct(product: ProductDto): ProductDto {
const originalPrice = toNumber(product.originalPrice)
return {
...product,
price: toNumber(product.price) ?? 0,
...(originalPrice !== undefined ? { originalPrice } : {}),
tags: product.tags || [],
specs: product.specs || [],
tone: product.tone || [28, 22, 18],
images: product.images || [],
}
}
/** 在售商品分页列表;公共接口,不携带登录 token。 */
export async function fetchProducts(params: ProductListParams = {}): Promise<ProductListResult> {
const query = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
const result = await http.get<ProductListResult>(`/api/products${query ? `?${query}` : ''}`, { auth: false })
return { ...result, list: (result.list || []).map(normalizeProduct) }
}
/** 单个在售商品;不存在或下架时后端返回 404。 */
export async function fetchProduct(id: string): Promise<ProductDto> {
const product = await http.get<ProductDto>(`/api/products/${encodeURIComponent(id)}`, { auth: false })
return normalizeProduct(product)
}
export async function fetchCategories(): Promise<CategoryDto[]> {
return http.get<CategoryDto[]>('/api/categories', { auth: false })
}
+31
View File
@@ -0,0 +1,31 @@
import type { ProductCategory } from '../types'
import type { ProductDto } from './api/product'
/**
* 将商品目录接口数据适配为现有 DIY/设计清单仍在使用的商品模型。
* 页面只从接口读取商品;本适配层避免把后端字段差异散落到各个页面。
*/
export function toProductCategory(product: ProductDto): ProductCategory {
return {
id: product.id,
name: product.name,
desc: product.subtitle || product.description || '',
icon: product.iconImg || '',
iconImg: product.iconImg,
price: product.price,
originalPrice: product.originalPrice,
leadTime: product.leadTime,
description: product.description || product.subtitle || '',
subtitle: product.subtitle,
tone: product.tone,
story: product.story,
scene: product.scene,
tags: product.tags,
specs: product.specs,
mask: product.mask,
images: product.images,
status: product.status,
sort: product.sort,
categoryId: product.categoryId,
}
}
+6 -1
View File
@@ -1,11 +1,16 @@
import Taro from '@tarojs/taro'
declare const __API_BASE_URL__: string | undefined
/**
* 后端接口请求封装
* 后端统一响应:{ code, message, data }code === 0 表示成功
* token 通过 Authorization: Bearer <token> 传入
*/
export const BASE_URL = 'https://wxbackend.tokenleaping.com'
/** 由 Taro 构建时从 .env 注入;本机联调可设为 http://127.0.0.1:3090。 */
export const BASE_URL = typeof __API_BASE_URL__ !== 'undefined'
? __API_BASE_URL__
: 'https://wxbackend.tokenleaping.com'
const TOKEN_KEY = 'smart_access_token'