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
+4
View File
@@ -1,2 +1,6 @@
# 阿里云 OSS 资源桶基址,迁移时只改这一个变量
OSS_BASE_URL=https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com
# 后端接口基址:本机 Docker 联调时使用 http://127.0.0.1:3090
# 不配置时会继续使用线上测试接口。
TARO_APP_API_BASE_URL=http://127.0.0.1:3090
+2
View File
@@ -14,6 +14,8 @@ const config = {
plugins: [],
defineConstants: {
__OSS_BASE_URL__: JSON.stringify(process.env.OSS_BASE_URL || ''),
// 接口基址由本机 .env 注入;不写则继续使用已部署的测试服务。
__API_BASE_URL__: JSON.stringify(process.env.TARO_APP_API_BASE_URL || 'https://wxbackend.tokenleaping.com'),
},
copy: {
patterns: [
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[205],{6296:function(s,e,a){var c=a(7842),n=a(118),r=a(758),i=a.n(r),o=a(9703),t=a(9201),l=a(5414),d=a(9674),h=a(3053),p=a(5234),m=a(4848);function x(){var s=(0,o.wR)(),e=s.resolvedTheme,a=(0,t.c)();(0,l.l)(e);var c=function(s){i().navigateTo({url:"/pages/shop/detail/index?id=".concat(s)})};return(0,m.jsxs)(n.Ss,{className:"shop-page theme-".concat(e),children:[(0,m.jsx)(d.A,{}),(0,m.jsx)(h.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,m.jsxs)(n.Ss,{className:"shop-list",children:[(0,m.jsxs)(n.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,m.jsx)(n.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,m.jsx)(n.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"})]}),(0,m.jsx)(n.Ss,{className:"shop-grid",children:p.zv.map(function(s){var e=s.images[0]||s.iconImg||"";return(0,m.jsxs)(n.Ss,{className:"shop-card",onTap:function(){return c(s.id)},children:[(0,m.jsx)(n.Ss,{className:"shop-card-img-wrap",children:(0,m.jsx)(n._V,{className:"shop-card-img",src:e,mode:"aspectFill",lazyLoad:!0})}),(0,m.jsxs)(n.Ss,{className:"shop-card-body",children:[(0,m.jsx)(n.EY,{className:"shop-card-name",children:s.name}),(0,m.jsx)(n.EY,{className:"shop-card-desc",children:s.desc||""}),(0,m.jsxs)(n.EY,{className:"shop-card-price",children:["\xa5",s.price]})]})]},s.id)})})]})]})}var u={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(x,"pages/shop/index",{root:{cn:[]}},u||{}))}},function(s){var e=function(e){return s(s.s=e)};s.O(0,[907,96,76],function(){return e(6296)});s.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[205],{6296:function(e,s,a){var c=a(7842),n=a(1212),r=a(467),t=a(5544),i=a(118),o=a(758),l=a.n(o),h=a(6540),d=a(9703),p=a(9201),u=a(5414),m=a(9674),x=a(3053),g=a(2371),f=a(4848);function j(){var e=(0,d.wR)(),s=e.resolvedTheme,a=(0,p.c)();(0,u.l)(s);var c=(0,h.useState)([]),o=(0,t.A)(c,2),j=o[0],N=o[1],S=(0,h.useState)(!0),v=(0,t.A)(S,2),b=v[0],E=v[1],w=(0,h.useState)(""),k=(0,t.A)(w,2),A=k[0],Y=k[1],T=(0,h.useCallback)((0,r.A)((0,n.A)().m(function e(){var s,a;return(0,n.A)().w(function(e){while(1)switch(e.p=e.n){case 0:return E(!0),Y(""),e.p=1,e.n=2,(0,g.j0)({page:1,pageSize:100});case 2:s=e.v,N(s.list),e.n=4;break;case 3:e.p=3,a=e.v,Y(a instanceof Error?a.message:"\u5546\u54c1\u52a0\u8f7d\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5");case 4:return e.p=4,E(!1),e.f(4);case 5:return e.a(2)}},e,null,[[1,3,4,5]])})),[]);(0,h.useEffect)(function(){T()},[T]);var y=function(e){l().navigateTo({url:"/pages/shop/detail/index?id=".concat(e)})};return(0,f.jsxs)(i.Ss,{className:"shop-page theme-".concat(s),children:[(0,f.jsx)(m.A,{}),(0,f.jsx)(x.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,f.jsxs)(i.Ss,{className:"shop-list",children:[(0,f.jsxs)(i.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,f.jsx)(i.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,f.jsx)(i.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"})]}),(0,f.jsx)(i.Ss,{className:"shop-grid",children:j.map(function(e){var s=e.images[0]||e.iconImg||"";return(0,f.jsxs)(i.Ss,{className:"shop-card",onTap:function(){return y(e.id)},children:[(0,f.jsx)(i.Ss,{className:"shop-card-img-wrap",children:(0,f.jsx)(i._V,{className:"shop-card-img",src:s,mode:"aspectFill",lazyLoad:!0})}),(0,f.jsxs)(i.Ss,{className:"shop-card-body",children:[(0,f.jsx)(i.EY,{className:"shop-card-name",children:e.name}),(0,f.jsx)(i.EY,{className:"shop-card-desc",children:e.subtitle||e.description||""}),(0,f.jsxs)(i.EY,{className:"shop-card-price",children:["\xa5",e.price]})]})]},e.id)})}),b&&(0,f.jsx)(i.EY,{className:"shop-feedback",children:"\u6b63\u5728\u52a0\u8f7d\u5546\u54c1\u2026"}),!b&&A&&(0,f.jsx)(i.Ss,{className:"shop-feedback shop-feedback-action",onTap:T,children:(0,f.jsxs)(i.EY,{children:["\u52a0\u8f7d\u5931\u8d25\uff1a",A,"\u3002\u70b9\u51fb\u91cd\u8bd5"]})}),!b&&!A&&0===j.length&&(0,f.jsx)(i.EY,{className:"shop-feedback",children:"\u6682\u65e0\u5728\u552e\u5546\u54c1"})]})]})}var N={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(j,"pages/shop/index",{root:{cn:[]}},N||{}))}},function(e){var s=function(s){return e(e.s=s)};e.O(0,[907,96,76],function(){return s(6296)});e.O()}]);
+1 -1
View File
@@ -1 +1 @@
.shop-list{padding:0 32rpx calc(220rpx + env(safe-area-inset-bottom))}.shop-header{padding-bottom:32rpx}.shop-header-title{color:var(--text-primary);display:block;font-size:56rpx;font-weight:800;letter-spacing:0;line-height:1.15}.shop-header-sub{color:var(--text-secondary);display:block;font-size:24rpx;margin-top:8rpx}.shop-grid{display:grid;gap:24rpx;grid-template-columns:1fr 1fr}.shop-card{background:var(--bg-card);border:1rpx solid var(--border);border-radius:24rpx;-webkit-box-shadow:var(--shadow-card);box-shadow:var(--shadow-card);overflow:hidden;-webkit-transition:-webkit-transform .15s ease;transition:-webkit-transform .15s ease;transition:transform .15s ease;transition:transform .15s ease,-webkit-transform .15s ease}.shop-card:active{-webkit-transform:scale(.98);-ms-transform:scale(.98);transform:scale(.98)}.shop-card-img-wrap{aspect-ratio:1/1;background:var(--bg-muted);overflow:hidden}.shop-card-img{height:100%;-o-object-fit:cover;object-fit:cover;-webkit-transition:-webkit-transform .4s ease;transition:-webkit-transform .4s ease;transition:transform .4s ease;transition:transform .4s ease,-webkit-transform .4s ease;width:100%}.shop-card-body{padding:20rpx 24rpx 24rpx}.shop-card-name{-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-primary);display:-webkit-box;font-size:28rpx;font-weight:700;line-height:1.4;overflow:hidden}.shop-card-desc{-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);display:-webkit-box;font-size:24rpx;line-height:1.4;margin-top:8rpx;overflow:hidden}.shop-card-price{color:var(--accent-primary);display:block;font-size:32rpx;font-variant:tabular-nums;font-weight:700;margin-top:12rpx}@media (min-width:750rpx){.shop-page{-ms-flex-pack:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:center;justify-content:center}}
.shop-list{padding:0 32rpx calc(220rpx + env(safe-area-inset-bottom))}.shop-header{padding-bottom:32rpx}.shop-header-title{color:var(--text-primary);display:block;font-size:56rpx;font-weight:800;letter-spacing:0;line-height:1.15}.shop-header-sub{color:var(--text-secondary);display:block;font-size:24rpx;margin-top:8rpx}.shop-grid{display:grid;gap:24rpx;grid-template-columns:1fr 1fr}.shop-card{background:var(--bg-card);border:1rpx solid var(--border);border-radius:24rpx;-webkit-box-shadow:var(--shadow-card);box-shadow:var(--shadow-card);overflow:hidden;-webkit-transition:-webkit-transform .15s ease;transition:-webkit-transform .15s ease;transition:transform .15s ease;transition:transform .15s ease,-webkit-transform .15s ease}.shop-card:active{-webkit-transform:scale(.98);-ms-transform:scale(.98);transform:scale(.98)}.shop-card-img-wrap{aspect-ratio:1/1;background:var(--bg-muted);overflow:hidden}.shop-card-img{height:100%;-o-object-fit:cover;object-fit:cover;-webkit-transition:-webkit-transform .4s ease;transition:-webkit-transform .4s ease;transition:transform .4s ease;transition:transform .4s ease,-webkit-transform .4s ease;width:100%}.shop-card-body{padding:20rpx 24rpx 24rpx}.shop-card-name{-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-primary);display:-webkit-box;font-size:28rpx;font-weight:700;line-height:1.4;overflow:hidden}.shop-card-desc{-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);display:-webkit-box;font-size:24rpx;line-height:1.4;margin-top:8rpx;overflow:hidden}.shop-card-price{color:var(--accent-primary);display:block;font-size:32rpx;font-variant:tabular-nums;font-weight:700;margin-top:12rpx}.shop-feedback{color:var(--text-secondary);display:block;font-size:26rpx;padding:48rpx 24rpx;text-align:center}.shop-feedback-action{color:var(--accent-primary)}@media (min-width:750rpx){.shop-page{-ms-flex-pack:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:center;justify-content:center}}
+1 -1
View File
File diff suppressed because one or more lines are too long
+49 -80
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,6 +94,7 @@ export default function Index() {
{/* 定制成品展示 */}
<View className='showcase-section'>
<Text className='home-section-title'></Text>
{showcaseProducts.length > 0 ? (
<Swiper
className='showcase-swiper'
circular
@@ -141,18 +103,23 @@ export default function Index() {
duration={500}
indicatorDots
>
{SHOWCASE_LIST.map((item, idx) => (
<SwiperItem key={idx} className='showcase-swiper-item'>
{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>
) : (
<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'