Compare commits

...

10 Commits

Author SHA1 Message Date
cc58bd791d feat(ticket): 添加水票释放计划功能
- 在应用配置中注册新的释放计划页面路由 ticket/release/index
- 简化 API 请求参数结构,移除不必要的包装对象
- 在用户票券列表中添加释放计划详情入口和跳转逻辑
- 显示票券套票名称信息增强用户体验
- 在配送时间选择中添加日期验证防止选择过去日期
- 新增完整的释放计划详情页面实现列表展示、下拉刷新、上拉加载等功能
- 添加释放计划状态显示和数量统计信息展示
2026-03-10 15:30:38 +08:00
ac194b93eb feat(ticket): 添加水票释放计划功能
- 在应用配置中注册新的释放计划页面路由 ticket/release/index
- 简化 API 请求参数结构,移除不必要的包装对象
- 在用户票券列表中添加释放计划详情入口和跳转逻辑
- 显示票券套票名称信息增强用户体验
- 在配送时间选择中添加日期验证防止选择过去日期
- 新增完整的释放计划详情页面实现列表展示、下拉刷新、上拉加载等功能
- 添加释放计划状态显示和数量统计信息展示
2026-03-10 15:27:33 +08:00
1cdb6404ad feat(ticket): 添加水票立即送水功能
- 引入 ensureLoggedIn 工具函数用于登录验证
- 实现 goSendWater 函数处理送水逻辑
- 添加水票状态和可用次数校验
- 在水票列表项中添加立即送水按钮
- 设置按钮禁用状态根据水票可用性
- 防止卡片点击事件冒泡冲突
2026-03-10 13:56:02 +08:00
ef6a55112f fix(water-delivery): 移除送水功能中的硬编码商品ID
- 移除了立即送水按钮中的固定商品ID参数
- 修改ensureLoggedIn函数调用,不再传递商品ID
- 更新Taro.navigateTo路径,移除硬编码的goodsId参数
2026-03-10 13:43:52 +08:00
00f3954012 feat(ticket): 实现基于模板配置的动态起送数量功能
- 引入 gltTicketTemplate API 获取模板配置
- 将固定起送数量改为动态可配置的最小起送数量
- 添加基于商品ID或票据模板ID获取起送配置的功能
- 实现页面初始化时从票据模板加载起送数量配置
- 更新用户界面显示实际的动态起送数量要求
- 添加异步加载和取消请求的安全处理机制
2026-03-10 12:11:48 +08:00
0c9a03d656 feat(ticket): 添加水票可用数量计算和订单取消后水票回退功能
- 引入 getGltUserTicket 和 updateGltUserTicket API 接口
- 实现 getTicketAvailableQty 函数用于计算水票可用数量
- 添加 rollbackUserTicketAfterOrderCancel 函数处理订单取消后的水票回退逻辑
- 在订单取消时获取取消前的水票状态并进行数量对比
- 订单取消成功后自动回退相应的水票数量
- 添加水票回退失败时的错误提示和用户通知
- 更新取消订单的成功提示信息为"订单已取消,水票已退回"
2026-03-10 11:36:59 +08:00
80d4db4156 config(server): 切换到正式服务器API地址
- 将SERVER_API_URL从测试地址切换为正式地址
- 注释掉旧的服务器地址配置
- 确保使用正确的线上服务接口地址
2026-03-10 11:30:32 +08:00
a6749bcedb fix(order): 优化订单状态判断逻辑并修复页面跳转参数
- 修改送水订单跳转链接,添加tab参数支持
- 更新骑手端页面样式,添加业务渐变背景色
- 将骑手端"工资明细"改为"收入明细"
- 优化订单配送状态判断逻辑,支持配送未开始的订单修改取消
- 更新订单操作提示文案,从"待配送"改为"配送未开始"
- 实现页面tab参数解析,支持通过URL参数指定默认标签页
- 调整按钮文字顺序,将"订单修改/取消"改为"修改订单/取消订单"
- 更新服务器API地址配置,切换到新的生产环境域名
2026-03-10 11:22:34 +08:00
49c801c751 refactor(order): 重构订单状态处理逻辑并优化送水订单功能
- 将订单状态相关工具函数提取到独立的 utils 文件中
- 统一订单状态文本和颜色显示逻辑
- 移除重复的状态判断函数
- 优化送水订单列表的数据过滤逻辑
- 添加订单编辑模式支持
- 实现订单修改和取消功能
- 修复订单状态判断中的数值转换问题
- 优化送水订单的时间选择组件
- 添加订单数据加载和验证逻辑
- 重构订单详情页的条件渲染逻辑
2026-03-09 12:48:02 +08:00
3248315f6e refactor(shop): 移除水票套票商品配送时间选择功能
- 删除了配送时间相关的状态管理和日期选择器组件
- 移除了配送时间验证和格式化逻辑
- 更新了订单提交流程,不再传递配送时间参数
- 修改支付回调处理,支持自定义成功行为和跳转逻辑
- 简化了水票商品的购买流程,移除配送时间相关校验
2026-03-09 12:17:29 +08:00
15 changed files with 882 additions and 374 deletions

View File

@@ -8,9 +8,7 @@ import type { GltUserTicketRelease, GltUserTicketReleaseParam } from './model';
export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<PageResult<GltUserTicketRelease>>>(
'/glt/glt-user-ticket-release/page',
{
params
}
);
if (res.code === 0) {
return res.data;
@@ -24,9 +22,7 @@ export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam
export async function listGltUserTicketRelease(params?: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<GltUserTicketRelease[]>>(
'/glt/glt-user-ticket-release',
{
params
}
);
if (res.code === 0 && res.data) {
return res.data;

View File

@@ -56,6 +56,7 @@ export default {
"points/points",
"ticket/index",
"ticket/use",
"ticket/release/index",
"ticket/orders/index",
// "gift/index",
// "gift/redeem",

View File

@@ -491,7 +491,7 @@ const DealerWithdraw: React.FC = () => {
<Form.Item name="amount" label="提现金额">
<Input
placeholder="请输入提现金额"
type="number"
type="digit"
/>
</Form.Item>

View File

@@ -217,8 +217,8 @@ function Home() {
title: '立即送水',
icon: <Cart size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return
Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' })
if (!ensureLoggedIn('/user/ticket/use')) return
Taro.navigateTo({ url: '/user/ticket/use' })
},
},
{
@@ -226,8 +226,9 @@ function Home() {
title: '送水订单',
icon: <Agenda size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return
Taro.navigateTo({ url: '/user/ticket/index' })
const url = '/user/ticket/index?tab=order'
if (!ensureLoggedIn(url)) return
Taro.navigateTo({ url })
},
},
{

View File

@@ -74,7 +74,14 @@ const DealerIndex: React.FC = () => {
<View>
{/*头部信息*/}
{dealerUser && (
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
<View
className="px-4 py-6 relative overflow-hidden"
style={{
...themeStyles.primaryBackground,
background: businessGradients.order.processing,
color: '#ffffff'
}}
>
{/* 装饰性背景元素 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
@@ -218,7 +225,7 @@ const DealerIndex: React.FC = () => {
</View>
</Grid.Item>
<Grid.Item text={'工资明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<Grid.Item text={'收入明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Purse color="#10b981" size="20"/>

View File

@@ -1,4 +1,4 @@
import {useEffect, useMemo, useState} from "react";
import {useEffect, useState} from "react";
import {
Image,
Button,
@@ -9,7 +9,6 @@ import {
ActionSheet,
Popup,
InputNumber,
DatePicker,
ConfigProvider
} from '@nutui/nutui-react-taro'
import {Location, ArrowRight} from '@nutui/icons-react-taro'
@@ -39,7 +38,6 @@ import {
filterUsableCoupons,
filterUnusableCoupons
} from "@/utils/couponUtils";
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";
@@ -57,18 +55,6 @@ 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)
@@ -122,10 +108,6 @@ const OrderConfirm = () => {
})()
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)
@@ -451,22 +433,7 @@ 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) {
@@ -530,9 +497,6 @@ const OrderConfirm = () => {
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)
}
);
@@ -540,7 +504,31 @@ const OrderConfirm = () => {
console.log('🎯 使用推荐优惠券的订单数据:', updatedOrderData);
// 执行支付
await PaymentHandler.pay(updatedOrderData, currentPaymentType);
await PaymentHandler.pay(updatedOrderData, currentPaymentType, hasTicketTemplate ? {
onSuccess: async () => {
const id = goods.goodsId
try {
const res = await Taro.showModal({
title: '提示',
content: '是否立刻送水?',
confirmText: '立刻送水',
cancelText: '稍后'
})
if (res?.confirm) {
if (id) {
await Taro.redirectTo({ url: `/user/ticket/use?goodsId=${id}` })
} else {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
} else {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
} catch (_e) {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
return false
}
} : undefined);
return; // 提前返回,避免重复执行支付
} else {
// 用户选择不使用优惠券,继续支付
@@ -558,9 +546,6 @@ const OrderConfirm = () => {
comments: '桂乐淘',
deliveryType: 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined
}
@@ -595,7 +580,31 @@ const OrderConfirm = () => {
});
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理
await PaymentHandler.pay(orderData, paymentType);
await PaymentHandler.pay(orderData, paymentType, hasTicketTemplate ? {
onSuccess: async () => {
const id = goods.goodsId
try {
const res = await Taro.showModal({
title: '提示',
content: '是否立刻送水?',
confirmText: '立刻送水',
cancelText: '稍后'
})
if (res?.confirm) {
if (id) {
await Taro.redirectTo({ url: `/user/ticket/use?goodsId=${id}` })
} else {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
} else {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
} catch (_e) {
await Taro.redirectTo({ url: '/user/ticket/index' })
}
return false
}
} : undefined);
// ✅ 移除双重成功提示 - PaymentHandler会处理成功提示
// Taro.showToast({
@@ -760,10 +769,7 @@ const OrderConfirm = () => {
})
useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
if (!isLoggedIn()) return
setSendTime(getMinSendDate().toDate())
setSendTimePickerVisible(false)
loadAllData()
}, [goodsId]);
@@ -822,28 +828,6 @@ 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={(*/}
@@ -1138,23 +1122,6 @@ const OrderConfirm = () => {
<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'}>

View File

@@ -8,6 +8,7 @@ import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs";
import PaymentCountdown from "@/components/PaymentCountdown";
import {getShopOrderStatusText} from "@/utils/shopOrderStatus";
import './index.scss'
// 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验)
@@ -114,37 +115,6 @@ const OrderDetail = () => {
}
}
const getOrderStatusText = (order: ShopOrder) => {
// 优先检查订单状态
if (order.orderStatus === 2) return '已取消';
if (order.orderStatus === 3) return '取消中';
if (order.orderStatus === 4) return '退款申请中';
if (order.orderStatus === 5) return '退款被拒绝';
if (order.orderStatus === 6) return '退款成功';
if (order.orderStatus === 7) return '客户端申请退款';
// 检查支付状态 (payStatus为boolean类型)
if (!order.payStatus) return '待付款';
// 已付款后检查发货状态
if (order.deliveryStatus === 10) 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 '已完成';
if (order.orderStatus === 0) return '未使用';
return '未知状态';
};
const getPayTypeText = (payType?: number) => {
switch (payType) {
case 0:
@@ -194,7 +164,7 @@ const OrderDetail = () => {
order.payStatus &&
order.orderStatus !== 1 &&
order.deliveryStatus === 20 &&
(!order.riderId || !!order.sendEndTime)
(!order.riderId || Number(order.riderId) === 0 || !!order.sendEndTime)
return (
<div className={'order-detail-page'}>
@@ -232,7 +202,7 @@ const OrderDetail = () => {
<CellGroup>
<Cell title="订单编号" description={order.orderNo}/>
<Cell title="下单时间" description={dayjs(order.createTime).format('YYYY-MM-DD HH:mm:ss')}/>
<Cell title="订单状态" description={getOrderStatusText(order)}/>
<Cell title="订单状态" description={getShopOrderStatusText(order)}/>
</CellGroup>
<CellGroup>

View File

@@ -16,6 +16,7 @@ import {copyText} from "@/utils/common";
import PaymentCountdown from "@/components/PaymentCountdown";
import {PaymentType} from "@/utils/payment";
import {ErrorType, RequestError} from "@/utils/request";
import {getShopOrderStatusColor, getShopOrderStatusText, isShopOrderCompleted} from "@/utils/shopOrderStatus";
// 判断订单是否支付已过期
const isPaymentExpired = (createTime: string, timeoutHours: number = 24): boolean => {
@@ -165,68 +166,11 @@ function OrderList(props: OrderListProps) {
};
// “已完成”应以订单状态为准不要用商品ID等字段推断完成态否则会造成 Tab(待发货/待收货) 与状态文案不同步
const isOrderCompleted = (order: ShopOrder) => toNum(order.orderStatus) === 1;
const isOrderCompleted = (order: ShopOrder) => isShopOrderCompleted(order);
// 获取订单状态文本
const getOrderStatusText = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
const getOrderStatusText = (order: ShopOrder) => getShopOrderStatusText(order);
// 优先检查订单状态
if (orderStatus === 2) return '已取消';
if (orderStatus === 4) return '退款申请中';
if (orderStatus === 5) return '退款被拒绝';
if (orderStatus === 6) return '退款成功';
if (orderStatus === 7) return '客户端申请退款';
if (isOrderCompleted(order)) return '已完成';
// 检查支付状态 (payStatus为boolean类型false/0表示未付款true/1表示已付款)
if (!order.payStatus) return '等待买家付款';
// 已付款后检查发货状态
if (deliveryStatus === 10) return '待发货';
if (deliveryStatus === 20) {
// 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId || Number(order.riderId) === 0) return '待收货';
// 配送员确认送达后sendEndTime有值才进入“待确认收货”
if (order.sendEndTime && !isOrderCompleted(order)) return '待确认收货';
return '配送中';
}
if (deliveryStatus === 30) return '部分发货';
if (orderStatus === 0) return '未使用';
return '未知状态';
};
// 获取订单状态颜色
const getOrderStatusColor = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
// 优先检查订单状态
if (orderStatus === 2) return 'text-gray-500'; // 已取消
if (orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (orderStatus === 6) return 'text-green-500'; // 退款成功
if (orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (isOrderCompleted(order)) return 'text-green-600'; // 已完成
// 检查支付状态
if (!order.payStatus) return 'text-orange-500'; // 等待买家付款
// 已付款后检查发货状态
if (deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (deliveryStatus === 20) {
if (!order.riderId || Number(order.riderId) === 0) return 'text-purple-500'; // 待收货
if (order.sendEndTime && !isOrderCompleted(order)) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中
}
if (deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600'; // 默认颜色
};
const getOrderStatusColor = (order: ShopOrder) => getShopOrderStatusColor(order);
// 使用后端统一的 statusFilter 进行筛选
const getOrderStatusParams = (index: string | number) => {

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import {
Button,
@@ -14,13 +14,13 @@ import {
Tag
} from '@nutui/nutui-react-taro';
import { View, Text, Image } from '@tarojs/components';
import { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import { getGltUserTicket, pageGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import { pageGltTicketOrder, removeGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model';
import { getShopUserAddress } from '@/api/shop/shopUserAddress';
import { BaseUrl } from '@/config/app';
import dayjs from "dayjs";
import { ensureLoggedIn } from '@/utils/auth';
const PAGE_SIZE = 10;
@@ -47,8 +47,6 @@ const UserTicketList = () => {
const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null);
const [qrImageUrl, setQrImageUrl] = useState('');
const addressCacheRef = useRef<Record<number, { lng: number; lat: number; fullAddress?: string } | null>>({});
const getUserId = () => {
const raw = Taro.getStorageSync('UserId');
const id = Number(raw);
@@ -97,6 +95,41 @@ const UserTicketList = () => {
setQrVisible(true);
};
const goSendWater = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
if (Number(ticket.status) === 1) {
Taro.showToast({ title: '该水票已冻结,无法下单', icon: 'none' });
return;
}
const avail = Number(ticket.availableQty ?? 0);
if (!Number.isFinite(avail) || avail <= 0) {
Taro.showToast({ title: '可用次数不足', icon: 'none' });
return;
}
const gid = Number(ticket.goodsId);
const url =
Number.isFinite(gid) && gid > 0 ? `/user/ticket/use?goodsId=${gid}` : '/user/ticket/use';
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const goReleasePlanDetail = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
const url = `/user/ticket/release/index?userTicketId=${encodeURIComponent(String(ticket.id))}&templateName=${encodeURIComponent(
String(ticket.templateName ?? '')
)}&frozenQty=${encodeURIComponent(String(ticket.frozenQty ?? 0))}&releasedQty=${encodeURIComponent(
String(ticket.releasedQty ?? 0)
)}`;
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -188,17 +221,16 @@ const UserTicketList = () => {
});
const resList = res?.list || [];
const nextList = isRefresh ? resList : [...orderList, ...resList];
const safeList = resList.filter((o) => Number((o as any)?.deleted) !== 1);
const nextList = isRefresh ? safeList : [...orderList, ...safeList];
setOrderList(nextList);
const count = typeof res?.count === 'number' ? res.count : nextList.length;
setOrderTotal(count);
setOrderHasMore(nextList.length < count);
const serverCount = typeof res?.count === 'number' ? res.count : undefined;
const total = typeof serverCount === 'number' ? serverCount : nextList.length;
setOrderTotal(total);
setOrderHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : resList.length >= PAGE_SIZE);
if (resList.length > 0) {
setOrderPage(currentPage + 1);
} else {
setOrderHasMore(false);
}
if (resList.length > 0) setOrderPage(currentPage + 1);
else setOrderHasMore(false);
} catch (error) {
console.error('获取送水订单失败:', error);
Taro.showToast({ title: '获取送水订单失败', icon: 'error' });
@@ -265,78 +297,136 @@ const UserTicketList = () => {
return d.isValid() ? d.format('YYYY年MM月DD日') : v;
};
const parseLatLng = (latRaw?: unknown, lngRaw?: unknown) => {
const lat = typeof latRaw === 'number' ? latRaw : parseFloat(String(latRaw ?? ''));
const lng = typeof lngRaw === 'number' ? lngRaw : parseFloat(String(lngRaw ?? ''));
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { lat, lng };
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0;
const anyT: any = t;
const raw =
anyT.availableQty ??
anyT.availableNum ??
anyT.availableCount ??
anyT.remainQty ??
anyT.remainNum ??
anyT.remainCount;
const n = Number(raw);
if (Number.isFinite(n)) return n;
const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0);
const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0);
const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0);
const computed =
(Number.isFinite(total) ? total : 0) -
(Number.isFinite(used) ? used : 0) -
(Number.isFinite(frozen) ? frozen : 0);
return Number.isFinite(computed) ? computed : 0;
};
const handleNavigateToAddress = async (order: GltTicketOrder) => {
try {
// Prefer coordinates from backend if present (non-typed fields), otherwise fetch by addressId.
const anyOrder = order as any;
const direct =
parseLatLng(anyOrder?.addressLat ?? anyOrder?.lat, anyOrder?.addressLng ?? anyOrder?.lng) ||
parseLatLng(anyOrder?.receiverLat, anyOrder?.receiverLng);
const rollbackUserTicketAfterOrderCancel = async (order: GltTicketOrder, before?: GltUserTicket | null) => {
const ticketId = Number(order?.userTicketId);
const qty = Math.max(0, Math.floor(Number((order as any)?.totalNum ?? 0)));
if (!Number.isFinite(ticketId) || ticketId <= 0) return;
if (!Number.isFinite(qty) || qty <= 0) return;
let coords = direct;
let fullAddress: string | undefined = order.address || undefined;
const after = await getGltUserTicket(ticketId);
if (!after?.id) return;
if (!coords && order.addressId) {
const cached = addressCacheRef.current[order.addressId];
if (cached) {
coords = { lat: cached.lat, lng: cached.lng };
fullAddress = fullAddress || cached.fullAddress;
} else if (cached === null) {
coords = null;
} else {
const addr = await getShopUserAddress(order.addressId);
const parsed = parseLatLng(addr?.lat, addr?.lng);
if (parsed) {
coords = parsed;
fullAddress = fullAddress || addr?.fullAddress || addr?.address || undefined;
addressCacheRef.current[order.addressId] = { ...parsed, fullAddress };
} else {
addressCacheRef.current[order.addressId] = null;
}
}
}
const beforeAvail = before ? getTicketAvailableQty(before) : undefined;
const afterAvail = getTicketAvailableQty(after);
if (!coords) {
if (fullAddress) {
await Taro.setClipboardData({ data: fullAddress });
Taro.showToast({ title: '未配置定位,地址已复制', icon: 'none' });
} else {
Taro.showToast({ title: '暂无可导航的地址', icon: 'none' });
}
return;
let need = qty;
if (typeof beforeAvail === 'number') {
const delta = afterAvail - beforeAvail;
if (delta >= qty) return; // backend already rolled back
if (delta > 0) need = Math.max(0, qty - delta);
}
if (need <= 0) return;
Taro.openLocation({
latitude: coords.lat,
longitude: coords.lng,
name: '收货地址',
address: fullAddress || ''
const currentAvailRaw = Number((after as any)?.availableQty);
const baseAvail = Number.isFinite(currentAvailRaw) ? currentAvailRaw : afterAvail;
const nextAvail = (Number.isFinite(baseAvail) ? baseAvail : 0) + need;
const frozenRaw = Number((after as any)?.frozenQty ?? 0);
const frozen = Number.isFinite(frozenRaw) ? frozenRaw : 0;
const reduceFrozen = Math.min(frozen, need);
const nextFrozen = reduceFrozen > 0 ? Math.max(0, frozen - reduceFrozen) : undefined;
await updateGltUserTicket({
...after,
availableQty: nextAvail,
...(nextFrozen !== undefined ? { frozenQty: nextFrozen } : {})
});
} catch (e) {
console.error('一键导航失败:', e);
Taro.showToast({ title: '导航失败,请重试', icon: 'none' });
}
};
const handleOneClickCall = async (order: GltTicketOrder) => {
const phone = (order.riderPhone || order.storePhone || '').trim();
if (!phone) {
Taro.showToast({ title: '暂无可呼叫的电话', icon: 'none' });
// Allow users to modify/cancel before delivery starts (e.g. 待派单 / 待配送).
const isTicketOrderPendingDelivery = (order: GltTicketOrder) => {
if (!order?.id) return false;
if (Number(order.status) === 1) return false;
if (Number((order as any)?.deleted) === 1) return false;
if (order.receiveConfirmTime || order.sendEndTime || order.sendStartTime) return false;
const ds = Number((order as any)?.deliveryStatus);
// If backend didn't set deliveryStatus yet, treat it as pending.
if (!Number.isFinite(ds)) return true;
// 0/10: before delivery starts
return ds === 0 || ds === 10;
};
const handleOrderModify = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可修改', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/user/ticket/use?orderId=${order.id}` });
};
const handleOrderCancel = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可取消', icon: 'none' });
return;
}
const modal = await Taro.showModal({
title: '取消订单',
content: '确定要取消该订单吗?取消后无法恢复。',
confirmText: '确认取消'
});
if (!modal.confirm) return;
try {
await Taro.makePhoneCall({ phoneNumber: phone });
Taro.showLoading({ title: '取消中...' });
let beforeTicket: GltUserTicket | null = null;
if (order.userTicketId) {
beforeTicket = await getGltUserTicket(Number(order.userTicketId)).catch(() => null);
}
try {
await updateGltTicketOrder({ id: order.id, deleted: 1 });
} catch (e) {
console.error('一键呼叫失败:', e);
Taro.showToast({ title: '呼叫失败,请手动拨打', icon: 'none' });
await removeGltTicketOrder(order.id);
}
try {
await rollbackUserTicketAfterOrderCancel(order, beforeTicket);
Taro.showToast({ title: '订单已取消,水票已退回', icon: 'success' });
} catch (e) {
console.error('取消订单后退回水票失败:', e);
await Taro.showModal({
title: '取消成功',
content: '订单已取消,但水票退回失败,请稍后刷新“我的水票”确认,或联系客服处理。',
showCancel: false
});
}
await reloadOrders(true);
} catch (e) {
console.error('取消送水订单失败:', e);
Taro.showToast({ title: '取消失败,请重试', icon: 'none' });
} finally {
Taro.hideLoading();
}
};
@@ -391,12 +481,23 @@ const UserTicketList = () => {
}
useDidShow(() => {
if (activeTab === 'ticket') {
reloadTickets(true).then();
} else {
reloadOrders(true).then();
const tabParam = Taro.getCurrentInstance().router?.params?.tab
const nextTab =
tabParam === 'ticket' || tabParam === 'order'
? tabParam
: undefined
if (nextTab && nextTab !== activeTab) {
setActiveTab(nextTab)
}
});
const tabToLoad = nextTab || activeTab
if (tabToLoad === 'ticket') {
reloadTickets(true).then()
} else {
reloadOrders(true).then()
}
})
return (
<ConfigProvider>
@@ -479,6 +580,9 @@ const UserTicketList = () => {
<Text className="text-base font-semibold text-gray-900">
{item.id}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
@@ -490,13 +594,25 @@ const UserTicketList = () => {
</View>
)}
</View>
<View className="flex flex-col items-end gap-2 hidden">
<View className="flex flex-col items-end gap-2">
<Button
size="small"
type="primary"
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
e.stopPropagation();
void goSendWater(item);
}}
>
</Button>
{/*<Tag type={item.status === 1 ? 'danger' : 'success'}>*/}
{/* {item.status === 1 ? '冻结' : '正常'}*/}
{/*</Tag>*/}
<Button
size="small"
type="primary"
style={{ display: 'none'}}
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
// Avoid triggering card click.
@@ -518,7 +634,14 @@ const UserTicketList = () => {
<Text className="text-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="flex flex-col items-center">
<View
className="flex flex-col items-center"
hoverClass="opacity-70"
onClick={(e) => {
e.stopPropagation();
void goReleasePlanDetail(item);
}}
>
<Text className="text-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
@@ -576,30 +699,29 @@ const UserTicketList = () => {
<View className="mt-1">
<Text className="text-xs text-gray-500">{formatDateTime(item.createTime)}</Text>
</View>
{(!!item.addressId || !!item.address || !!item.riderPhone || !!item.storePhone) ? (
{item.id ? (
<View className="mt-3 flex justify-end gap-2">
{(!!item.addressId || !!item.address) ? (
<Button
size="small"
disabled={!isTicketOrderPendingDelivery(item)}
onClick={(e) => {
e.stopPropagation();
void handleNavigateToAddress(item);
void handleOrderModify(item);
}}
>
</Button>
) : null}
{(!!item.riderPhone || !!item.storePhone) ? (
<Button
size="small"
type="danger"
disabled={!isTicketOrderPendingDelivery(item)}
onClick={(e) => {
e.stopPropagation();
void handleOneClickCall(item);
void handleOrderCancel(item);
}}
>
</Button>
) : null}
</View>
) : null}
{/*{item.storeName ? (*/}

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '释放计划',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { ConfigProvider, Empty, InfiniteLoading, Loading, PullToRefresh, Tag } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { pageGltUserTicketRelease } from '@/api/glt/gltUserTicketRelease'
import type { GltUserTicketRelease } from '@/api/glt/gltUserTicketRelease/model'
import { ensureLoggedIn } from '@/utils/auth'
const PAGE_SIZE = 10
const MAX_FETCH_ROUNDS = 10
export default function TicketReleasePlanPage() {
const [list, setList] = useState<GltUserTicketRelease[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState<number | undefined>(undefined)
const router = Taro.getCurrentInstance().router
const userTicketId = String(router?.params?.userTicketId || '').trim()
const templateName = (() => {
const raw = String(router?.params?.templateName || '')
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
const frozenQtyText = router?.params?.frozenQty !== undefined ? String(router?.params?.frozenQty) : undefined
const releasedQtyText = router?.params?.releasedQty !== undefined ? String(router?.params?.releasedQty) : undefined
const getUserId = () => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}
const getStatusMeta = (item: GltUserTicketRelease) => {
const status = Number(item.status)
if (status === 1) return { text: '已释放', type: 'success' as const }
if (status === 0) return { text: '待释放', type: 'warning' as const }
return { text: `状态${Number.isFinite(status) ? status : '-'}`, type: 'primary' as const }
}
const formatDateTime = (v?: string) => {
if (!v) return '-'
const d = dayjs(v)
return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v
}
const reload = async (isRefresh = true) => {
if (loading) return
const uid = getUserId()
if (!uid) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (!userTicketId) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const baseList = isRefresh ? [] : list
const seen = new Set(baseList.map(r => String(r.id ?? `${r.userTicketId ?? ''}:${r.periodNo ?? ''}:${r.releaseTime ?? ''}`)))
let nextPage = isRefresh ? 1 : page
let serverHasMore = true
let added = 0
let nextList = baseList.slice()
for (let round = 0; round < MAX_FETCH_ROUNDS; round++) {
if (!serverHasMore) break
// Only query by current logged-in userId; userTicketId is filtered on the client.
const res = await pageGltUserTicketRelease({
page: nextPage,
limit: PAGE_SIZE,
userId: uid
} as any)
const incoming = Array.isArray(res?.list) ? res.list : []
const safe = incoming
.filter(r => Number((r as any)?.deleted) !== 1)
.filter(r => !userTicketId || String(r.userTicketId || '') === userTicketId)
.filter(r => {
const k = String(r.id ?? `${r.userTicketId ?? ''}:${r.periodNo ?? ''}:${r.releaseTime ?? ''}`)
if (seen.has(k)) return false
seen.add(k)
return true
})
if (safe.length) {
nextList = nextList.concat(safe)
added += safe.length
}
serverHasMore = incoming.length >= PAGE_SIZE
if (!serverHasMore) break
nextPage += 1
// Stop early once we got something to render for this ticket.
if (added > 0) break
}
nextList.sort((a, b) => {
const at = dayjs(a.releaseTime || a.createTime || 0).valueOf()
const bt = dayjs(b.releaseTime || b.createTime || 0).valueOf()
return bt - at
})
setList(nextList)
setTotal(nextList.length)
setHasMore(serverHasMore)
setPage(nextPage)
} catch (e) {
console.error('加载释放计划失败:', e)
Taro.showToast({ title: '加载失败', icon: 'none' })
setHasMore(false)
} finally {
setLoading(false)
}
}
useDidShow(() => {
const redirect = userTicketId
? `/user/ticket/release/index?userTicketId=${encodeURIComponent(userTicketId)}`
: '/user/ticket/index'
if (!ensureLoggedIn(redirect)) return
void reload(true)
})
const handleRefresh = async () => {
await reload(true)
}
const loadMore = async () => {
if (!loading && hasMore) await reload(false)
}
return (
<ConfigProvider>
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh)', overflowY: 'auto' }} id="ticket-release-scroll">
<View className="px-4 py-3">
<View className="bg-white rounded-xl p-4 mb-3">
<View className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900"></Text>
{typeof total === 'number' ? (
<Text className="text-xs text-gray-400"> {total} </Text>
) : null}
</View>
<View className="mt-2 text-xs text-gray-500">
<Text>{userTicketId || '-'}</Text>
</View>
{templateName ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{templateName}</Text>
</View>
) : null}
{frozenQtyText !== undefined || releasedQtyText !== undefined ? (
<View className="mt-2 flex gap-4">
{frozenQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{frozenQtyText}</Text>
</View>
) : null}
{releasedQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{releasedQtyText}</Text>
</View>
) : null}
</View>
) : null}
</View>
{list.length === 0 && !loading && !hasMore ? (
<View className="flex flex-col justify-center items-center" style={{ height: 'calc(100vh - 220px)' }}>
<Empty description="暂无释放计划" style={{ backgroundColor: 'transparent' }} />
</View>
) : (
<InfiniteLoading
target="ticket-release-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
<View>
{list.map((item, index) => {
const meta = getStatusMeta(item)
return (
<View
key={String(item.id ?? `${item.userTicketId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-sm font-semibold text-gray-900">
{item.periodNo ?? '-'}
</Text>
<View className="mt-1 text-xs text-gray-500">
<Text>{item.releaseQty ?? 0}</Text>
</View>
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.releaseTime)}</Text>
</View>
</View>
<Tag type={meta.type}>{meta.text}</Tag>
</View>
</View>
)
})}
</View>
</InfiniteLoading>
)}
</View>
</View>
</PullToRefresh>
</ConfigProvider>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { View, Text, Picker } from '@tarojs/components'
import {
Button,
Cell,
@@ -13,7 +13,7 @@ import {
Space
} from '@nutui/nutui-react-taro'
import { ArrowRight, Location, Ticket } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import dayjs, { type Dayjs } from 'dayjs'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods'
import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
@@ -25,8 +25,9 @@ import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
import { getGltUserTicket, listGltUserTicket } from '@/api/glt/gltUserTicket'
import { getGltTicketTemplate, getGltTicketTemplateByGoodsId } from '@/api/glt/gltTicketTemplate'
import { addGltTicketOrder, getGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'
import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
@@ -35,16 +36,17 @@ import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
const MIN_START_QTY = 10
const DEFAULT_MIN_START_QTY = 10
const ADDRESS_CHANGE_COOLDOWN_DAYS = 30
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
const [minStartQty, setMinStartQty] = useState<number>(DEFAULT_MIN_START_QTY)
const [quantity, setQuantity] = useState<number>(DEFAULT_MIN_START_QTY)
const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection).
const [sendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [sendTime, setSendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('')
const [submitLoading, setSubmitLoading] = useState<boolean>(false)
@@ -89,11 +91,21 @@ const OrderConfirm = () => {
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const orderId = router?.params?.orderId;
const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId])
const numericOrderId = useMemo(() => {
const n = orderId ? Number(orderId) : undefined
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined
}, [orderId])
const isEditMode = !!numericOrderId
const [editingOrder, setEditingOrder] = useState<GltTicketOrder | null>(null)
const editingInitRef = useRef(false)
const userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
@@ -124,6 +136,12 @@ const OrderConfirm = () => {
return d.isValid() ? d : null
}
const clampSendDateToToday = (d: Dayjs) => {
const today = dayjs().startOf('day')
if (!d.isValid()) return today
return d.isBefore(today, 'day') ? today : d.startOf('day')
}
const getOrderTime = (o?: Partial<GltTicketOrder> | null) => {
return parseTime(o?.createTime) || parseTime(o?.updateTime)
}
@@ -300,17 +318,28 @@ const OrderConfirm = () => {
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
return Math.max(0, Math.min(stockMax, availableTicketTotal))
}, [availableTicketTotal, goods?.stock])
if (!isEditMode) return Math.max(0, Math.min(stockMax, availableTicketTotal))
const original = Number(editingOrder?.totalNum ?? 0)
const originalSafe = Number.isFinite(original) ? original : 0
const ticketId = Number(editingOrder?.userTicketId ?? 0)
const ticketIdSafe = Number.isFinite(ticketId) && ticketId > 0 ? ticketId : undefined
const rawTicket = ticketIdSafe ? (tickets || []).find(t => Number(t?.id) === ticketIdSafe) : undefined
if (!rawTicket) return Math.max(0, Math.min(stockMax, originalSafe))
const avail = getTicketAvailableQty(rawTicket)
const upper = Math.max(0, avail + originalSafe)
return Math.max(0, Math.min(stockMax, upper))
}, [availableTicketTotal, editingOrder?.totalNum, editingOrder?.userTicketId, goods?.stock, isEditMode, tickets])
const canStartOrder = useMemo(() => {
return maxQuantity >= MIN_START_QTY
}, [maxQuantity])
return maxQuantity >= minStartQty
}, [maxQuantity, minStartQty])
const displayQty = useMemo(() => {
if (!canStartOrder) return 0
return Math.max(MIN_START_QTY, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder])
return Math.max(minStartQty, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder, minStartQty])
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
@@ -579,7 +608,7 @@ const OrderConfirm = () => {
setQuantity(0)
return
}
setQuantity(Math.max(MIN_START_QTY, Math.min(newQuantity || MIN_START_QTY, upper)))
setQuantity(Math.max(minStartQty, Math.min(newQuantity || minStartQty, upper)))
}
const loadUserTickets = async () => {
@@ -623,13 +652,16 @@ const OrderConfirm = () => {
const onSubmit = async () => {
if (submitLoading) return
if (deliveryRangeCheckingRef.current) return
if (!goods?.goodsId) return
// 基础校验
if (!userId) {
Taro.showToast({ title: '请先登录', icon: 'none' })
return
}
if (isEditMode && !editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (!address?.id) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
@@ -672,7 +704,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return
}
if (availableTicketTotal <= 0) {
if (!isEditMode && availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
}
@@ -682,30 +714,41 @@ const OrderConfirm = () => {
Taro.showToast({ title: '请选择送水数量', icon: 'none' })
return
}
if (finalQty > availableTicketTotal) {
if (!isEditMode && finalQty > availableTicketTotal) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods.stock !== undefined && finalQty > goods.stock) {
if (isEditMode && finalQty > maxQuantity) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods?.stock !== undefined && finalQty > goods.stock) {
Taro.showToast({ title: '商品库存不足', icon: 'none' })
return
}
if (finalQty < MIN_START_QTY) {
Taro.showToast({ title: `最低起送 ${MIN_START_QTY}`, icon: 'none' })
if (finalQty < minStartQty) {
Taro.showToast({ title: `最低起送 ${minStartQty}`, icon: 'none' })
return
}
if (!sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
// 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange()
if (!ok) return
const confirmRes = await Taro.showModal({
title: '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
title: isEditMode ? '确认修改' : '确认下单',
content: isEditMode
? `配送时间:${sendTimeText}\n送水数量${finalQty}\n是否确认修改`
: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
})
if (!confirmRes.confirm) return
@@ -713,13 +756,21 @@ const OrderConfirm = () => {
setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' })
if (isEditMode) {
await updateGltTicketOrder({
id: editingOrder?.id,
addressId: address.id,
totalNum: finalQty,
buyerRemarks: orderRemark,
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
})
} else {
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
// Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
// Consume tickets with smaller available qty first.
let remain = finalQty
let created = 0
for (const t of ticketsToConsume) {
if (remain <= 0) break
const avail = getTicketAvailableQty(t)
@@ -737,27 +788,27 @@ const OrderConfirm = () => {
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
riderName: autoRider?.realName,
riderPhone: autoRider?.mobile,
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
comments: goods?.name ? `立即送水:${goods.name}` : '立即送水'
})
remain -= useQty
created += 1
}
if (remain > 0) {
// Ticket counts might have changed between loading and submission.
throw new Error('水票可用次数不足,请刷新后重试')
}
}
await loadUserTickets()
Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' })
Taro.showToast({ title: isEditMode ? '修改成功' : '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800)
} catch (e: any) {
console.error('水票下单失败:', e)
Taro.showToast({ title: e?.message || '下单失败', icon: 'none' })
console.error(isEditMode ? '送水订单修改失败:' : '水票下单失败:', e)
Taro.showToast({ title: e?.message || (isEditMode ? '修改失败' : '下单失败'), icon: 'none' })
} finally {
Taro.hideLoading()
setSubmitLoading(false)
@@ -772,11 +823,28 @@ const OrderConfirm = () => {
if (!opts?.silent) setLoading(true)
setError('')
const [goodsRes, addressRes] = await Promise.all([
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null),
listShopUserAddress({ isDefault: true })
const [addressRes, editingOrderRes, goodsByParam] = await Promise.all([
listShopUserAddress({ isDefault: true }),
numericOrderId ? getGltTicketOrder(numericOrderId) : Promise.resolve(null),
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null)
])
let goodsRes = goodsByParam
if (!goodsRes && editingOrderRes?.userTicketId) {
const ticketId = Number(editingOrderRes.userTicketId)
if (Number.isFinite(ticketId) && ticketId > 0) {
try {
const ticket = await getGltUserTicket(ticketId)
const gid = Number((ticket as any)?.goodsId)
if (Number.isFinite(gid) && gid > 0) {
goodsRes = await getShopGoods(gid)
}
} catch (e) {
console.error('加载订单关联商品失败:', e)
}
}
}
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
@@ -788,6 +856,49 @@ const OrderConfirm = () => {
setAddress(addressRes[0])
}
if (numericOrderId && editingOrderRes && !editingInitRef.current) {
editingInitRef.current = true
setEditingOrder(editingOrderRes)
Taro.setNavigationBarTitle({ title: '订单确认' })
const ds = editingOrderRes.deliveryStatus
const hasProgress = !!editingOrderRes.sendStartTime || !!editingOrderRes.sendEndTime || !!editingOrderRes.receiveConfirmTime
const isPending =
Number((editingOrderRes as any)?.deleted) !== 1 &&
Number(editingOrderRes.status) !== 1 &&
!hasProgress &&
(ds === 10 || (typeof ds !== 'number' && !!editingOrderRes.riderId))
if (!isPending) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
setTimeout(() => {
Taro.navigateBack()
}, 600)
return
}
const initQty = Number(editingOrderRes.totalNum ?? minStartQty)
setQuantity(Number.isFinite(initQty) && initQty > 0 ? initQty : minStartQty)
setOrderRemark(String(editingOrderRes.buyerRemarks || ''))
const st = parseTime(editingOrderRes.sendTime)
if (st) setSendTime(clampSendDateToToday(st).toDate())
const addrId = Number(editingOrderRes.addressId)
const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined
if (addrIdSafe) {
const hit = addressRes?.find(a => Number(a?.id) === addrIdSafe)
if (hit?.id) {
setAddress(hit)
} else {
try {
const addr = await getShopUserAddress(addrIdSafe)
if (addr?.id) setAddress(addr)
} catch (e) {
console.error('加载订单收货地址失败:', e)
}
}
}
}
// Load ticket-order history to enforce "address can be modified once per 30 days".
// If currently locked, force using last ticket-order address (snapshot) to avoid getting stuck with a new default address.
try {
@@ -902,11 +1013,54 @@ const OrderConfirm = () => {
useEffect(() => {
setQuantity(prev => {
if (maxQuantity <= 0) return 0
if (maxQuantity < MIN_START_QTY) return 0
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY
if (maxQuantity < minStartQty) return 0
if (!prev || prev < minStartQty) return minStartQty
return Math.min(prev, maxQuantity)
})
}, [maxQuantity])
}, [maxQuantity, minStartQty])
const minStartQtyKey = useMemo(() => {
const gid = Number(goods?.goodsId)
if (Number.isFinite(gid) && gid > 0) return `g:${gid}`
// If there is exactly one ticket template available, infer min start qty from it (covers "稍后再送" without goodsId).
const ids = Array.from(
new Set(
(usableTickets || [])
.map(t => Number(t?.templateId))
.filter(id => Number.isFinite(id) && id > 0)
)
)
if (ids.length === 1) return `t:${ids[0]}`
return ''
}, [goods?.goodsId, usableTickets])
// Use configured min start-send qty from ticket template (by goodsId or by user's unique templateId).
useEffect(() => {
let cancelled = false
;(async () => {
try {
if (!minStartQtyKey) {
setMinStartQty(DEFAULT_MIN_START_QTY)
return
}
const [kind, rawId] = minStartQtyKey.split(':')
const id = Number(rawId)
const tpl =
kind === 'g'
? await getGltTicketTemplateByGoodsId(id)
: await getGltTicketTemplate(id)
const n = Number(tpl?.startSendQty)
const safe = Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_START_QTY
if (!cancelled) setMinStartQty(safe)
} catch (_e) {
if (!cancelled) setMinStartQty(DEFAULT_MIN_START_QTY)
}
})()
return () => {
cancelled = true
}
}, [minStartQtyKey])
// If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
useEffect(() => {
@@ -946,7 +1100,7 @@ const OrderConfirm = () => {
}
// 加载状态
if (loading || !goods) {
if (loading) {
return <OrderConfirmSkeleton/>
}
@@ -1017,9 +1171,27 @@ const OrderConfirm = () => {
<Cell
title={'配送时间'}
extra={(
<Picker
mode="date"
start={dayjs().format('YYYY-MM-DD')}
value={dayjs(sendTime).format('YYYY-MM-DD')}
onChange={(e) => {
const v = (e as any)?.detail?.value
const d = dayjs(v)
if (!d.isValid()) return
if (d.isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
setSendTime(d.startOf('day').toDate())
}}
>
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14} />
</View>
</Picker>
)}
/>
</CellGroup>
@@ -1029,16 +1201,16 @@ const OrderConfirm = () => {
title={'送水数量'}
description={
canStartOrder
? `最低起送 ${MIN_START_QTY}`
: `最低起送 ${MIN_START_QTY} 桶(当前最多 ${maxQuantity} 桶)`
? `最低起送 ${minStartQty}`
: `最低起送 ${minStartQty} 桶(当前最多 ${maxQuantity} 桶)`
}
extra={(
<ConfigProvider theme={customTheme}>
<InputNumber
value={displayQty}
min={canStartOrder ? MIN_START_QTY : 0}
min={canStartOrder ? minStartQty : 0}
max={canStartOrder ? maxQuantity : 0}
step={10}
step={minStartQty >= 10 ? 10 : 1}
readOnly
disabled={!canStartOrder}
onChange={handleQuantityChange}

View File

@@ -19,7 +19,8 @@ export enum PaymentType {
* 支付结果回调
*/
export interface PaymentCallback {
onSuccess?: () => void;
// Return `false` to skip default "支付成功" toast + redirect.
onSuccess?: () => void | boolean | Promise<void | boolean>;
onError?: (error: string) => void;
onComplete?: () => void;
}
@@ -118,17 +119,27 @@ export class PaymentHandler {
if (paymentSuccess) {
console.log('支付成功,订单号:', result.orderNo);
// 先收起 loading避免遮挡 modal/toast
try {
Taro.hideLoading();
} catch (_e) {
// ignore
}
const onSuccessResult = await callback?.onSuccess?.();
const skipDefaultSuccessBehavior = onSuccessResult === false;
if (!skipDefaultSuccessBehavior) {
Taro.showToast({
title: '支付成功',
icon: 'success'
});
callback?.onSuccess?.();
// 跳转到订单页面
setTimeout(() => {
Taro.navigateTo({ url: '/user/order/order' });
}, 2000);
}
} else {
throw new Error('支付未完成');
}

View File

@@ -5,6 +5,7 @@ import {User} from "@/api/system/user/model";
export const TEMPLATE_ID = '10584';
// 服务接口 - 请根据实际情况修改
export const SERVER_API_URL = 'https://glt-server.websoft.top/api';
// export const SERVER_API_URL = 'https://server.websoft.top/api';
// export const SERVER_API_URL = 'http://127.0.0.1:8000/api';
/**
* 保存用户信息到本地存储

View File

@@ -0,0 +1,65 @@
import type { ShopOrder } from '@/api/shop/shopOrder/model';
const toNum = (value: unknown): number | undefined => {
if (value === null || value === undefined || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
};
export const isShopOrderCompleted = (order: Pick<ShopOrder, 'orderStatus'>): boolean =>
toNum(order?.orderStatus) === 1;
export const getShopOrderStatusText = (order: ShopOrder): string => {
const orderStatus = toNum(order?.orderStatus);
const deliveryStatus = toNum(order?.deliveryStatus);
const riderId = toNum(order?.riderId);
if (orderStatus === 2) return '已取消';
if (orderStatus === 3) return '取消中';
if (orderStatus === 4) return '退款申请中';
if (orderStatus === 5) return '退款被拒绝';
if (orderStatus === 6) return '退款成功';
if (orderStatus === 7) return '客户端申请退款';
if (orderStatus === 1) return '已完成';
if (!order?.payStatus) return '等待买家付款';
if (deliveryStatus === 10) return '待发货';
if (deliveryStatus === 20) {
if (!riderId || riderId === 0) return '待收货';
if (order?.sendEndTime) return '待确认收货';
return '配送中';
}
if (deliveryStatus === 30) return '部分发货';
if (orderStatus === 0) return '未使用';
return '未知状态';
};
export const getShopOrderStatusColor = (order: ShopOrder): string => {
const orderStatus = toNum(order?.orderStatus);
const deliveryStatus = toNum(order?.deliveryStatus);
const riderId = toNum(order?.riderId);
if (orderStatus === 2) return 'text-gray-500'; // 已取消
if (orderStatus === 3) return 'text-orange-500'; // 取消中
if (orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (orderStatus === 6) return 'text-green-500'; // 退款成功
if (orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (orderStatus === 1) return 'text-green-600'; // 已完成
if (!order?.payStatus) return 'text-orange-500'; // 等待买家付款
if (deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (deliveryStatus === 20) {
if (!riderId || riderId === 0) return 'text-purple-500'; // 待收货
if (order?.sendEndTime) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中
}
if (deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600';
};