feat(registration): 优化经销商注册流程并增加地址定位功能

- 修改导航栏标题从“邀请注册”为“注册成为会员”
- 修复重复提交问题并移除不必要的submitting状态
- 增加昵称和头像的必填验证提示
- 添加用户角色缺失时的默认角色写入机制
- 集成地图选点功能,支持经纬度获取和地址解析
- 实现微信地址导入功能,自动填充基本信息
- 增加定位权限检查和错误处理机制
- 添加.gitignore规则忽略备份文件夹src__bak
- 移除已废弃的银行卡和客户管理页面代码
- 优化表单验证规则和错误提示信息
- 实现经销商注册成功后自动跳转到“我的”页面
- 添加用户信息缓存刷新机制确保角色信息同步
```
This commit is contained in:
2026-03-01 12:35:41 +08:00
parent 945351be91
commit eee4644d06
296 changed files with 28845 additions and 6664 deletions

View File

@@ -1,5 +1,6 @@
import {Image} from '@nutui/nutui-react-taro'
import {Share} from '@nutui/icons-react-taro'
import {View, Text} from '@tarojs/components'
import Taro from '@tarojs/taro'
import './GoodsList.scss'
@@ -8,43 +9,45 @@ const GoodsList = (props: any) => {
return (
<>
<div className={'py-3'}>
<div className={'flex flex-col justify-between items-center rounded-lg px-2'}>
{props.data?.map((item, index) => {
<View className={'py-3'}>
<View className={'flex flex-col justify-between items-center rounded-lg px-2'}>
{props.data?.map((item: any, index: number) => {
return (
<div key={index} className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
<View key={index} className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
<Image src={item.image} mode={'aspectFit'} lazyLoad={false}
radius="10px 10px 0 0" height="180"
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/>
<div className={'flex flex-col p-2 rounded-lg'}>
<div>
<div className={'car-no text-sm'}>{item.name}</div>
<div className={'flex justify-between text-xs py-1'}>
<span className={'text-orange-500'}>{item.comments}</span>
<span className={'text-gray-400'}> {item.sales}</span>
</div>
<div className={'flex justify-between items-center py-2'}>
<div className={'flex text-red-500 text-xl items-baseline'}>
<span className={'text-xs'}></span>
<span className={'font-bold text-2xl'}>{item.price}</span>
</div>
<div className={'buy-btn'}>
<div className={'cart-icon'}>
<View className={'flex flex-col p-2 rounded-lg'}>
<View>
<View className={'car-no text-sm'}>{item.name}</View>
<View className={'flex justify-between text-xs py-1'}>
<Text className={'text-orange-500'}>{item.comments}</Text>
<Text className={'text-gray-400'}> {item.sales}</Text>
</View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{item.price}</Text>
<Text className={'text-xs px-1'}></Text>
<Text className={'text-xs text-gray-400 line-through'}>{item.salePrice}</Text>
</View>
<View className={'buy-btn'}>
<View className={'cart-icon'}>
<Share size={20} className={'mx-4 mt-2'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/>
</div>
<div className={'text-white pl-4 pr-5'}
</View>
<View className={'text-white pl-4 pr-5'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}>
</div>
</div>
</div>
</div>
</div>
</div>
</View>
</View>
</View>
</View>
</View>
</View>
)
})}
</div>
</div>
</View>
</View>
</>
)
}

View File

@@ -1,6 +1,6 @@
import Taro from '@tarojs/taro'
import GoodsList from './components/GoodsList'
import {useShareAppMessage, useShareTimeline} from "@tarojs/taro"
import {useShareAppMessage} from "@tarojs/taro"
import {Loading} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react"
import {useRouter} from '@tarojs/taro'
@@ -40,22 +40,15 @@ function Category() {
})
}, []);
useShareTimeline(() => {
return {
title: `${nav?.categoryName}_易赊宝`,
path: `/shop/category/index?id=${categoryId}`
};
});
useShareAppMessage(() => {
return {
title: `${nav?.categoryName}_易赊宝`,
path: `/shop/category/index?id=${categoryId}`,
success: function (res) {
console.log('分享成功', res);
success: function () {
console.log('分享成功');
},
fail: function (res) {
console.log('分享失败', res);
fail: function () {
console.log('分享失败');
}
};
});

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '全部评论',
navigationBarTextStyle: 'black'
})

View File

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '立即送水',
navigationBarTextStyle: 'black'
})

0
src/shop/gift/index.tsx Normal file
View File

View File

@@ -15,3 +15,11 @@ rich-text img {
.no-margin {
margin: 0 !important; /* 使用 !important 来确保覆盖默认样式 */
}
/* 文本截断样式 */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}

View File

@@ -1,8 +1,8 @@
import {useEffect, useState} from "react";
import {Image, Divider, Badge} from "@nutui/nutui-react-taro";
import {ArrowLeft, Headphones, Share, Cart} from "@nutui/icons-react-taro";
import Taro, {useShareAppMessage, useShareTimeline} from "@tarojs/taro";
import {RichText, View} from '@tarojs/components'
import {Image, Badge, Popup, CellGroup, Cell} from "@nutui/nutui-react-taro";
import {ArrowLeft, Headphones, Share, Cart, ArrowRight} from "@nutui/icons-react-taro";
import Taro, {useShareAppMessage} from "@tarojs/taro";
import {RichText, View, Text} from '@tarojs/components'
import {ShopGoods} from "@/api/shop/shopGoods/model";
import {getShopGoods} from "@/api/shop/shopGoods";
import {listShopGoodsSpec} from "@/api/shop/shopGoodsSpec";
@@ -14,33 +14,81 @@ import navTo, {wxParse} from "@/utils/common";
import SpecSelector from "@/components/SpecSelector";
import "./index.scss";
import {useCart} from "@/hooks/useCart";
import {useConfig} from "@/hooks/useConfig";
import {parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
import { ensureLoggedIn } from '@/utils/auth'
import {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate";
import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model";
const GoodsDetail = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>(44);
const [windowWidth, setWindowWidth] = useState<number>(390)
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [files, setFiles] = useState<any[]>([]);
const [specs, setSpecs] = useState<ShopGoodsSpec[]>([]);
const [skus, setSkus] = useState<ShopGoodsSku[]>([]);
const [showSpecSelector, setShowSpecSelector] = useState(false);
const [specAction, setSpecAction] = useState<'cart' | 'buy'>('cart');
const [showBottom, setShowBottom] = useState(false)
const [bottomItem, setBottomItem] = useState<any>({
title: '',
content: ''
})
// 水票套票模板:存在时该商品不允许加入购物车(购物车无法支付此类商品)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
const [ticketTemplateChecked, setTicketTemplateChecked] = useState(false)
// const [selectedSku, setSelectedSku] = useState<ShopGoodsSku | null>(null);
const [loading, setLoading] = useState(false);
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.id;
// 使用购物车Hook
const {cartCount, addToCart} = useCart();
const {cartCount, addToCart} = useCart()
const {config} = useConfig()
// 如果从分享链接进入(携带 inviter/source/t且当前未登录则暂存邀请信息用于注册后绑定关系
useEffect(() => {
try {
const currentUserId = Taro.getStorageSync('UserId')
if (currentUserId) return
const inviteParams = parseInviteParams({query: router?.params})
if (inviteParams?.inviter) {
saveInviteParams(inviteParams)
trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter))
}
} catch (e) {
// 邀请参数解析/存储失败不影响正常浏览商品
console.error('商品详情页处理邀请参数失败:', e)
}
// router 在 Taro 中可能不稳定;这里仅在 goodsId 变化时尝试处理一次即可
}, [goodsId])
// 处理加入购物车
const handleAddToCart = () => {
const handleAddToCart = async () => {
if (!goods) return;
if (!Taro.getStorageSync('UserId')) {
return Taro.showToast({
title: '请先登录',
icon: 'none',
duration: 2000
});
// 水票套票商品:不允许加入购物车(购物车无法支付)
// 优先使用已加载的 ticketTemplate若尚未加载则补一次查询
let tpl = ticketTemplate
let checked = ticketTemplateChecked
if (!tpl && goods?.goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId))
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
checked = true
} catch (_e) {
tpl = null
setTicketTemplateChecked(true)
checked = true
}
}
if (!checked || tpl) {
return
}
if (!ensureLoggedIn(`/shop/goodsDetail/index?id=${goods.goodsId}`)) return
// 如果有规格,显示规格选择器
if (specs.length > 0) {
@@ -62,13 +110,7 @@ const GoodsDetail = () => {
const handleBuyNow = () => {
if (!goods) return;
if (!Taro.getStorageSync('UserId')) {
return Taro.showToast({
title: '请先登录',
icon: 'none',
duration: 2000
});
}
if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goods.goodsId}`)) return
// 如果有规格,显示规格选择器
if (specs.length > 0) {
@@ -82,11 +124,30 @@ const GoodsDetail = () => {
};
// 规格选择确认回调
const handleSpecConfirm = (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => {
const handleSpecConfirm = async (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => {
// setSelectedSku(sku);
setShowSpecSelector(false);
if (action === 'cart') {
// 水票套票商品:不允许加入购物车(购物车无法支付)
let tpl = ticketTemplate
let checked = ticketTemplateChecked
if (!tpl && goods?.goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId))
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
checked = true
} catch (_e) {
tpl = null
setTicketTemplateChecked(true)
checked = true
}
}
if (!checked || tpl) {
return
}
// 加入购物车
addToCart({
goodsId: goods!.goodsId!,
@@ -117,9 +178,28 @@ const GoodsDetail = () => {
}
};
const openBottom = (title: string, content: string) => {
setBottomItem({
title,
content
})
setShowBottom(true)
}
useEffect(() => {
let alive = true
Taro.getSystemInfo({
success: (res) => {
if (!alive) return
setWindowWidth(res.windowWidth)
setStatusBarHeight(Number(res.statusBarHeight) + 5)
},
});
if (goodsId) {
setLoading(true);
// 切换商品时先重置套票模板,避免复用上一个商品状态
setTicketTemplate(null)
setTicketTemplateChecked(false)
// 加载商品详情
getShopGoods(Number(goodsId))
@@ -128,6 +208,7 @@ const GoodsDetail = () => {
if (res.content) {
res.content = wxParse(res.content);
}
if (!alive) return
setGoods(res);
if (res.files) {
const arr = JSON.parse(res.files);
@@ -138,12 +219,27 @@ const GoodsDetail = () => {
console.error("Failed to fetch goods detail:", error);
})
.finally(() => {
if (!alive) return
setLoading(false);
});
// 查询商品是否绑定水票模板(失败/无数据时不影响正常浏览)
getGltTicketTemplateByGoodsId(Number(goodsId))
.then((tpl) => {
if (!alive) return
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
})
.catch((_e) => {
if (!alive) return
setTicketTemplate(null)
setTicketTemplateChecked(true)
})
// 加载商品规格
listShopGoodsSpec({goodsId: Number(goodsId)} as any)
.then((data) => {
if (!alive) return
setSpecs(data || []);
})
.catch((error) => {
@@ -153,20 +249,30 @@ const GoodsDetail = () => {
// 加载商品SKU
listShopGoodsSku({goodsId: Number(goodsId)} as any)
.then((data) => {
if (!alive) return
setSkus(data || []);
})
.catch((error) => {
console.error("Failed to fetch goods skus:", error);
});
}
return () => {
alive = false
}
}, [goodsId]);
// 分享给好友
useShareAppMessage(() => {
const inviter = Taro.getStorageSync('UserId')
const sharePath =
inviter
? `/shop/goodsDetail/index?id=${goodsId}&inviter=${inviter}&source=goods_share&t=${Date.now()}`
: `/shop/goodsDetail/index?id=${goodsId}`
return {
title: goods?.name || '精选商品',
path: `/shop/goodsDetail/index?id=${goodsId}`,
imageUrl: goods?.image, // 分享图片
path: sharePath,
imageUrl: goods?.image ? `${goods.image}?x-oss-process=image/resize,w_500,h_400,m_fill` : undefined, // 分享图片调整为5:4比例
success: function (res: any) {
console.log('分享成功', res);
Taro.showToast({
@@ -186,22 +292,15 @@ const GoodsDetail = () => {
};
});
// 分享到朋友圈
useShareTimeline(() => {
return {
title: `${goods?.name || '精选商品'} - 易赊宝`,
path: `/shop/goodsDetail/index?id=${goodsId}`,
imageUrl: goods?.image
};
});
if (!goods || loading) {
return <div>...</div>;
return <View>...</View>;
}
const showAddToCart = ticketTemplateChecked && !ticketTemplate
return (
<div className={"py-0"}>
<div
<View className={"py-0"}>
<View
className={
"fixed z-10 bg-white flex justify-center items-center font-bold shadow-sm opacity-70"
}
@@ -209,36 +308,36 @@ const GoodsDetail = () => {
borderRadius: "100%",
width: "32px",
height: "32px",
top: "50px",
top: statusBarHeight + 'px',
left: "10px",
}}
onClick={() => Taro.navigateBack()}
>
<ArrowLeft size={14}/>
</div>
<div className={
</View>
<View className={
"fixed z-10 bg-white flex justify-center items-center font-bold shadow-sm opacity-90"
}
style={{
borderRadius: "100%",
width: "32px",
height: "32px",
top: "50px",
right: "110px",
}}
onClick={() => Taro.switchTab({url: `/pages/cart/cart`})}>
style={{
borderRadius: "100%",
width: "32px",
height: "32px",
top: statusBarHeight + 'px',
right: "110px",
}}
onClick={() => Taro.switchTab({url: `/pages/cart/cart`})}>
<Badge value={cartCount} top="-2" right="2">
<div style={{display: 'flex', alignItems: 'center'}}>
<View style={{display: 'flex', alignItems: 'center'}}>
<Cart size={16}/>
</div>
</View>
</Badge>
</div>
</View>
{
files.length > 0 && (
<Swiper defaultValue={0} indicator height={'350px'}>
<Swiper defaultValue={0} indicator height={windowWidth}>
{files.map((item) => (
<Swiper.Item key={item}>
<Image width="100%" height={'100%'} src={item.url} mode={'scaleToFill'} lazyLoad={false}/>
<Image width={windowWidth} height={windowWidth} src={item.url} mode={'scaleToFill'} lazyLoad={false}/>
</Swiper.Item>
))}
</Swiper>
@@ -254,67 +353,120 @@ const GoodsDetail = () => {
/>
)
}
<div
className={"flex flex-col justify-between items-center rounded-lg px-2"}
<View
className={"flex flex-col justify-between items-center"}
>
<div
<View
className={
"flex flex-col rounded-lg bg-white shadow-sm w-full mt-2"
"flex flex-col bg-white w-full"
}
>
<div className={"flex flex-col p-2 rounded-lg"}>
<View className={"flex flex-col p-3 rounded-lg"}>
<>
<div className={'flex justify-between'}>
<div className={'flex text-red-500 text-xl items-baseline'}>
<span className={'text-xs'}></span>
<span className={'font-bold text-2xl'}>{goods.price}</span>
</div>
<View className={'flex justify-between'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{goods.price}</Text>
<Text className={'text-xs px-1'}></Text>
<Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}</Text>
</View>
<span className={"text-gray-400 text-xs"}> {goods.sales}</span>
</div>
<div className={'flex justify-between items-center'}>
<div className={'goods-info'}>
<div className={"car-no text-lg"}>
</View>
<View className={'flex justify-between items-center'}>
<View className={'goods-info'}>
<View className={"car-no text-lg"}>
{goods.name}
</div>
<div className={"flex justify-between text-xs py-1"}>
</View>
<View className={"flex justify-between text-xs py-1"}>
<span className={"text-orange-500"}>
{goods.comments}
</span>
</div>
</div>
<button
className={'flex flex-col justify-center items-center text-gray-500 px-1 gap-1 text-nowrap whitespace-nowrap'}
open-type="share"><Share
size={20}/>
<span className={'text-xs'}></span>
</button>
</div>
</View>
</View>
<View>
<button
className={'flex flex-col justify-center items-center bg-white text-gray-500 px-1 gap-1 text-nowrap whitespace-nowrap'}
open-type="share">
<Share size={20}/>
<span className={'text-xs'}></span>
</button>
</View>
</View>
</>
</div>
</div>
<div className={"mt-2 py-2"}>
<Divider></Divider>
<RichText nodes={goods.content || '内容详情'}/>
</div>
</div>
{/*底部购买按钮*/}
<div className={'fixed bg-white w-full bottom-0 left-0 pt-4 pb-10'}>
<View className={'btn-bar flex justify-between items-center'}>
<div className={'flex justify-center items-center mx-4'}>
<button open-type="contact" className={'flex items-center'}>
<Headphones size={18} style={{marginRight: '4px'}}/>
</button>
</div>
<div className={'buy-btn mx-4'}>
<div className={'cart-add px-4 text-sm'}
onClick={() => handleAddToCart()}>
</div>
<div className={'cart-buy pl-4 pr-5 text-sm'}
onClick={() => handleBuyNow()}>
</div>
</div>
</View>
</View>
</div>
<View className={'w-full'}>
{
config?.deliveryText && (
<CellGroup>
<Cell title={'配送'} extra={
<View className="flex items-center">
<Text className={'truncate max-w-56 inline-block'}>{config?.deliveryText || '14:30下单明天配送'}</Text>
<ArrowRight color="#cccccc" size={15}/>
</View>
} onClick={() => openBottom('配送', `${config?.deliveryText}`)}/>
<Cell title={'保障'} extra={
<View className="flex items-center">
<Text className={'truncate max-w-56 inline-block'}>{config?.guaranteeText || '支持7天无理由退货'}</Text>
<ArrowRight color="#cccccc" size={15}/>
</View>
} onClick={() => openBottom('保障', `${config?.guaranteeText}`)}/>
</CellGroup>
)
}
{config?.openComments == '1' && (
<CellGroup>
<Cell title={'用户评价 (0)'} extra={
<>
<Text></Text>
<ArrowRight color="#cccccc" size={15}/>
</>
} onClick={() => navTo(`/shop/comments/index`)}/>
<Cell className={'flex h-32 bg-white p-4'}>
</Cell>
</CellGroup>
)}
</View>
<View className={'w-full'}>
<RichText nodes={goods.content || '内容详情'}/>
<View className={'h-24'}></View>
</View>
</View>
{/*底部弹窗*/}
<Popup
visible={showBottom}
position="bottom"
onClose={() => {
setShowBottom(false)
}}
lockScroll
>
<View className={'flex flex-col p-4'}>
<Text className={'font-bold text-sm'}>{bottomItem.title}</Text>
<Text className={'text-gray-500 my-2'}>{bottomItem.content}</Text>
</View>
</Popup>
{/*底部购买按钮*/}
<View className={'fixed bg-white w-full bottom-0 left-0 pt-4 pb-6'}>
<View className={'btn-bar flex justify-between items-center'}>
<View className={'flex justify-center items-center mx-4'}>
<button open-type="contact" className={'flex items-center text-sm py-2'}>
<Headphones size={16} style={{marginRight: '4px'}}/>
</button>
</View>
<View className={'buy-btn mx-4'}>
{showAddToCart && (
<View className={'cart-add px-4 text-sm'}
onClick={() => handleAddToCart()}>
</View>
)}
<View className={`cart-buy text-sm ${showAddToCart ? 'pl-4 pr-5' : 'cart-buy-only px-4'}`}
onClick={() => handleBuyNow()}>
</View>
</View>
</View>
</View>
{/* 规格选择器 */}
{showSpecSelector && (
@@ -327,7 +479,7 @@ const GoodsDetail = () => {
onClose={() => setShowSpecSelector(false)}
/>
)}
</div>
</View>
);
};

View File

@@ -1,4 +1,4 @@
import {useEffect, useState} from "react";
import {useEffect, useMemo, useState} from "react";
import {
Image,
Button,
@@ -9,6 +9,7 @@ import {
ActionSheet,
Popup,
InputNumber,
DatePicker,
ConfigProvider
} from '@nutui/nutui-react-taro'
import {Location, ArrowRight} from '@nutui/icons-react-taro'
@@ -27,6 +28,8 @@ import OrderConfirmSkeleton from "@/components/OrderConfirmSkeleton";
import CouponList from "@/components/CouponList";
import {CouponCardProps} from "@/components/CouponCard";
import {getMyAvailableCoupons} from "@/api/shop/shopUserCoupon";
import {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate";
import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model";
import {
transformCouponData,
calculateCouponDiscount,
@@ -36,7 +39,11 @@ import {
filterUsableCoupons,
filterUnusableCoupons
} from "@/utils/couponUtils";
import navTo from "@/utils/common";
import dayjs from 'dayjs'
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import { ensureLoggedIn, isLoggedIn } from '@/utils/auth'
const OrderConfirm = () => {
@@ -50,6 +57,21 @@ const OrderConfirm = () => {
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('')
const [payLoading, setPayLoading] = useState<boolean>(false)
// 配送时间(仅水票套票商品需要)
// 当日截单时间:超过该时间下单,最早配送日顺延到次日(避免 21:00 下单仍显示“当天配送”)
const DELIVERY_CUTOFF_HOUR = 21
const getMinSendDate = () => {
const now = dayjs()
const cutoff = now.hour(DELIVERY_CUTOFF_HOUR).minute(0).second(0).millisecond(0)
const startOfToday = now.startOf('day')
// >= 截单时间则最早只能选次日
return now.isSame(cutoff) || now.isAfter(cutoff) ? startOfToday.add(1, 'day') : startOfToday
}
const [sendTime, setSendTime] = useState<Date>(() => getMinSendDate().toDate())
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
// 水票套票活动(若存在则按规则限制最小购买量等)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
// InputNumber 主题配置
const customTheme = {
@@ -67,13 +89,92 @@ const OrderConfirm = () => {
const [availableCoupons, setAvailableCoupons] = useState<CouponCardProps[]>([])
const [couponLoading, setCouponLoading] = useState<boolean>(false)
// 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage
const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([])
const [storeLoading, setStoreLoading] = useState(false)
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
// 页面级兜底:未登录直接进入下单页时,引导去注册/登录并回跳
useEffect(() => {
if (!goodsId) {
// 也可能是 orderData 模式;这里只做最小兜底
if (!ensureLoggedIn('/shop/orderConfirm/index')) return
return
}
if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return
}, [goodsId])
const isTicketTemplateActive =
!!ticketTemplate &&
ticketTemplate.enabled !== false &&
ticketTemplate.status !== 1 &&
ticketTemplate.deleted !== 1
const hasTicketTemplate = !!ticketTemplate
// 套票活动最低购买量:优先取模板配置
const ticketMinBuyQty = (() => {
const n = Number(ticketTemplate?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const getGiftTicketQty = (buyQty: number) => {
if (!isTicketTemplateActive) return 0
const multiplier = Number(ticketTemplate?.giftMultiplier || 0)
const startSend = Number(ticketTemplate?.startSendQty || 0)
if (multiplier > 0) return Math.max(0, buyQty) * multiplier
return Math.max(0, startSend)
}
const loadStores = async () => {
if (storeLoading) return
try {
setStoreLoading(true)
const list = await listShopStore()
setStores((list || []).filter(s => s?.isDelete !== 1))
} catch (e) {
console.error('获取门店列表失败:', e)
setStores([])
Taro.showToast({title: '获取门店列表失败', icon: 'none'})
} finally {
setStoreLoading(false)
}
}
// @ts-ignore
const openStorePopup = async () => {
setStorePopupVisible(true)
if (!stores.length) {
await loadStores()
}
}
// 计算商品总价
const getGoodsTotal = () => {
if (!goods) return 0
return parseFloat(goods.price || '0') * quantity
const price = parseFloat(goods.price || '0')
// const total = price * quantity
// 🔍 详细日志,用于排查数值精度问题
// console.log('💵 商品总价计算:', {
// goodsPrice: goods.price,
// goodsPriceType: typeof goods.price,
// parsedPrice: price,
// quantity: quantity,
// total: total,
// totalFixed2: total.toFixed(2),
// totalString: total.toString()
// })
return price * quantity
}
// 计算优惠券折扣
@@ -98,14 +199,16 @@ const OrderConfirm = () => {
// 处理数量变化
const handleQuantityChange = (value: string | number) => {
const newQuantity = typeof value === 'string' ? parseInt(value) || 1 : value
const finalQuantity = Math.max(1, Math.min(newQuantity, goods?.stock || 999))
const fallback = isTicketTemplateActive ? minBuyQty : 1
const newQuantity = typeof value === 'string' ? parseInt(value, 10) || fallback : value
const finalQuantity = Math.max(fallback, Math.min(newQuantity, goods?.stock || 999))
setQuantity(finalQuantity)
// 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用
if (availableCoupons.length > 0) {
const newTotal = parseFloat(goods?.price || '0') * finalQuantity
const sortedCoupons = sortCoupons(availableCoupons, newTotal)
const usableCoupons = filterUsableCoupons(sortedCoupons, newTotal)
setAvailableCoupons(sortedCoupons)
// 检查当前选中的优惠券是否还可用
@@ -115,6 +218,56 @@ const OrderConfirm = () => {
title: '当前优惠券不满足使用条件,已自动取消',
icon: 'none'
})
// 🎯 自动推荐新的最优优惠券
if (usableCoupons.length > 0) {
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, newTotal)
if (discount > 0) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: `已为您重新推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
}
} else if (!selectedCoupon && usableCoupons.length > 0) {
// 🔔 如果没有选中优惠券但有可用的,推荐最优的
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, newTotal)
if (discount > 0) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: `已为您推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
} else if (selectedCoupon && usableCoupons.length > 0) {
// 🔍 检查是否有更好的优惠券
const bestCoupon = usableCoupons[0]
const currentDiscount = calculateCouponDiscount(selectedCoupon, newTotal)
const bestDiscount = calculateCouponDiscount(bestCoupon, newTotal)
// 如果有更好的优惠券优惠超过0.01元)
if (bestDiscount > currentDiscount + 0.01 && bestCoupon.id !== selectedCoupon.id) {
Taro.showModal({
title: '发现更优惠的优惠券',
content: `有更好的优惠券可用,额外节省¥${(bestDiscount - currentDiscount).toFixed(2)},是否更换?`,
success: (res) => {
if (res.confirm) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: '优惠券已更换',
icon: 'success'
})
}
}
})
}
}
}
}
@@ -123,9 +276,45 @@ const OrderConfirm = () => {
const handleCouponSelect = (coupon: CouponCardProps) => {
const total = getGoodsTotal()
// 🔍 详细日志记录,用于排查问题
console.log('🎫 手动选择优惠券详细信息:', {
coupon: {
id: coupon.id,
title: coupon.title,
type: coupon.type,
amount: coupon.amount,
minAmount: coupon.minAmount,
status: coupon.status
},
orderInfo: {
goodsPrice: goods?.price,
quantity: quantity,
total: total,
totalFixed: total.toFixed(2)
},
validation: {
isUsable: isCouponUsable(coupon, total),
discount: calculateCouponDiscount(coupon, total),
reason: getCouponUnusableReason(coupon, total)
}
})
// 检查是否可用
if (!isCouponUsable(coupon, total)) {
const reason = getCouponUnusableReason(coupon, total)
// 🚨 记录手动选择失败的详细信息
console.error('🚨 手动选择优惠券失败:', {
reason,
coupon,
total,
minAmount: coupon.minAmount,
comparison: {
totalVsMinAmount: `${total} < ${coupon.minAmount}`,
result: total < (coupon.minAmount || 0)
}
})
Taro.showToast({
title: reason || '优惠券不可用',
icon: 'none'
@@ -151,7 +340,7 @@ const OrderConfirm = () => {
}
// 加载用户优惠券
const loadUserCoupons = async () => {
const loadUserCoupons = async (totalOverride?: number) => {
try {
setCouponLoading(true)
@@ -163,15 +352,63 @@ const OrderConfirm = () => {
const transformedCoupons = res.map(transformCouponData)
// 按优惠金额排序
const total = getGoodsTotal()
const total = totalOverride ?? getGoodsTotal()
const sortedCoupons = sortCoupons(transformedCoupons, total)
const usableCoupons = filterUsableCoupons(sortedCoupons, total)
setAvailableCoupons(sortedCoupons)
// 🎯 智能推荐:自动应用最优惠的可用优惠券
if (usableCoupons.length > 0 && !selectedCoupon) {
const bestCoupon = usableCoupons[0] // 已经按优惠金额排序,第一个就是最优的
const discount = calculateCouponDiscount(bestCoupon, total)
// 🔍 详细日志记录自动推荐的信息
console.log('🤖 自动推荐优惠券详细信息:', {
coupon: {
id: bestCoupon.id,
title: bestCoupon.title,
type: bestCoupon.type,
amount: bestCoupon.amount,
minAmount: bestCoupon.minAmount,
status: bestCoupon.status
},
orderInfo: {
goodsPrice: goods?.price,
quantity: quantity,
total: total,
totalFixed: total.toFixed(2)
},
validation: {
isUsable: isCouponUsable(bestCoupon, total),
discount: discount,
reason: getCouponUnusableReason(bestCoupon, total)
}
})
if (discount > 0) {
setSelectedCoupon(bestCoupon)
// 显示智能推荐提示
Taro.showToast({
title: `已为您推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
}
// 🔔 优惠券提示:如果有可用优惠券,显示提示
if (usableCoupons.length > 0) {
console.log(`发现${usableCoupons.length}张可用优惠券,已为您推荐最优惠券`)
}
console.log('加载优惠券成功:', {
originalData: res,
transformedData: transformedCoupons,
sortedData: sortedCoupons
sortedData: sortedCoupons,
usableCoupons: usableCoupons,
recommendedCoupon: usableCoupons[0] || null
})
} else {
setAvailableCoupons([])
@@ -213,6 +450,32 @@ const OrderConfirm = () => {
return;
}
// 水票套票商品:保存配送时间到 ShopOrder.sendStartTime
if (hasTicketTemplate && !sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (hasTicketTemplate) {
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
Taro.showToast({
title: `已过当日${DELIVERY_CUTOFF_HOUR}点截单,最早配送:${min.format('YYYY-MM-DD')}`,
icon: 'none'
})
return
}
}
// 水票套票活动:最小购买量校验
if (isTicketTemplateActive && quantity < minBuyQty) {
Taro.showToast({
title: `最低购买量:${minBuyQty}`,
icon: 'none'
})
return
}
// 库存校验
if (goods.stock !== undefined && quantity > goods.stock) {
Taro.showToast({
@@ -233,6 +496,56 @@ const OrderConfirm = () => {
})
return;
}
} else {
// 🔔 支付前最后一次检查:提醒用户是否有可用优惠券
const total = getGoodsTotal()
const usableCoupons = filterUsableCoupons(availableCoupons, total)
if (usableCoupons.length > 0) {
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, total)
if (discount > 0) {
// 用模态框提醒用户
const confirmResult = await new Promise<boolean>((resolve) => {
Taro.showModal({
title: '发现可用优惠券',
content: `您有优惠券可使用,可省¥${discount.toFixed(2)},是否使用?`,
success: (res) => resolve(res.confirm),
fail: () => resolve(false)
})
})
if (confirmResult) {
setSelectedCoupon(bestCoupon)
// 🔄 使用优惠券后需要重新构建订单数据,这里直接递归调用支付函数
// 但要确保传递最新的优惠券信息
const currentPaymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
const updatedOrderData = buildSingleGoodsOrder(
goods.goodsId!,
quantity,
address.id,
{
comments: goods.name,
deliveryType: 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
couponId: parseInt(String(bestCoupon.id), 10)
}
);
console.log('🎯 使用推荐优惠券的订单数据:', updatedOrderData);
// 执行支付
await PaymentHandler.pay(updatedOrderData, currentPaymentType);
return; // 提前返回,避免重复执行支付
} else {
// 用户选择不使用优惠券,继续支付
}
}
}
}
// 构建订单数据
@@ -241,26 +554,43 @@ const OrderConfirm = () => {
quantity,
address.id,
{
comments: goods.name,
comments: '易赊宝',
deliveryType: 0,
buyerRemarks: orderRemark,
// 确保couponId是数字类型
couponId: selectedCoupon ? Number(selectedCoupon.id) : undefined
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined
}
);
// 根据支付方式选择支付类型
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
console.log('开始支付:', {
// 🔍 支付前的详细信息记录
console.log('💰 开始支付 - 详细信息:', {
orderData,
paymentType,
selectedCoupon: selectedCoupon ? {
id: selectedCoupon.id,
title: selectedCoupon.title,
type: selectedCoupon.type,
amount: selectedCoupon.amount,
minAmount: selectedCoupon.minAmount,
discount: getCouponDiscount()
} : null,
finalPrice: getFinalPrice()
priceCalculation: {
goodsPrice: goods?.price,
quantity: quantity,
goodsTotal: getGoodsTotal(),
couponDiscount: getCouponDiscount(),
finalPrice: getFinalPrice()
},
couponValidation: selectedCoupon ? {
isUsable: isCouponUsable(selectedCoupon, getGoodsTotal()),
reason: getCouponUnusableReason(selectedCoupon, getGoodsTotal())
} : null
});
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理
@@ -272,31 +602,35 @@ const OrderConfirm = () => {
// icon: 'success'
// })
} catch (error: any) {
return navTo('/user/order/order?statusFilter=0', true)
// console.error('支付失败:', error)
const message = String(error?.message || '')
const isOutOfDeliveryRange =
message.includes('不在配送范围') ||
message.includes('配送范围') ||
message.includes('电子围栏') ||
message.includes('围栏')
// 只处理PaymentHandler未处理的错误
// if (!error.handled) {
// let errorMessage = '支付失败,请重试';
//
// // 根据错误类型提供具体提示
// if (error.message?.includes('余额不足')) {
// errorMessage = '账户余额不足,请充值后重试';
// } else if (error.message?.includes('优惠券')) {
// errorMessage = '优惠券使用失败,请重新选择';
// } else if (error.message?.includes('库存')) {
// errorMessage = '商品库存不足,请减少购买数量';
// } else if (error.message?.includes('地址')) {
// errorMessage = '收货地址信息有误,请重新选择';
// } else if (error.message) {
// errorMessage = error.message;
// }
// Taro.showToast({
// title: errorMessage,
// icon: 'error'
// })
// console.log('跳去未付款的订单列表页面')
// }
// “配送范围”类错误给出更友好的解释,并提供快捷入口去更换收货地址
if (isOutOfDeliveryRange) {
try {
const res = await Taro.showModal({
title: '暂不支持配送',
content: '当前收货地址超出配送范围。您可以更换收货地址后再下单,或联系门店确认配送范围。',
confirmText: '更换地址',
cancelText: '我知道了'
})
if (res?.confirm) {
Taro.navigateTo({ url: '/user/address/index' })
}
} catch (_e) {
// ignore
}
return
}
// 兜底:仅在 PaymentHandler 未弹过提示时再提示一次
if (!error?.handled) {
Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' })
}
} finally {
setPayLoading(false)
}
@@ -304,6 +638,9 @@ const OrderConfirm = () => {
// 统一的数据加载函数
const loadAllData = async () => {
// 未登录时不发起接口请求;页面会被登录兜底逻辑引导走注册/登录页
if (!isLoggedIn()) return
try {
setLoading(true)
setError('')
@@ -320,10 +657,42 @@ const OrderConfirm = () => {
])
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
// 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单)
let tpl: GltTicketTemplate | null = null
if (goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goodsId))
} catch (e) {
tpl = null
}
}
const tplActive =
!!tpl &&
tpl.enabled !== false &&
tpl.status !== 1 &&
tpl.deleted !== 1
const tplMinBuyQty = (() => {
const n = Number(tpl?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
// 设置商品信息(若存在套票模板,则默认 canBuyNumber 使用模板最小购买量)
if (goodsRes) {
const patchedGoods: ShopGoods = { ...goodsRes }
if (tplActive && ((patchedGoods.canBuyNumber ?? 0) === 0)) {
patchedGoods.canBuyNumber = tplMinBuyQty
}
setGoods(patchedGoods)
// 设置默认购买数量:优先使用 canBuyNumber否则使用 1
const initQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? (patchedGoods.canBuyNumber as number) : 1
setQuantity(initQty)
}
setTicketTemplate(tpl)
// 设置默认收货地址
if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0])
@@ -338,9 +707,16 @@ const OrderConfirm = () => {
setPayment(paymentRes[0])
}
// 加载优惠券(在商品信息加载完成后)
// 加载优惠券:使用“初始数量”对应的总价做推荐,避免默认数量变化导致推荐不准
if (goodsRes) {
await loadUserCoupons()
const initQty = (() => {
const n = Number(goodsRes?.canBuyNumber)
if (Number.isFinite(n) && n > 0) return Math.floor(n)
if (tplActive) return tplMinBuyQty
return 1
})()
const total = parseFloat(goodsRes.price || '0') * initQty
await loadUserCoupons(total)
}
} catch (err) {
console.error('加载数据失败:', err)
@@ -351,10 +727,17 @@ const OrderConfirm = () => {
}
useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店
if (!isLoggedIn()) return
setSelectedStore(getSelectedStoreFromStorage())
loadAllData()
})
useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
if (!isLoggedIn()) return
setSendTime(getMinSendDate().toDate())
setSendTimePickerVisible(false)
loadAllData()
}, [goodsId]);
@@ -413,6 +796,48 @@ const OrderConfirm = () => {
)}
</CellGroup>
{hasTicketTemplate && (
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={() => {
// 若页面停留跨过截单时间,打开选择器前再校正一次最早可选日期
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
}
setSendTimePickerVisible(true)
}}
/>
</CellGroup>
)}
{/*<CellGroup>*/}
{/* <Cell*/}
{/* title={(*/}
{/* <View className="flex items-center gap-2">*/}
{/* <Shop className={'text-gray-500'}/>*/}
{/* <Text>门店</Text>*/}
{/* </View>*/}
{/* )}*/}
{/* extra={(*/}
{/* <View className={'flex items-center gap-2'}>*/}
{/* <View className={'text-gray-900'}>*/}
{/* {selectedStore?.name || '请选择门店'}*/}
{/* </View>*/}
{/* <ArrowRight className={'text-gray-400'} size={14}/>*/}
{/* </View>*/}
{/* )}*/}
{/* onClick={openStorePopup}*/}
{/* />*/}
{/*</CellGroup>*/}
<CellGroup>
<Cell key={goods.goodsId}>
<View className={'flex w-full justify-between gap-3'}>
@@ -422,26 +847,34 @@ const OrderConfirm = () => {
height: '80px',
}} lazyLoad={false}/>
</View>
<View className={'flex flex-col w-full'} style={{width: '100%'}}>
<View className={'flex flex-col w-full ml-2'} style={{width: '100%'}}>
<Text className={'font-medium w-full'}>{goods.name}</Text>
<Text className={'number text-gray-400 text-sm py-2'}>80g/</Text>
{/*<Text className={'number text-gray-400 text-sm py-2'}>80g/袋</Text>*/}
<View className={'flex justify-between items-center'}>
<Text className={'text-red-500'}>{goods.price}</Text>
<View className={'flex flex-col items-end gap-1'}>
<ConfigProvider theme={customTheme}>
<InputNumber
value={quantity}
min={1}
max={goods.stock || 999}
disabled={goods.canBuyNumber != 0}
onChange={handleQuantityChange}
/>
</ConfigProvider>
<ConfigProvider theme={customTheme}>
<InputNumber
value={quantity}
min={isTicketTemplateActive ? minBuyQty : 1}
max={goods.stock || 999}
step={minBuyQty === 1 ? 1 : 10}
readOnly
disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive}
onChange={handleQuantityChange}
/>
</ConfigProvider>
{goods.stock !== undefined && (
<Text className={'text-xs text-gray-400'}>
{goods.stock}
</Text>
)}
{isTicketTemplateActive && (
<View className={'text-xs text-gray-500'}>
<Text>{minBuyQty}</Text>
<Text className={'ml-2'}>{getGiftTicketQty(quantity)}</Text>
</View>
)}
</View>
</View>
</View>
@@ -474,7 +907,31 @@ const OrderConfirm = () => {
<View className={'text-red-500 text-sm mr-1'}>
{selectedCoupon ? `-¥${getCouponDiscount().toFixed(2)}` : '暂未使用'}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
{(() => {
const usableCoupons = filterUsableCoupons(availableCoupons, getGoodsTotal())
if (usableCoupons.length > 0 && !selectedCoupon) {
return (
<View className={'flex items-center'}>
<View className={'bg-red-500 text-white text-xs px-2 py-1 rounded mr-2'}>
{usableCoupons.length}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)
} else if (usableCoupons.length > 0) {
return (
<View className={'flex items-center'}>
<View className={'bg-green-500 text-white text-xs px-2 py-1 rounded mr-2'}>
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)
} else {
return <ArrowRight className={'text-gray-400'} size={14}/>
}
})()
}
</View>
)}
onClick={() => setCouponVisible(true)}
@@ -502,6 +959,20 @@ const OrderConfirm = () => {
)}/>
</CellGroup>
{ticketTemplate && (
<CellGroup>
<Cell extra={(
<div className={'text-red-500 text-sm'}>
<Text></Text>
<Text>{ticketTemplate.startSendQty}</Text>
<Text></Text>
<Text></Text>
</div>
)}/>
</CellGroup>
)}
{/* 支付方式选择 */}
<ActionSheet
visible={isVisible}
@@ -582,8 +1053,82 @@ const OrderConfirm = () => {
</View>
</Popup>
{/* 门店选择弹窗 */}
<Popup
visible={storePopupVisible}
position="bottom"
style={{height: '70vh'}}
onClose={() => setStorePopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setStorePopupVisible(false)}
>
</Text>
</View>
{storeLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<CellGroup>
{stores.map((s) => {
const isActive = !!selectedStore?.id && selectedStore.id === s.id
return (
<Cell
key={s.id}
title={<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>}
description={s.address || ''}
onClick={async () => {
let storeToSave: ShopStore = s
if (s?.id) {
try {
const full = await getShopStore(s.id)
if (full) storeToSave = full
} catch (_e) {
// keep base item
}
}
setSelectedStore(storeToSave)
saveSelectedStoreToStorage(storeToSave)
setStorePopupVisible(false)
Taro.showToast({title: '门店已切换', icon: 'success'})
}}
/>
)
})}
{!stores.length && (
<Cell title={<Text className="text-gray-500"></Text>} />
)}
</CellGroup>
)}
</View>
</Popup>
<Gap height={50}/>
<DatePicker
visible={sendTimePickerVisible}
title="选择配送时间"
type="date"
startDate={getMinSendDate().toDate()}
endDate={dayjs().add(30, 'day').toDate()}
value={sendTime}
onClose={() => setSendTimePickerVisible(false)}
onCancel={() => setSendTimePickerVisible(false)}
onConfirm={(_options, selectedValue) => {
const [y, m, d] = (selectedValue || []).map(v => Number(v))
const next = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0)
setSendTime(next)
setSendTimePickerVisible(false)
}}
/>
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10 border-t border-gray-200'}>
<View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}>
@@ -602,6 +1147,7 @@ const OrderConfirm = () => {
type="success"
size="large"
loading={payLoading}
disabled={isTicketTemplateActive && quantity < minBuyQty}
onClick={() => onPay(goods)}
>
{payLoading ? '支付中...' : '立即付款'}

View File

@@ -2,8 +2,6 @@ import {useEffect, useState} from "react";
import {Image, Button, Cell, CellGroup, Input, Space} from '@nutui/nutui-react-taro'
import {Location, ArrowRight} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {ShopGoods} from "@/api/shop/shopGoods/model";
import {getShopGoods} from "@/api/shop/shopGoods";
import {View} from '@tarojs/components';
import {listShopUserAddress} from "@/api/shop/shopUserAddress";
import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
@@ -12,30 +10,31 @@ import {useCart, CartItem} from "@/hooks/useCart";
import Gap from "@/components/Gap";
import {Payment} from "@/api/system/payment/model";
import {PaymentHandler, PaymentType, buildCartOrder} from "@/utils/payment";
import { ensureLoggedIn } from '@/utils/auth'
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
const [payment, setPayment] = useState<Payment>()
const [payment] = useState<Payment>()
const [checkoutItems, setCheckoutItems] = useState<CartItem[]>([]);
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const {
cartItems,
removeFromCart
} = useCart();
console.log(goods, 'goods>>>>')
console.log(setPayment,'setPayment>>>')
const reload = async () => {
const address = await listShopUserAddress({isDefault: true});
if (address.length > 0) {
console.log(address, '111')
setAddress(address[0])
const addressList = await listShopUserAddress({isDefault: true});
if (addressList.length > 0) {
setAddress(addressList[0])
}
}
// 页面级兜底:防止未登录时进入结算页导致接口报错/仅提示“请先登录”
useEffect(() => {
// redirect 到当前结算页,登录成功后返回继续支付
if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return
}, [])
// 加载结算商品数据
const loadCheckoutItems = () => {
try {
@@ -59,6 +58,8 @@ const OrderConfirm = () => {
* 统一支付入口
*/
const onPay = async () => {
if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return
// 基础校验
if (!address) {
Taro.showToast({
@@ -79,7 +80,7 @@ const OrderConfirm = () => {
// 构建订单数据
const orderData = buildCartOrder(
checkoutItems.map(item => ({
goodsId: item.goodsId!,
goodsId: item.goodsId,
quantity: item.quantity || 1
})),
address.id,
@@ -104,16 +105,11 @@ const OrderConfirm = () => {
};
useEffect(() => {
if (goodsId) {
getShopGoods(Number(goodsId)).then(res => {
setGoods(res);
}).catch(error => {
console.error("Failed to fetch goods detail:", error);
});
}
if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return
reload().then();
loadCheckoutItems();
}, [goodsId, cartItems]);
}, [cartItems]);
// 计算总价
const getTotalPrice = () => {
@@ -159,19 +155,19 @@ const OrderConfirm = () => {
</CellGroup>
<CellGroup>
{checkoutItems.map((goods, _) => (
<Cell key={goods.goodsId}>
{checkoutItems.map((item) => (
<Cell key={item.goodsId}>
<Space>
<Image src={goods.image} mode={'aspectFill'} style={{
<Image src={item.image} mode={'aspectFill'} style={{
width: '80px',
height: '80px',
}} lazyLoad={false}/>
<View className={'flex flex-col'}>
<View className={'font-medium w-full'}>{goods.name}</View>
<View className={'number text-gray-400 text-sm py-2'}>80g/</View>
<View className={'font-medium w-full'}>{item.name}</View>
{/*<View className={'number text-gray-400 text-sm py-2'}>80g/袋</View>*/}
<Space className={'flex justify-start items-center'}>
<View className={'text-red-500'}>{goods.price}</View>
<View className={'text-gray-500 text-sm'}>x {goods.quantity}</View>
<View className={'text-red-500'}>{item.price}</View>
<View className={'text-gray-500 text-sm'}>x {item.quantity}</View>
</Space>
</View>
</Space>

View File

@@ -3,7 +3,6 @@
.nut-cell-group__title {
padding: 10px 16px;
font-size: 14px;
color: #999;
}
@@ -24,4 +23,4 @@
margin-left: 10px;
}
}
}
}

View File

@@ -1,33 +1,48 @@
import {useEffect, useState} from "react";
import {Cell, CellGroup, Image, Space, Button} from '@nutui/nutui-react-taro'
import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
import {ShopOrder} from "@/api/shop/shopOrder/model";
import {getShopOrder, updateShopOrder} from "@/api/shop/shopOrder";
import {getShopOrder, updateShopOrder, refundShopOrder} from "@/api/shop/shopOrder";
import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs";
import PaymentCountdown from "@/components/PaymentCountdown";
import './index.scss'
// 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验)
const isWithinRefundWindow = (payTime?: string, windowMinutes: number = 60): boolean => {
if (!payTime) return false;
const raw = String(payTime).trim();
const t = /^\d+$/.test(raw)
? dayjs(Number(raw) < 1e12 ? Number(raw) * 1000 : Number(raw)) // 兼容秒/毫秒时间戳
: dayjs(raw);
if (!t.isValid()) return false;
return dayjs().diff(t, 'minute') <= windowMinutes;
};
const OrderDetail = () => {
const [order, setOrder] = useState<ShopOrder | null>(null);
const [orderGoodsList, setOrderGoodsList] = useState<ShopOrderGoods[]>([]);
const [confirmReceiveDialogVisible, setConfirmReceiveDialogVisible] = useState(false)
const router = Taro.getCurrentInstance().router;
const orderId = router?.params?.orderId;
// 处理支付超时
const handlePaymentExpired = async () => {
if (!order) return;
if (!order.orderId) return;
try {
// 自动取消过期订单
await updateShopOrder({
...order,
// 只传最小字段,避免误取消/误走售后流程
orderId: order.orderId,
orderStatus: 2 // 已取消
});
// 更新本地状态
setOrder(prev => prev ? { ...prev, orderStatus: 2 } : null);
setOrder(prev => prev ? {...prev, orderStatus: 2} : null);
Taro.showToast({
title: '订单已自动取消',
@@ -39,6 +54,66 @@ const OrderDetail = () => {
}
};
// 申请退款
const handleApplyRefund = async () => {
if (order) {
try {
const confirm = await Taro.showModal({
title: '申请退款',
content: '确认要申请退款吗?',
confirmText: '确认',
cancelText: '取消'
})
if (!confirm?.confirm) return
Taro.showLoading({ title: '提交中...' })
// 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
await refundShopOrder({
orderId: order.orderId,
refundMoney: order.payPrice || order.totalPrice,
orderStatus: 7
})
// 乐观更新本地状态
setOrder(prev => prev ? { ...prev, orderStatus: 7 } : null)
Taro.showToast({ title: '退款申请已提交', icon: 'success' })
} catch (error) {
console.error('申请退款失败:', error);
Taro.showToast({
title: '操作失败,请重试',
icon: 'none'
});
} finally {
try {
Taro.hideLoading()
} catch (_e) {
// ignore
}
}
}
};
// 确认收货(客户)
const handleConfirmReceive = async () => {
if (!order?.orderId) return
try {
setConfirmReceiveDialogVisible(false)
await updateShopOrder({
orderId: order.orderId,
deliveryStatus: order.deliveryStatus, // 10未发货 20已发货 30部分发货
orderStatus: 1 // 已完成
})
Taro.showToast({title: '确认收货成功', icon: 'success'})
setOrder(prev => (prev ? {...prev, orderStatus: 1} : prev))
} catch (e) {
console.error('确认收货失败:', e)
Taro.showToast({title: '确认收货失败', icon: 'none'})
setConfirmReceiveDialogVisible(true)
}
}
const getOrderStatusText = (order: ShopOrder) => {
// 优先检查订单状态
if (order.orderStatus === 2) return '已取消';
@@ -53,8 +128,15 @@ const OrderDetail = () => {
// 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) return '待收货';
if (order.deliveryStatus === 30) return '已收货';
if (order.deliveryStatus === 20) {
// 若订单有配送员,则以配送员送达时间作为“可确认收货”的依据
if (order.riderId) {
if (order.sendEndTime && order.orderStatus !== 1) return '待确认收货';
return '配送中';
}
return '待收货';
}
if (order.deliveryStatus === 30) return '部分发货';
// 最后检查订单完成状态
if (order.orderStatus === 1) return '已完成';
@@ -65,25 +147,33 @@ const OrderDetail = () => {
const getPayTypeText = (payType?: number) => {
switch (payType) {
case 0: return '余额支付';
case 1: return '微信支付';
case 102: return '微信Native';
case 2: return '会员卡支付';
case 3: return '支付宝';
case 4: return '现金';
case 5: return 'POS机';
default: return '未知支付方式';
case 0:
return '余额支付';
case 1:
return '微信支付';
case 102:
return '微信Native';
case 2:
return '会员卡支付';
case 3:
return '支付宝';
case 4:
return '现金';
case 5:
return 'POS机';
default:
return '未知支付方式';
}
};
useEffect(() => {
if (orderId) {
console.log('shop-goods',orderId)
console.log('shop-goods', orderId)
getShopOrder(Number(orderId)).then(async (res) => {
setOrder(res);
// 获取订单商品列表
const goodsRes = await listShopOrderGoods({ orderId: Number(orderId) });
const goodsRes = await listShopOrderGoods({orderId: Number(orderId)});
if (goodsRes && goodsRes.length > 0) {
setOrderGoodsList(goodsRes);
}
@@ -97,12 +187,22 @@ const OrderDetail = () => {
return <div>...</div>;
}
const currentUserId = Number(Taro.getStorageSync('UserId'))
const isOwner = !!currentUserId && currentUserId === order.userId
const canConfirmReceive =
isOwner &&
order.payStatus &&
order.orderStatus !== 1 &&
order.deliveryStatus === 20 &&
(!order.riderId || !!order.sendEndTime)
return (
<div className={'order-detail-page'}>
{/* 支付倒计时显示 - 详情页实时更新 */}
{!order.payStatus && order.orderStatus !== 2 && (
<div className="order-detail-countdown p-4 bg-red-50 border-b border-red-100">
<div className="order-detail-countdown flex justify-center p-4 border-b border-gray-50">
<PaymentCountdown
expirationTime={order.expirationTime}
createTime={order.createTime}
payStatus={order.payStatus}
realTime={true}
@@ -113,17 +213,11 @@ const OrderDetail = () => {
</div>
)}
<CellGroup title="订单信息">
<Cell title="订单编号" description={order.orderNo} />
<Cell title="下单时间" description={dayjs(order.createTime).format('YYYY-MM-DD HH:mm:ss')} />
<Cell title="订单状态" description={getOrderStatusText(order)} />
</CellGroup>
<CellGroup title="商品信息">
<CellGroup>
{orderGoodsList.map((item, index) => (
<Cell key={index}>
<div className={'flex items-center'}>
<Image src={item.image || '/default-goods.png'} width="80" height="80" lazyLoad={false} />
<Image src={item.image || '/default-goods.png'} width="80" height="80" lazyLoad={false}/>
<div className={'ml-2'}>
<div className={'text-sm font-bold'}>{item.goodsName}</div>
{item.spec && <div className={'text-gray-500 text-xs'}>{item.spec}</div>}
@@ -135,25 +229,52 @@ const OrderDetail = () => {
))}
</CellGroup>
<CellGroup title="收货信息">
<Cell title="收货人" description={order.realName} />
<Cell title="手机号" description={order.phone} />
<Cell title="收货地址" description={order.address} />
<CellGroup>
<Cell title="订单编号" description={order.orderNo}/>
<Cell title="下单时间" description={dayjs(order.createTime).format('YYYY-MM-DD HH:mm:ss')}/>
<Cell title="订单状态" description={getOrderStatusText(order)}/>
</CellGroup>
<CellGroup title="支付信息">
<Cell title="支付方式" description={getPayTypeText(order.payType)} />
<Cell title="实付金额" description={`${order.payPrice}`} />
<CellGroup>
<Cell title="收货人" description={order.realName}/>
<Cell title="手机号" description={order.phone}/>
<Cell title="收货地址" description={order.address}/>
</CellGroup>
<div className={'fixed-bottom'}>
<Space>
{!order.payStatus && <Button size="small" onClick={() => console.log('取消订单')}></Button>}
{!order.payStatus && <Button size="small" type="primary" onClick={() => console.log('立即支付')}></Button>}
{order.orderStatus === 1 && <Button size="small" onClick={() => console.log('申请退款')}>退</Button>}
{order.deliveryStatus === 20 && <Button size="small" type="primary" onClick={() => console.log('确认收货')}></Button>}
</Space>
</div>
{order.payStatus && (
<CellGroup>
<Cell title="支付方式" description={getPayTypeText(order.payType)}/>
<Cell title="实付金额" description={`${order.payPrice}`}/>
</CellGroup>
)}
<View className={'h5-div fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-5 border-t border-gray-200'}>
<View className={'flex justify-end px-4'}>
<Space>
{!order.payStatus && <Button onClick={() => console.log('取消订单')}></Button>}
{!order.payStatus && <Button type="primary" onClick={() => console.log('立即支付')}></Button>}
{order.orderStatus === 1 && order.payStatus && isWithinRefundWindow(order.payTime, 60) && (
<Button onClick={handleApplyRefund}>退</Button>
)}
{canConfirmReceive && (
<Button type="primary" onClick={() => setConfirmReceiveDialogVisible(true)}>
</Button>
)}
</Space>
</View>
</View>
<Dialog
title="确认收货"
visible={confirmReceiveDialogVisible}
confirmText="确认收货"
cancelText="我再想想"
onConfirm={handleConfirmReceive}
onCancel={() => setConfirmReceiveDialogVisible(false)}
>
</Dialog>
</div>
);
};

View File

@@ -18,7 +18,7 @@ const GoodsItem = ({ goods }: GoodsItemProps) => {
}
return (
<div className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
<View className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
<Image
src={goods.image || ''}
mode={'aspectFit'}
@@ -27,31 +27,31 @@ const GoodsItem = ({ goods }: GoodsItemProps) => {
height="180"
onClick={goToDetail}
/>
<div className={'flex flex-col p-2 rounded-lg'}>
<div>
<div className={'car-no text-sm'}>{goods.name || goods.goodsName}</div>
<div className={'flex justify-between text-xs py-1'}>
<View className={'flex flex-col p-2 rounded-lg'}>
<View>
<View className={'car-no text-sm'}>{goods.name || goods.goodsName}</View>
<View className={'flex justify-between text-xs py-1'}>
<span className={'text-orange-500'}>{goods.comments || ''}</span>
<span className={'text-gray-400'}> {goods.sales || 0}</span>
</div>
<div className={'flex justify-between items-center py-2'}>
<div className={'flex text-red-500 text-xl items-baseline'}>
</View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<span className={'text-xs'}></span>
<span className={'font-bold text-2xl'}>{goods.price || '0.00'}</span>
</div>
<div className={'buy-btn'}>
<div className={'cart-icon'}>
</View>
<View className={'buy-btn'}>
<View className={'cart-icon'}>
<Share size={20} className={'mx-4 mt-2'}
onClick={goToDetail}/>
</div>
<div className={'text-white pl-4 pr-5'}
</View>
<View className={'text-white pl-4 pr-5'}
onClick={goToDetail}>
</div>
</div>
</div>
</div>
</div>
</div>
</View>
</View>
</View>
</View>
</View>
</View>
)
}