diff --git a/src/app.config.ts b/src/app.config.ts index b4dc6ac..bffab9c 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,12 +1,27 @@ export default defineAppConfig({ pages: [ 'pages/index/index', + 'pages/product/index', + 'pages/cart/index', + 'pages/profile/index', + 'pages/message/index', + 'pages/login/index', ], window: { backgroundTextStyle: 'light', - navigationBarBackgroundColor: '#fff', - navigationBarTitleText: 'WeChat', + navigationBarBackgroundColor: '#ffffff', + navigationBarTitleText: '订货采购', navigationBarTextStyle: 'black', }, + tabBar: { + custom: true, + list: [ + { pagePath: 'pages/index/index', text: '首页' }, + { pagePath: 'pages/product/index', text: '商品' }, + { pagePath: 'pages/cart/index', text: '购物车' }, + { pagePath: 'pages/message/index', text: '消息' }, + { pagePath: 'pages/profile/index', text: '我的' }, + ], + }, animation: false, }) diff --git a/src/app.ts b/src/app.ts index 3c32fab..f77b176 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ class App extends Component { // this.props.children 是将要会渲染的页面 render () { + // @ts-ignore return this.props.children } } diff --git a/src/custom-tab-bar/index.less b/src/custom-tab-bar/index.less index e8bd888..f739818 100644 --- a/src/custom-tab-bar/index.less +++ b/src/custom-tab-bar/index.less @@ -4,67 +4,38 @@ left: 0; right: 0; display: flex; - align-items: flex-start; - justify-content: space-around; - background: rgba(255, 255, 255, 0.85); + align-items: center; + height: 110rpx; + padding-bottom: env(safe-area-inset-bottom); + background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); - border-top: 1px solid rgba(0, 0, 0, 0.06); + border-top: 1rpx solid rgba(0, 0, 0, 0.06); z-index: 999; - box-sizing: border-box; + box-sizing: content-box; .tab-item { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding-top: 10px; - flex: 1; - position: relative; - - .tab-icon { - font-size: 40px; - line-height: 1.2; - margin-bottom: 2px; - } - - .tab-label { - font-size: 20px; - color: #969799; - line-height: 1.4; - } - - &.active .tab-label { - color: #1989fa; - } + height: 100%; + color: #969799; } - /* 中间发布按钮 */ - .tab-publish { - justify-content: flex-start; - padding-top: 0; + .tab-icon{ + width: 36px; + height: 36px; + margin-bottom: 10px; + } - .publish-btn { - width: 88px; - height: 88px; - border-radius: 50%; - background: linear-gradient(135deg, #1989fa 0%, #07c160 100%); - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 8px 24px rgba(25, 137, 250, 0.4); - margin-top: -30px; - - .publish-icon { - font-size: 44px; - color: #fff; - font-weight: 300; - line-height: 1; - } - } - - .publish-label { - margin-top: 4px; - } + .tab-label { + font-size: 24rpx; } } + +.custom-tab-content { + height: 110rpx; + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/src/custom-tab-bar/index.tsx b/src/custom-tab-bar/index.tsx index 8e2ebad..475145b 100644 --- a/src/custom-tab-bar/index.tsx +++ b/src/custom-tab-bar/index.tsx @@ -1,60 +1,88 @@ +import {useEffect, useState} from 'react' import Taro from '@tarojs/taro' -import { View, Text } from '@tarojs/components' +import {View, Text, Image} from '@tarojs/components' +import useAuthStore from '@/stores/auth/useAuthStore' +import useCartStore from '@/stores/cart/useCartStore' +import IndexImage from '@/static/images/nav/index.png'; +import IndexActiveImage from '@/static/images/nav/index_active.png'; +import CartImage from '@/static/images/nav/cart.png'; +import CartActiveImage from '@/static/images/nav/cart_active.png'; +import MessageImage from '@/static/images/nav/message.png'; +import MessageActiveImage from '@/static/images/nav/message_active.png'; +import ProductImage from '@/static/images/nav/product.png'; +import ProductActiveImage from '@/static/images/nav/product_active.png'; +import ProfileImage from '@/static/images/nav/profile.png'; +import ProfileActiveImage from '@/static/images/nav/profile_active.png'; import './index.less' -interface CustomTabBarProps { - /** 当前激活的 tab key。H5 由页面传入;小程序由 Taro 自动渲染,props 为空 */ - activeKey?: string -} +/** tab 配置(与 app.config.ts 的 tabBar.list 保持一致) */ +const TAB_LIST = [ + { + key: 'index', + label: '首页', + icon: IndexImage, + activeIcon: IndexActiveImage, + path: '/pages/index/index' + }, + { + key: 'product', + label: '商品', + icon: ProductImage, + activeIcon: ProductActiveImage, + path: '/pages/product/index' + }, + { + key: 'cart', + label: '购物车', + icon: CartImage, + activeIcon: CartActiveImage, + path: '/pages/cart/index' + }, + { + key: 'message', + label: '消息', + icon: MessageImage, + activeIcon: MessageActiveImage, + path: '/pages/message/index' + }, + { + key: 'profile', + label: '我的', + icon: ProfileImage, + activeIcon: ProfileActiveImage, + path: '/pages/profile/index' + }, +] + +export default function CustomTabBar() { + const [activeKey, setActiveKey] = useState('index'); + // 从当前路由推导激活 tab(Taro 在每个页面重新实例化 tabBar,渲染时取值即可) + const pages = Taro.getCurrentPages() + + useEffect(() => { + console.log(pages); + const route = pages[pages.length - 1]?.route ?? '' + setActiveKey(TAB_LIST.find(tab => tab.path.includes(route))?.key ?? 'index') + }, [pages]); -export default function CustomTabBar({ activeKey }: CustomTabBarProps) { /** 切换 Tab */ - const handleTabClick = (tab: string, path: string) => { - if (tab === activeKey) return - Taro.switchTab({ url: path }) + const handleTabTap = (tab: (typeof TAB_LIST)[number]) => { + if (tab.key === activeKey) return + Taro.switchTab({ url: tab.path }) } return ( - <> + + - handleTabClick('index', '/index')} - > - 首页 - 首页 - - handleTabClick('index', '/index')} - > - 首页 - 商品 - - - {/* 中间发布按钮 —— 不属于 tabBar list,纯 UI 元素 */} - handleTabClick('', '')}> - - + {TAB_LIST.map(tab => ( + handleTabTap(tab)} className='tab-item'> + + {tab.label} - 购物车 - - handleTabClick('index', '/index')} - > - 首页 - 消息 - - handleTabClick('index', '/index')} - > - 首页 - 我的 - + ))} - + ) } diff --git a/src/index.html b/src/index.html index 064914c..49264a4 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,7 @@ - antmjs + 订货采购 diff --git a/src/pages/cart/index.config.ts b/src/pages/cart/index.config.ts index a2af952..4202651 100644 --- a/src/pages/cart/index.config.ts +++ b/src/pages/cart/index.config.ts @@ -1,3 +1,3 @@ export default definePageConfig({ - navigationBarTitleText: '首页' -}) \ No newline at end of file + navigationBarTitleText: '购物车', +}) diff --git a/src/pages/cart/index.less b/src/pages/cart/index.less index e69de29..236d68f 100644 --- a/src/pages/cart/index.less +++ b/src/pages/cart/index.less @@ -0,0 +1,286 @@ +.cart-page { + min-height: 100vh; + background: #f7f8fa; + padding: 20rpx 24rpx 200rpx; + box-sizing: border-box; + + // ===== 头部 ===== + .cart-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8rpx 8rpx 24rpx; + + &__title { + font-size: 36rpx; + font-weight: 600; + } + + &__clear { + font-size: 26rpx; + color: #969799; + } + } + + .cart-empty { + padding-top: 160rpx; + } + + .cart-loading { + padding-top: 160rpx; + text-align: center; + color: #c8c9cc; + font-size: 26rpx; + } + + // ===== 购物车项 ===== + .cart-item { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 16rpx; + display: flex; + + &--invalid { + background: #fafafa; + + .cart-item__name { + color: #c8c9cc; + } + } + + &__image { + width: 150rpx; + height: 150rpx; + border-radius: 12rpx; + background: #f2f3f5; + flex-shrink: 0; + } + + &__info { + flex: 1; + min-width: 0; + margin-left: 20rpx; + display: flex; + flex-direction: column; + } + + &__title-row { + display: flex; + align-items: center; + } + + &__name { + font-size: 30rpx; + color: #323233; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__invalid-tag { + margin-left: 12rpx; + font-size: 20rpx; + color: #c8c9cc; + border: 1rpx solid #d8d9db; + border-radius: 6rpx; + padding: 2rpx 8rpx; + flex-shrink: 0; + } + + &__spec { + margin-top: 10rpx; + font-size: 24rpx; + color: #969799; + } + + &__bottom { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__price { + font-size: 30rpx; + color: #ee0a24; + font-weight: 600; + + &--none { + color: #c8c9cc; + font-weight: 400; + font-size: 24rpx; + } + } + + &__actions { + margin-left: 20rpx; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + flex-shrink: 0; + } + + &__amount { + font-size: 30rpx; + color: #323233; + font-weight: 600; + } + + &__delete { + font-size: 24rpx; + color: #969799; + padding: 8rpx 4rpx; + } + } + + .cart-invalid-hint { + padding: 20rpx; + font-size: 22rpx; + color: #fa5151; + background: #fff4f4; + border-radius: 12rpx; + margin-top: 8rpx; + } + + // ===== 底部结算栏 ===== + .cart-footer { + position: fixed; + left: 0; + right: 0; + bottom: calc(110rpx + env(safe-area-inset-bottom)); + background: #fff; + display: flex; + align-items: center; + padding: 16rpx 24rpx; + border-top: 1rpx solid #ebedf0; + box-sizing: border-box; + + &__total { + flex: 1; + display: flex; + flex-direction: column; + } + + &__label { + font-size: 22rpx; + color: #969799; + } + + &__amount { + font-size: 36rpx; + color: #ee0a24; + font-weight: 600; + } + + &__submit { + width: 240rpx; + border-radius: 999rpx; + } + } + + // ===== 下单确认弹层 ===== + .order-popup { + padding: 32rpx 32rpx 24rpx; + + &__title { + font-size: 34rpx; + font-weight: 600; + display: block; + } + + &__list { + max-height: 480rpx; + margin-top: 20rpx; + } + + &__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20rpx 0; + border-bottom: 1rpx solid #f2f3f5; + + &-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + } + + &-name { + font-size: 28rpx; + color: #323233; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-spec { + margin-top: 6rpx; + font-size: 22rpx; + color: #969799; + } + + &-right { + display: flex; + align-items: center; + margin-left: 20rpx; + } + + &-qty { + font-size: 26rpx; + color: #969799; + margin-right: 20rpx; + } + + &-amount { + font-size: 28rpx; + color: #323233; + font-weight: 500; + } + } + + &__remark { + margin-top: 24rpx; + } + + &__remark-label { + font-size: 26rpx; + color: #323233; + } + + &__remark-input { + margin-top: 12rpx; + width: 100%; + min-height: 120rpx; + background: #f7f8fa; + border-radius: 12rpx; + padding: 16rpx; + box-sizing: border-box; + font-size: 26rpx; + } + + &__footer { + margin-top: 24rpx; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__total { + font-size: 28rpx; + color: #323233; + + // 金额颜色由内部元素控制 + } + + &__submit { + width: 280rpx; + border-radius: 999rpx; + } + } +} diff --git a/src/pages/cart/index.tsx b/src/pages/cart/index.tsx index d7fa857..02be2e0 100644 --- a/src/pages/cart/index.tsx +++ b/src/pages/cart/index.tsx @@ -1,34 +1,282 @@ -import { View } from '@tarojs/components' -import { Button } from '@antmjs/vantui' +import { useCallback, useEffect, useRef, useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components' +import { Button, Empty, Popup, Stepper } from '@antmjs/vantui' +import useCartStore from '@/stores/cart/useCartStore' +import { createOrderApi } from '@/services/order' +import type { CartItem } from '@/types/cart' import './index.less' -export default function Index() { +export default function CartPage() { + const items = useCartStore(s => s.items) + const totalQuantity = useCartStore(s => s.totalQuantity) + const totalAmount = useCartStore(s => s.totalAmount) + const loading = useCartStore(s => s.loading) + const fetchCart = useCartStore(s => s.fetchCart) + const updateQuantity = useCartStore(s => s.updateQuantity) + const removeItem = useCartStore(s => s.removeItem) + const clearCart = useCartStore(s => s.clearCart) + const clearLocal = useCartStore(s => s.clearLocal) + + /** 本地编辑中的数量(输入即时生效,500ms 防抖后提交服务端) */ + const [qtyMap, setQtyMap] = useState>({}) + const debounceRef = useRef>>({}) + + /** 下单弹层 */ + const [showOrder, setShowOrder] = useState(false) + const [remark, setRemark] = useState('') + const [submitting, setSubmitting] = useState(false) + + /** 可购项(status=1) */ + const purchasable = items.filter(item => item.status === 1) + const hasInvalid = items.length > 0 && purchasable.length < items.length + + useDidShow(() => { + fetchCart().catch(() => {}) + }) + + /** 同步本地编辑数量:删除不存在的项,保留在编数量 */ + useEffect(() => { + setQtyMap(prev => { + const next: Record = {} + for (const item of items) { + if (prev[item.id] !== undefined) { + next[item.id] = prev[item.id] + } + } + return next + }) + }, [items]) + + /** 清理防抖定时器 */ + useEffect(() => { + const timers = debounceRef.current + return () => { + Object.values(timers).forEach(t => clearTimeout(t)) + } + }, []) + + /** 数量变更:本地即时更新 + 防抖提交 */ + const handleQtyChange = useCallback( + (item: CartItem, val: string | number) => { + const value = Number(val) + if (!value || value <= 0) return + setQtyMap(prev => ({ ...prev, [item.id]: String(value) })) + if (debounceRef.current[item.id]) { + clearTimeout(debounceRef.current[item.id]) + } + debounceRef.current[item.id] = setTimeout(() => { + updateQuantity(item.id, value).catch(() => { + // 服务端拒绝(超上限等)→ 重新同步展示 + fetchCart().catch(() => {}) + }) + }, 500) + }, + [updateQuantity, fetchCart], + ) + + /** 删除单项 */ + const handleRemove = useCallback( + (item: CartItem) => { + Taro.showModal({ + title: '删除商品', + content: `确定删除「${item.name}」吗?`, + confirmColor: '#ee0a24', + success: res => { + if (res.confirm) { + removeItem(item.id).catch(() => {}) + } + }, + }) + }, + [removeItem], + ) + + /** 清空购物车 */ + const handleClear = useCallback(() => { + Taro.showModal({ + title: '清空购物车', + content: '确定清空购物车吗?', + confirmColor: '#ee0a24', + success: res => { + if (res.confirm) { + clearCart().catch(() => {}) + } + }, + }) + }, [clearCart]) + + /** 打开下单弹层 */ + const handleOrderTap = useCallback(() => { + if (!purchasable.length) { + Taro.showToast({ title: '没有可购买的商品', icon: 'none' }) + return + } + setShowOrder(true) + }, [purchasable.length]) + + /** 提交订单(金额一律服务端重算) */ + const handleSubmitOrder = useCallback(async () => { + if (submitting) return + setSubmitting(true) + try { + const res = await createOrderApi({ + items: purchasable.map(item => ({ + product_id: item.product_id, + quantity: Number(qtyMap[item.id] ?? item.quantity), + })), + remark: remark.trim() || undefined, + }) + Taro.showToast({ title: '下单成功', icon: 'success' }) + setShowOrder(false) + setRemark('') + // 本地先清空,再以服务端为准同步(服务端可能保留购物车内容) + clearLocal() + fetchCart().catch(() => {}) + } catch { + // 错误(已下架/未设等级价)已由 request 层 toast + } finally { + setSubmitting(false) + } + }, [submitting, purchasable, qtyMap, remark, clearLocal, fetchCart]) + + /** 当前展示的数量 */ + const displayQty = (item: CartItem) => qtyMap[item.id] ?? item.quantity + return ( - - - 上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less + + {/* ========== 头部 ========== */} + + 购物车 + {items.length > 0 && ( + 清空 + )} + + + {/* ========== 列表 ========== */} + {items.length === 0 ? ( + loading ? ( + 加载中... + ) : ( + + + + ) + ) : ( + items.map(item => ( + + + + + {item.name} + {item.status === 0 && 已失效} + + {item.spec} / {item.unit} + + {item.price !== null ? ( + ¥{item.price} + ) : ( + 价格待定 + )} + {item.status === 1 ? ( + handleQtyChange(item, e.detail)} + /> + ) : ( + handleRemove(item)}>删除 + )} + + + {item.status === 1 && ( + + ¥{item.amount ?? '0.00'} + handleRemove(item)}>删除 + + )} + + )) + )} + + {hasInvalid && ( + + 部分商品已下架或未设置您所在等级的价格,不可下单 + + )} + + {/* ========== 底部结算栏 ========== */} + {items.length > 0 && ( + + + 合计(可购{totalQuantity}件) + ¥{totalAmount} + + + + )} + + {/* ========== 下单确认弹层 ========== */} + setShowOrder(false)} + > + + 确认下单 + + {purchasable.map(item => ( + + + {item.name} + {item.spec} / {item.unit} + + + ×{displayQty(item)} + ¥{item.amount ?? '0.00'} + + + ))} + + + 订单备注 +