Compare commits

..

22 Commits

Author SHA1 Message Date
81c63e0e65 feat(order): 添加订单自动取消过期功能
- 在OrderList组件中新增autoCancelExpired和paymentTimeoutHours属性
- 实现支付过期订单的自动检测和取消逻辑
- 添加parseTime和isOrderPaymentExpiredSafe辅助函数
- 使用useRef管理自动取消状态避免重复执行
- 支持单次最多处理20笔过期订单避免接口风暴
- 区分resetPage和loadMore场景下的列表状态同步
- 更新useEffect依赖数组包含新的属性参数
2026-03-08 13:08:35 +08:00
86f7506422 fix(order): 解决支付取消后加载状态未正确重置问题
- 在支付流程中添加 skipFinallyResetPayLoading 标志来控制加载状态
- 检测用户取消支付情况并跳转到待付款订单列表页面
- 优化支付取消后的页面导航逻辑,支持 redirectTo 和 navigateTo 两种方式
- 修改订单列表中的按钮文案"取消订单"为"取消"
- 修改订单列表中的按钮文案"立即支付"为"继续支付"
2026-03-08 10:41:00 +08:00
fae144549e style(goods): 商品列表卡片样式优化
- 为商品图片添加固定宽高和圆角样式
- 在政企采购专区卡片上添加点击跳转功能
- 在桂乐淘福利区卡片上添加点击跳转功能
- 为两个专区卡片添加箭头图标指示跳转
- 从 NutUI 图标库导入箭头图标组件
2026-03-07 16:13:52 +08:00
718eddff63 refactor(home): 移除未使用的导航功能
- 删除未使用的 navTo 导入语句
- 注释掉政企采购专区的点击跳转功能
- 注释掉桂乐淘福利惊爆区的点击跳转功能
- 保留卡片展示样式但移除交互逻辑
2026-03-07 15:41:49 +08:00
a4a0a922fc refactor(home): 移除未使用的导航功能
- 删除未使用的 navTo 导入语句
- 注释掉政企采购专区的点击跳转功能
- 注释掉桂乐淘福利惊爆区的点击跳转功能
- 保留卡片展示样式但移除交互逻辑
2026-03-07 15:41:00 +08:00
ca2436a2e8 feat(shop): 商品列表组件重构并优化页面展示
- 新增 GoodsList.scss 样式文件,实现网格布局和商品卡片样式
- 重构 GoodsList.tsx 组件,使用新的样式结构和 ShopGoods 类型
- 移除 Share 图标依赖,简化购买按钮设计
- 注释掉首页的桶装水和水票套餐分类入口
- 更新政企采购专区跳转链接至正确分类ID
- 在商品列表页面添加空状态显示组件
- 修改商品列表请求参数,增加状态过滤条件
2026-03-07 15:39:31 +08:00
83ba49d860 fix(home): 更新首页商品分类配置并优化跳转功能
- 修改开发环境API_BASE_URL配置
- 引入通用导航工具函数navTo
- 更新商品分类标签名称为桶装水和水票套餐
- 添加政企采购专区卡片组件
- 集成桂乐淘福利惊爆区跳转功能
- 修复页面布局结构中的多余空行问题
2026-03-07 15:20:16 +08:00
7375a3b1ce fix(order): 修复订单退款功能并调整开发环境配置
- 将开发环境API地址切换回本地服务
- 移除订单详情页面中的退款接口导入
- 将退款操作改为更新订单状态方式实现
- 注释掉用户页面底部版本号显示
2026-03-07 13:13:44 +08:00
756b548bf9 fix(dealer): 修复提现页面跳转和提示图标问题
- 将提现金额超限提示的图标从 'error' 改为 'none'
- 修复经销商首页可提现金额区域的点击跳转功能
- 移除重复的 onClick 事件绑定,统一在父容器上处理点击事件
2026-03-07 02:08:42 +08:00
76e76c62ef fix(withdraw): 修复经销商提现页面类型定义和渲染逻辑
- 将 activeTab 状态类型从 string | number 限定为 string
- 统一 Tab 切换处理中的值转换逻辑,避免类型不一致问题
- 修复条件判断中的字符串比较,确保类型安全
- 调整组件渲染方式,改为按需渲染当前选中的标签页内容
- 更新骑手页面工资统计标题为配送提成以匹配实际业务逻辑
2026-03-07 02:01:10 +08:00
546d90cc28 feat(app): 更新版权信息和分类标题
- 修改版权信息从公司名称变更为品牌宣传语
- 更新首页分类标签名称为政企采购专区和桂乐淘福利惊爆区
- 调整用户页脚版权显示格式,移除年份和Copyright标识
2026-03-07 01:44:12 +08:00
d4fd61376c fix(dealer): 更新页面标题和文案内容
- 将导航栏标题从"桂乐淘分享中心"改为"账户管理中心"
- 移除提现金额表单项的必填标识
- 修复提现注意事项中的错别字"再"改为"在"
- 统一更新用户组件中的页面标题显示
- 将用户网格组件中的"我的服务"改为"桂乐淘服务中心"
2026-03-07 01:38:27 +08:00
b27421fd6e fix(dealer): 更新佣金状态文案并修复商品价格显示
- 将佣金统计中的"冻结中"改为"待使用"
- 为可提现金额添加点击跳转到提现页面的功能
- 更新商品详情页价格字段从price改为buyingPrice
- 注释掉首页商品卡片中的买水票优惠按钮
- 在商品详情页价格后添加单位显示
2026-03-07 01:13:51 +08:00
b929b8d35e feat(user): 添加地址编辑时的地区锁定功能
- 新增 regionLocked 状态管理地区锁定状态
- 编辑模式下有经纬度时自动锁定地区,防止被识别覆盖
- 地图选点后锁定地区并验证省市区完整性
- 锁定状态下点击地区选择器显示提示信息
- 表单提交前验证必填的省市区字段
- 使用 View 组件替换 div 优化 Taro 兼容性
- 识别成功时根据锁定状态显示不同提示文案
2026-03-06 11:39:47 +08:00
23af704c68 fix(shop): 修复订单确认页面数量输入组件逻辑
- 调整步长设置逻辑,根据最小购买数量动态设置步长值
- 移除票据模板激活时的禁用条件限制
- 简化数量输入框的禁用状态判断逻辑
2026-03-01 12:22:23 +08:00
ab61aa9ee0 fix(user): 修复用户页面组件状态刷新和水票余额显示问题
- 在订单确认页面添加水票模板购买数量限制逻辑
- 为用户页面添加dealerViewKey状态确保子组件正确刷新
- 交换用户卡片中水票和余额的位置显示正确数据
- 移除自动跳转邀请注册页面逻辑改用显式跳转
- 添加预期失败场景的日志过滤避免不必要的错误输出
2026-02-28 20:28:18 +08:00
64d30e1b62 fix(user): 修复用户自动登录和输入组件配置问题
- 修改导航栏标题从"成为经销商"为"注册成为会员"
- 隐藏团队页面中的手机号显示功能
- 为订单确认页面的数量输入组件添加步长和只读属性
- 为用户票券页面的数量输入组件添加步长和只读属性
- 移除未使用的getStoredInviteParams导入
- 优化自动登录失败处理逻辑,避免不必要的页面跳转
- 添加错误消息过滤,避免预期失败情况下的控制台刷屏
2026-02-28 20:10:33 +08:00
a8eb9e11be feat(dealer): 添加订单解冻状态和订单状态显示功能
- 在订单模型中新增佣金解冻字段和订单状态字段
- 扩展订单状态判断逻辑支持解冻状态和订单取消状态
- 更新订单状态颜色映射适配新的状态类型
- 修改订单组件中的状态显示以支持新字段
- 优化订单状态文本和颜色渲染逻辑
2026-02-28 19:42:56 +08:00
338dc421db fix(ticket): 修复配送地址超出范围提示问题
- 仅在用户允许修改门票配送地址时显示提示
- 避免在冷却窗口期间显示冗余提示
- 添加对地址修改限制状态的检查
- 更新 useEffect 依赖数组以包含地址修改限制状态
2026-02-28 00:40:38 +08:00
6f1e0a6a2b feat(ticket): 添加送水地址修改限制功能
- 引入 ADDRESS_CHANGE_COOLDOWN_DAYS 常量设置30天修改间隔
- 新增 ticketAddressModifyLimit 状态管理地址修改权限
- 实现 loadTicketAddressModifyLimit 函数查询订单历史判断修改限制
- 添加 openAddressPage 函数控制地址页面跳转逻辑
- 在提交订单时验证地址修改限制并显示提示信息
- 初始化时加载地址修改限制并强制使用锁定地址
- 更新地址单元格点击事件为 openAddressPage 函数
- 添加地址修改限制状态显示到界面
2026-02-28 00:38:04 +08:00
8b5609255a fix(ticket): 解决配送范围检查中的地址状态问题
- 添加 deliveryRangeCheckedAddressId 状态防止使用旧地址的配送范围结果
- 在配送范围检查成功时更新已检查地址ID
- 在配送范围检查失败时清除已检查地址ID
- 在地址变更预检查中添加已检查地址验证
- 更新依赖数组包含 deliveryRangeCheckedAddressId
- 修正提交按钮禁用条件确保只在当前地址已检查且超出范围时禁用
- 完善按钮文字显示逻辑确保只在当前地址已检查且超出范围时显示配送范围提示
2026-02-27 15:57:48 +08:00
31d47f0a0b ```
fix(order): 修复订单状态判断逻辑和配送范围验证

- 修复订单状态数值转换逻辑,统一使用 toNum 函数处理状态值
- 移除基于 formId 推断订单完成状态的逻辑,改用 orderStatus 字段
- 更新订单列表中各状态的条件判断,确保标签页与状态文案同步
- 修改配送范围验证逻辑,移除GPS定位回退,仅使用地址坐标验证
- 添加地址坐标缺失的错误提示和表单验证
- 更新配送范围检查的UI状态管理和错误处理流程
- 优化按钮状态控制,增加地址坐标验证检查
```
2026-02-27 15:49:21 +08:00
30 changed files with 805 additions and 239 deletions

View File

@@ -9,6 +9,6 @@ export const BaseUrl = API_BASE_URL;
// 当前版本 // 当前版本
export const Version = 'v3.0.8'; export const Version = 'v3.0.8';
// 版权信息 // 版权信息
export const Copyright = 'WebSoft Inc.'; export const Copyright = '桂乐淘·购享无界 乐惠万家';
// java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./ // java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./

BIN
dist.zip

Binary file not shown.

View File

@@ -20,7 +20,7 @@
#### 新增功能 #### 新增功能
- 用户头像和基本信息展示 - 用户头像和基本信息展示
- 佣金统计(可提现、冻结中、累计收益) - 佣金统计(可提现、待使用、累计收益)
- 团队统计(一级、二级、三级成员) - 团队统计(一级、二级、三级成员)
- 功能导航网格(分销订单、提现申请、我的团队、推广二维码) - 功能导航网格(分销订单、提现申请、我的团队、推广二维码)

View File

@@ -35,7 +35,7 @@ dealer: {
// 金额相关 // 金额相关
money: { money: {
available: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', // 可提现 - 绿色 available: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', // 可提现 - 绿色
frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 冻结中 - 蓝色 frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 待使用 - 蓝色
total: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' // 累计 - 橙色 total: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' // 累计 - 橙色
} }
``` ```

View File

@@ -31,6 +31,10 @@ export interface ShopDealerOrder {
isInvalid?: number; isInvalid?: number;
// 佣金结算(0未结算 1已结算) // 佣金结算(0未结算 1已结算)
isSettled?: number; isSettled?: number;
// 佣金解冻(0未解冻 1已解冻)
isUnfreeze?: number;
// 订单状态
orderStatus?: number;
// 结算时间 // 结算时间
settleTime?: number; settleTime?: number;
// 商城ID // 商城ID

View File

@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '成为经销商', navigationBarTitleText: '注册成为会员',
navigationBarTextStyle: 'black' navigationBarTextStyle: 'black'
}) })

View File

@@ -1,3 +1,3 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '桂乐淘分享中心' navigationBarTitleText: '账户管理中心'
}) })

View File

@@ -134,7 +134,7 @@ const DealerIndex: React.FC = () => {
<View className="grid grid-cols-3 gap-3"> <View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.available background: businessGradients.money.available
}}> }} onClick={() => navigateToPage('/dealer/withdraw/index')}>
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.money)} {formatMoney(dealerUser.money)}
</Text> </Text>
@@ -146,7 +146,7 @@ const DealerIndex: React.FC = () => {
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.freezeMoney)} {formatMoney(dealerUser.freezeMoney)}
</Text> </Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text> <Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>使</Text>
</View> </View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total background: businessGradients.money.total

View File

@@ -94,15 +94,19 @@ const DealerOrders: React.FC = () => {
} }
}, [fetchOrders]) }, [fetchOrders])
const getStatusText = (isSettled?: number, isInvalid?: number) => { const getStatusText = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => {
if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return '已取消'
if (isInvalid === 1) return '已失效' if (isInvalid === 1) return '已失效'
if (isUnfreeze === 1) return '已解冻'
if (isSettled === 1) return '已结算' if (isSettled === 1) return '已结算'
return '待结算' return '待结算'
} }
const getStatusColor = (isSettled?: number, isInvalid?: number) => { const getStatusColor = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => {
if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return 'default'
if (isInvalid === 1) return 'danger' if (isInvalid === 1) return 'danger'
if (isSettled === 1) return 'success' if (isUnfreeze === 1) return 'success'
if (isSettled === 1) return 'info'
return 'warning' return 'warning'
} }
@@ -120,8 +124,8 @@ const DealerOrders: React.FC = () => {
<Text className="font-semibold text-gray-800"> <Text className="font-semibold text-gray-800">
{order.orderNo || '-'} {order.orderNo || '-'}
</Text> </Text>
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}> <Tag type={getStatusColor(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}>
{getStatusText(order.isSettled, order.isInvalid)} {getStatusText(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}
</Tag> </Tag>
</View> </View>

View File

@@ -1,5 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '桂乐淘分享中心', navigationBarTitleText: '账户管理中心',
// Enable "Share to friends" and "Share to Moments" (timeline) for this page. // Enable "Share to friends" and "Share to Moments" (timeline) for this page.
enableShareAppMessage: true, enableShareAppMessage: true,
enableShareTimeline: true enableShareTimeline: true

View File

@@ -325,7 +325,7 @@ const DealerTeam: React.FC = () => {
</View> </View>
{/* 显示手机号(仅本级可见) */} {/* 显示手机号(仅本级可见) */}
{showPhone && member.phone && ( {showPhone && member.phone && (
<Text className="text-sm text-gray-500" onClick={(e) => { <Text className="text-sm text-gray-500 hidden" onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
makePhoneCall(member.phone || ''); makePhoneCall(member.phone || '');
}}> }}>

View File

@@ -98,7 +98,7 @@ const normalizeMoneyString = (money: unknown) => {
} }
const DealerWithdraw: React.FC = () => { const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState<string | number>('0') const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false) const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false) const [submitting, setSubmitting] = useState<boolean>(false)
@@ -114,10 +114,11 @@ const DealerWithdraw: React.FC = () => {
// Tab 切换处理函数 // Tab 切换处理函数
const handleTabChange = (value: string | number) => { const handleTabChange = (value: string | number) => {
console.log('Tab切换到:', value) console.log('Tab切换到:', value)
setActiveTab(value) const next = String(value)
setActiveTab(next)
// 如果切换到提现记录页面,刷新数据 // 如果切换到提现记录页面,刷新数据
if (String(value) === '1') { if (next === '1') {
fetchWithdrawRecords() fetchWithdrawRecords()
} }
} }
@@ -310,7 +311,7 @@ const DealerWithdraw: React.FC = () => {
if (amount > available) { if (amount > available) {
Taro.showToast({ Taro.showToast({
title: '提现金额超过可用余额', title: '提现金额超过可用余额',
icon: 'error' icon: 'none'
}) })
return return
} }
@@ -487,7 +488,7 @@ const DealerWithdraw: React.FC = () => {
labelPosition="top" labelPosition="top"
> >
<CellGroup> <CellGroup>
<Form.Item name="amount" label="提现金额" required> <Form.Item name="amount" label="提现金额">
<Input <Input
placeholder="请输入提现金额" placeholder="请输入提现金额"
type="number" type="number"
@@ -522,7 +523,7 @@ const DealerWithdraw: React.FC = () => {
<Text className="text-sm text-red-500"> <Text className="text-sm text-red-500">
1. 1.
2. 2.
3. 3.
</Text> </Text>
</View> </View>
@@ -628,13 +629,12 @@ const DealerWithdraw: React.FC = () => {
<View className="bg-gray-50 min-h-screen"> <View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={handleTabChange}> <Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.TabPane title="申请提现" value="0"> <Tabs.TabPane title="申请提现" value="0">
{renderWithdrawForm()}
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane title="提现记录" value="1"> <Tabs.TabPane title="提现记录" value="1">
{renderWithdrawRecords()}
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
{activeTab === '0' ? renderWithdrawForm() : renderWithdrawRecords()}
</View> </View>
) )
} }

View File

@@ -3,7 +3,7 @@ import Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model'; import { User } from '@/api/system/user/model';
import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout'; import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout';
import { TenantId } from '@/config/app'; import { TenantId } from '@/config/app';
import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite'; import { handleInviteRelation } from '@/utils/invite';
// 用户Hook // 用户Hook
export const useUser = () => { export const useUser = () => {
@@ -44,15 +44,10 @@ export const useUser = () => {
reject(new Error('自动登录失败')); reject(new Error('自动登录失败'));
} }
}).catch(_ => { }).catch(_ => {
// 首次注册,跳转到邀请注册页面 // 登录失败(通常是新用户尚未注册/未绑定手机号等)。
const pages = Taro.getCurrentPages(); // 这里不做任何“自动跳转”,避免用户点击「我的」时被强制带到分销/申请页,体验割裂。
const currentPage = pages[pages.length - 1]; // 需要登录的页面请使用 utils/auth 的 ensureLoggedIn / goToRegister 做显式跳转。
const inviteParams = getStoredInviteParams() reject(new Error('autoLoginByOpenId failed'));
if (currentPage?.route !== 'dealer/apply/add' && inviteParams?.inviter) {
return Taro.navigateTo({
url: '/dealer/apply/add'
});
}
}); });
}, },
fail: reject fail: reject
@@ -60,7 +55,11 @@ export const useUser = () => {
}); });
return res; return res;
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error);
// 新用户首次进入、未绑定手机号等场景属于“预期失败”,避免刷屏报错。
if (msg !== 'autoLoginByOpenId failed') {
console.error('自动登录失败:', error); console.error('自动登录失败:', error);
}
return null; return null;
} }
}; };

View File

@@ -3,7 +3,7 @@ import Banner from './Banner'
import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro' import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components' import { View, Text, Image, ScrollView } from '@tarojs/components'
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Cart, Gift, Ticket, Agenda } from '@nutui/icons-react-taro' import { Cart, Gift, Ticket, Agenda, ArrowRight } from '@nutui/icons-react-taro'
import { getShopInfo } from '@/api/layout' import { getShopInfo } from '@/api/layout'
import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite' import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite'
import { pageShopGoods } from '@/api/shop/shopGoods' import { pageShopGoods } from '@/api/shop/shopGoods'
@@ -11,6 +11,7 @@ import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket'
import { ensureLoggedIn } from '@/utils/auth' import { ensureLoggedIn } from '@/utils/auth'
import './index.scss' import './index.scss'
// import navTo from "@/utils/common";
function Home() { function Home() {
const [activeTabKey, setActiveTabKey] = useState('recommend') const [activeTabKey, setActiveTabKey] = useState('recommend')
@@ -289,7 +290,21 @@ function Home() {
</View> </View>
</View> </View>
{/* 分类Tabs */} <View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4560` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title"></Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
<View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4556` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title">·</Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
{/*分类Tabs*/}
<ScrollView className="home-tabs" scrollX enableFlex> <ScrollView className="home-tabs" scrollX enableFlex>
<View className="home-tabs__inner"> <View className="home-tabs__inner">
{tabs.map((tab) => { {tabs.map((tab) => {
@@ -306,7 +321,6 @@ function Home() {
})} })}
</View> </View>
</ScrollView> </ScrollView>
{/* 商品列表 */} {/* 商品列表 */}
<View className="goods-grid"> <View className="goods-grid">
{visibleGoods.map((item) => ( {visibleGoods.map((item) => (
@@ -329,20 +343,20 @@ function Home() {
<Text className="goods-card__sold">:{item.sales || 0}</Text> <Text className="goods-card__sold">:{item.sales || 0}</Text>
<View className="goods-card__price"> <View className="goods-card__price">
<Text className="goods-card__priceUnit"></Text> <Text className="goods-card__priceUnit"></Text>
<Text className="goods-card__priceValue">{item.price}</Text> <Text className="goods-card__priceValue">{item.buyingPrice}</Text>
</View> </View>
</View> </View>
<View className="goods-card__actions"> <View className="goods-card__actions">
<View {/*<View*/}
className="goods-card__btn goods-card__btn--ghost" {/* className="goods-card__btn goods-card__btn--ghost"*/}
onClick={() => { {/* onClick={() => {*/}
if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return {/* if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return*/}
Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' }) {/* Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })*/}
}} {/* }}*/}
> {/*>*/}
<Text className="goods-card__btnText"></Text> {/* <Text className="goods-card__btnText">买水票更优惠</Text>*/}
</View> {/*</View>*/}
<View <View
className="goods-card__btn goods-card__btn--primary" className="goods-card__btn goods-card__btn--primary"
onClick={() => onClick={() =>
@@ -356,6 +370,7 @@ function Home() {
</View> </View>
))} ))}
</View> </View>
</View> </View>
</> </>
) )

View File

@@ -52,7 +52,7 @@ const IsDealer = () => {
<View style={{display: 'inline-flex', alignItems: 'center'}}> <View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/> <Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}} <Text style={{fontSize: '16px'}}
className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '桂乐淘分享中心'}</Text> className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '账户管理中心'}</Text>
{/*<Text className={'text-white opacity-80 pl-3'}>门店核销</Text>*/} {/*<Text className={'text-white opacity-80 pl-3'}>门店核销</Text>*/}
</View> </View>
} }
@@ -76,7 +76,7 @@ const IsDealer = () => {
title={ title={
<View style={{display: 'inline-flex', alignItems: 'center'}}> <View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/> <Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '桂乐淘分享中心'}</Text> <Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '账户管理中心'}</Text>
<Text className={'text-white opacity-80 pl-3'}>{config?.vipComments || ''}</Text> <Text className={'text-white opacity-80 pl-3'}>{config?.vipComments || ''}</Text>
</View> </View>
} }

View File

@@ -335,13 +335,9 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<View className={'py-2'}> <View className={'py-2'}>
<View className={'flex justify-around mt-1'}> <View className={'flex justify-around mt-1'}>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}> onClick={() => navTo('/user/ticket/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text> <Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.balance || '0.00'}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.points || 0}</Text>
</View> </View>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}> onClick={() => navTo('/user/coupon/index', true)}>
@@ -349,9 +345,13 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.coupons || 0}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.coupons || 0}</Text>
</View> </View>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/ticket/index', true)}> onClick={() => navTo('/user/wallet/wallet', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text> <Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.balance || '0.00'}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.points || 0}</Text>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -47,8 +47,9 @@ const UserFooter = () => {
return ( return (
<> <>
<div className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}> <div className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}>
<div className={'text-xs text-gray-400 py-1'}>{Version}</div> {/*<div className={'text-xs text-gray-400 py-1'}>当前版本:{Version}</div>*/}
<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div> {/*<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div>*/}
<div className={'text-xs text-gray-400 py-1'}>{Copyright}</div>
</div> </div>
<Popup <Popup

View File

@@ -39,7 +39,7 @@ const UserCell = () => {
return ( return (
<> <>
<View className="bg-white mx-4 mt-4 rounded-xl"> <View className="bg-white mx-4 mt-4 rounded-xl">
<View className="font-semibold text-gray-800 pt-4 pl-4"></View> <View className="font-semibold text-gray-800 pt-4 pl-4"></View>
<ConfigProvider> <ConfigProvider>
<Grid <Grid
columns={4} columns={4}

View File

@@ -1,4 +1,4 @@
import {useEffect, useRef} from 'react' import {useEffect, useRef, useState} from 'react'
import {PullToRefresh} from '@nutui/nutui-react-taro' import {PullToRefresh} from '@nutui/nutui-react-taro'
import UserCard from "./components/UserCard"; import UserCard from "./components/UserCard";
import UserOrder from "./components/UserOrder"; import UserOrder from "./components/UserOrder";
@@ -14,12 +14,15 @@ function User() {
const userCardRef = useRef<any>() const userCardRef = useRef<any>()
const themeStyles = useThemeStyles(); const themeStyles = useThemeStyles();
// TabBar 页在小程序里通常不会销毁;从“注册/申请”页返回时需要触发子组件重新初始化/拉取最新状态。
const [dealerViewKey, setDealerViewKey] = useState(0)
// 下拉刷新处理 // 下拉刷新处理
const handleRefresh = async () => { const handleRefresh = async () => {
if (userCardRef.current?.handleRefresh) { if (userCardRef.current?.handleRefresh) {
await userCardRef.current.handleRefresh() await userCardRef.current.handleRefresh()
} }
setDealerViewKey(v => v + 1)
} }
useEffect(() => { useEffect(() => {
@@ -30,6 +33,7 @@ function User() {
userCardRef.current?.reloadStats?.() userCardRef.current?.reloadStats?.()
// 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新 // 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新
userCardRef.current?.reloadUserInfo?.() userCardRef.current?.reloadUserInfo?.()
setDealerViewKey(v => v + 1)
}) })
return ( return (
@@ -58,7 +62,7 @@ function User() {
</View> </View>
<UserCard ref={userCardRef}/> <UserCard ref={userCardRef}/>
<UserOrder/> <UserOrder/>
<IsDealer/> <IsDealer key={dealerViewKey}/>
<UserGrid/> <UserGrid/>
<UserFooter/> <UserFooter/>
</PullToRefresh> </PullToRefresh>

View File

@@ -130,7 +130,7 @@ const DealerIndex: React.FC = () => {
{dealerUser && ( {dealerUser && (
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}> <View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}>
<View className="mb-4"> <View className="mb-4">
<Text className="font-semibold text-gray-800"></Text> <Text className="font-semibold text-gray-800"></Text>
</View> </View>
<View className="grid grid-cols-3 gap-3"> <View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{

View File

@@ -0,0 +1,122 @@
.goods-grid {
margin-top: 18rpx;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx;
}
.goods-card {
border-radius: 22rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.06);
}
.goods-card__imgWrap {
padding: 18rpx 18rpx 0;
}
.goods-card__img {
width: 100%;
height: 280rpx;
border-radius: 18rpx;
background: #f4f4f4;
}
.goods-card__body {
padding: 18rpx 18rpx 20rpx;
}
.goods-card__title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 26rpx;
font-weight: 700;
color: #1c1c1c;
min-height: 72rpx;
}
.goods-card__meta {
margin-top: 10rpx;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 10rpx;
}
.goods-card__sold {
font-size: 22rpx;
color: #9a9a9a;
white-space: nowrap;
}
.goods-card__price {
display: flex;
align-items: baseline;
gap: 4rpx;
color: #27c86b;
white-space: nowrap;
}
.goods-card__priceUnit {
font-size: 22rpx;
font-weight: 800;
}
.goods-card__priceValue {
font-size: 36rpx;
font-weight: 900;
}
.goods-card__actions {
margin-top: 16rpx;
display: flex;
gap: 14rpx;
}
.goods-card__btn {
flex: 1;
height: 64rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
.goods-card__btn--ghost {
border: 2rpx solid rgba(32, 194, 106, 0.7);
background: #ffffff;
}
.goods-card__btn--primary {
background: linear-gradient(90deg, #24d34c 0%, #6df09a 100%);
}
.goods-card__btnText {
font-size: 24rpx;
font-weight: 700;
color: #18b85a;
white-space: nowrap;
}
.goods-card__btnText--primary {
color: #ffffff;
}
.buy-btn{
height: 70px;
background: linear-gradient(to bottom, #1cd98a, #24ca94);
border-radius: 100px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: space-around;
.cart-icon{
background: linear-gradient(to bottom, #bbe094, #4ee265);
border-radius: 100px 0 0 100px;
height: 70px;
}
}

View File

@@ -1,51 +1,57 @@
import {Image} from '@nutui/nutui-react-taro' import {Image} from '@nutui/nutui-react-taro'
import {Share} from '@nutui/icons-react-taro'
import {View, Text} from '@tarojs/components' import {View, Text} from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import './GoodsList.scss' import './GoodsList.scss'
import {ShopGoods} from "@/api/shop/shopGoods/model";
const GoodsList = (props: any) => { const GoodsList = (props: any) => {
return ( return (
<> <>
<View className={'py-3'}> <View className={'p-3'}>
<View className={'flex flex-col justify-between items-center rounded-lg px-2'}>
{props.data?.map((item: any, index: number) => { <View className="goods-grid">
return ( {props.data?.map((item: ShopGoods) => (
<View key={index} className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}> <View key={item.goodsId} className="goods-card">
<Image src={item.image} mode={'aspectFit'} lazyLoad={false} <View className="goods-card__imgWrap">
radius="10px 10px 0 0" height="180" <Image
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/> className="goods-card__img"
<View className={'flex flex-col p-2 rounded-lg'}> src={item.image || ''}
<View> mode="aspectFill"
<View className={'car-no text-sm'}>{item.name}</View> width="100%"
<View className={'flex justify-between text-xs py-1'}> height="280rpx"
<Text className={'text-orange-500'}>{item.comments}</Text> radius="18rpx"
<Text className={'text-gray-400'}> {item.sales}</Text> lazyLoad={false}
onClick={() =>
Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` })
}
/>
</View> </View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}> <View className="goods-card__body">
<Text className={'text-xs'}></Text> <Text className="goods-card__title">{item.name}</Text>
<Text className={'font-bold text-2xl'}>{item.price}</Text> <View className="goods-card__meta">
<Text className={'text-xs px-1'}></Text> <Text className="goods-card__sold">:{item.sales || 0}</Text>
<Text className={'text-xs text-gray-400 line-through'}>{item.salePrice}</Text> <View className="goods-card__price">
<Text className="goods-card__priceUnit"></Text>
<Text className="goods-card__priceValue">{item.buyingPrice}</Text>
</View> </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})}/>
</View> </View>
<View className={'text-white pl-4 pr-5'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}> <View className="goods-card__actions">
<View
className="goods-card__btn goods-card__btn--primary"
onClick={() =>
Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` })
}
>
<Text className="goods-card__btnText goods-card__btnText--primary"></Text>
</View> </View>
</View> </View>
</View> </View>
</View> </View>
</View> ))}
</View>
)
})}
</View> </View>
</View> </View>
</> </>

View File

@@ -1,7 +1,7 @@
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import GoodsList from './components/GoodsList' import GoodsList from './components/GoodsList'
import {useShareAppMessage} from "@tarojs/taro" import {useShareAppMessage} from "@tarojs/taro"
import {Loading} from '@nutui/nutui-react-taro' import {Loading,Empty} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react" import {useEffect, useState} from "react"
import {useRouter} from '@tarojs/taro' import {useRouter} from '@tarojs/taro'
import './index.scss' import './index.scss'
@@ -21,7 +21,7 @@ function Category() {
// 1.加载远程数据 // 1.加载远程数据
const id = Number(params.id) const id = Number(params.id)
const nav = await getCmsNavigation(id) const nav = await getCmsNavigation(id)
const shopGoods = await pageShopGoods({categoryId: id}) const shopGoods = await pageShopGoods({categoryId: id, status: 0})
// 2.处理业务逻辑 // 2.处理业务逻辑
setCategoryId(id) setCategoryId(id)
@@ -59,6 +59,12 @@ function Category() {
) )
} }
if(list.length == 0){
return (
<Empty description="暂无数据"/>
)
}
return ( return (
<> <>
<div className={'flex flex-col'}> <div className={'flex flex-col'}>

View File

@@ -366,9 +366,9 @@ const GoodsDetail = () => {
<View className={'flex justify-between'}> <View className={'flex justify-between'}>
<View className={'flex text-red-500 text-xl items-baseline'}> <View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text> <Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{goods.price}</Text> <Text className={'font-bold text-2xl'}>{goods.buyingPrice}</Text>
<Text className={'text-xs px-1'}></Text> <Text className={'text-xs px-1'}></Text>
<Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}</Text> <Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}/{goods.unitName}</Text>
</View> </View>
<span className={"text-gray-400 text-xs"}> {goods.sales}</span> <span className={"text-gray-400 text-xs"}> {goods.sales}</span>
</View> </View>

View File

@@ -430,6 +430,7 @@ const OrderConfirm = () => {
* 统一支付入口 * 统一支付入口
*/ */
const onPay = async (goods: ShopGoods) => { const onPay = async (goods: ShopGoods) => {
let skipFinallyResetPayLoading = false
try { try {
setPayLoading(true) setPayLoading(true)
@@ -603,6 +604,29 @@ const OrderConfirm = () => {
// }) // })
} catch (error: any) { } catch (error: any) {
const message = String(error?.message || '') const message = String(error?.message || '')
const isUserCancelPay =
message.includes('用户取消支付') ||
message.includes('取消支付') ||
message.toLowerCase().includes('requestpayment:fail cancel') ||
message.toLowerCase().includes('cancel')
// 用户取消支付:跳转到待付款列表,方便继续支付
if (isUserCancelPay) {
skipFinallyResetPayLoading = true
setPayLoading(false)
const url = '/user/order/order?statusFilter=0'
try {
await Taro.redirectTo({ url })
} catch (_e) {
try {
await Taro.navigateTo({ url })
} catch (_e2) {
// ignore
}
}
return
}
const isOutOfDeliveryRange = const isOutOfDeliveryRange =
message.includes('不在配送范围') || message.includes('不在配送范围') ||
message.includes('配送范围') || message.includes('配送范围') ||
@@ -632,8 +656,10 @@ const OrderConfirm = () => {
Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' }) Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' })
} }
} finally { } finally {
if (!skipFinallyResetPayLoading) {
setPayLoading(false) setPayLoading(false)
} }
}
}; };
// 统一的数据加载函数 // 统一的数据加载函数
@@ -858,6 +884,8 @@ const OrderConfirm = () => {
value={quantity} value={quantity}
min={isTicketTemplateActive ? minBuyQty : 1} min={isTicketTemplateActive ? minBuyQty : 1}
max={goods.stock || 999} max={goods.stock || 999}
step={minBuyQty === 1 ? 1 : 10}
readOnly
disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive} disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive}
onChange={handleQuantityChange} onChange={handleQuantityChange}
/> />

View File

@@ -3,7 +3,7 @@ import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import {View} from '@tarojs/components' import {View} from '@tarojs/components'
import {ShopOrder} from "@/api/shop/shopOrder/model"; import {ShopOrder} from "@/api/shop/shopOrder/model";
import {getShopOrder, updateShopOrder, refundShopOrder} from "@/api/shop/shopOrder"; import {getShopOrder, updateShopOrder} from "@/api/shop/shopOrder";
import {listShopOrderGoods} from "@/api/shop/shopOrderGoods"; import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model"; import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -69,7 +69,7 @@ const OrderDetail = () => {
Taro.showLoading({ title: '提交中...' }) Taro.showLoading({ title: '提交中...' })
// 退款相关操作使用退款接口PUT /api/shop/shop-order/refund // 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
await refundShopOrder({ await updateShopOrder({
orderId: order.orderId, orderId: order.orderId,
refundMoney: order.payPrice || order.totalPrice, refundMoney: order.payPrice || order.totalPrice,
orderStatus: 7 orderStatus: 7

View File

@@ -47,6 +47,7 @@ const AddUserAddress = () => {
const [FormData, setFormData] = useState<ShopUserAddress>({}) const [FormData, setFormData] = useState<ShopUserAddress>({})
const [inputText, setInputText] = useState<string>('') const [inputText, setInputText] = useState<string>('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null) const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const formRef = useRef<any>(null) const formRef = useRef<any>(null)
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null) const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false) const wxDraftPatchedRef = useRef(false)
@@ -120,7 +121,12 @@ const AddUserAddress = () => {
// 设置所在地区 // 设置所在地区
setText(`${address.province} ${address.city} ${address.region}`) setText(`${address.province} ${address.city} ${address.region}`)
// 回显已保存的经纬度(编辑模式) // 回显已保存的经纬度(编辑模式)
if (hasValidLngLat(address)) setSelectedLocation({ lng: String(address.lng), lat: String(address.lat) }) if (hasValidLngLat(address)) {
setSelectedLocation({ lng: String(address.lng), lat: String(address.lat) })
setRegionLocked(true)
} else {
setRegionLocked(false)
}
} catch (error) { } catch (error) {
console.error('加载地址失败:', error) console.error('加载地址失败:', error)
Taro.showToast({ Taro.showToast({
@@ -172,30 +178,39 @@ const AddUserAddress = () => {
const result = parseAddressText(inputText); const result = parseAddressText(inputText);
// 更新表单数据 // 更新表单数据
const newFormData = { const newFormData: any = {
...FormData, ...FormData,
name: result.name || FormData.name, name: result.name || FormData.name,
phone: result.phone || FormData.phone, phone: result.phone || FormData.phone,
address: result.address || FormData.address, address: result.address || FormData.address
province: result.province || FormData.province,
city: result.city || FormData.city,
region: result.region || FormData.region
}; };
if (!regionLocked) {
newFormData.province = result.province || FormData.province
newFormData.city = result.city || FormData.city
newFormData.region = result.region || FormData.region
}
setFormData(newFormData); setFormData(newFormData);
// 更新地区显示文本 // 更新地区显示文本
if (result.province && result.city && result.region) { if (!regionLocked && result.province && result.city && result.region) {
setText(`${result.province} ${result.city} ${result.region}`); setText(`${result.province} ${result.city} ${result.region}`);
} }
// 更新表单字段值 // 更新表单字段值
if (formRef.current) { if (formRef.current) {
formRef.current.setFieldsValue(newFormData); const patch: any = {
name: newFormData.name,
phone: newFormData.phone,
address: newFormData.address
}
if (!regionLocked && newFormData.region) patch.region = newFormData.region
formRef.current.setFieldsValue(patch);
} }
Taro.showToast({ Taro.showToast({
title: '识别成功', title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功',
icon: 'success' icon: 'success'
}); });
@@ -311,7 +326,6 @@ const AddUserAddress = () => {
name: res.name, name: res.name,
address: res.address address: res.address
} }
setSelectedLocation(next)
// 尝试从地图返回的 address 文本解析省市区best-effort // 尝试从地图返回的 address 文本解析省市区best-effort
const regionResult = res?.provinceName || res?.cityName || res?.adName const regionResult = res?.provinceName || res?.cityName || res?.adName
@@ -322,15 +336,22 @@ const AddUserAddress = () => {
} }
: parseRegion(String(res.address || '')) : parseRegion(String(res.address || ''))
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
if (!province || !city || !region) {
Taro.showToast({ title: '定位未识别到所在地区,请重新选择定位', icon: 'none' })
return
}
setSelectedLocation(next)
setRegionLocked(true)
// 将地图选点的地址同步到“收货地址”(不额外拼接省市区字段,省市区由独立字段保存) // 将地图选点的地址同步到“收货地址”(不额外拼接省市区字段,省市区由独立字段保存)
const nextDetailAddress = (() => { const nextDetailAddress = (() => {
const rawAddr = String(res.address || '').trim() const rawAddr = String(res.address || '').trim()
const name = String(res.name || '').trim() const name = String(res.name || '').trim()
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
// 选择定位返回的 address 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复 // 选择定位返回的 address 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复
let detail = rawAddr let detail = rawAddr
for (const part of [province, city, region]) { for (const part of [province, city, region]) {
@@ -350,20 +371,18 @@ const AddUserAddress = () => {
lng: next.lng, lng: next.lng,
lat: next.lat, lat: next.lat,
address: nextDetailAddress || prev.address, address: nextDetailAddress || prev.address,
province: regionResult?.province || prev.province, province,
city: regionResult?.city || prev.city, city,
region: regionResult?.region || prev.region region
})) }))
if (regionResult?.province && regionResult?.city && regionResult?.region) { setText(`${province} ${city} ${region}`)
setText(`${regionResult.province} ${regionResult.city} ${regionResult.region}`)
}
// 更新表单展示值Form initialValues 不会跟随 FormData 变化) // 更新表单展示值Form initialValues 不会跟随 FormData 变化)
if (formRef.current) { if (formRef.current) {
const patch: any = {} const patch: any = {}
if (nextDetailAddress) patch.address = nextDetailAddress if (nextDetailAddress) patch.address = nextDetailAddress
if (regionResult?.region) patch.region = regionResult.region patch.region = region
formRef.current.setFieldsValue(patch) formRef.current.setFieldsValue(patch)
} }
} }
@@ -407,6 +426,14 @@ const AddUserAddress = () => {
} }
} }
const openRegionPicker = () => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setVisible(true)
}
// 提交表单 // 提交表单
const submitSucceed = async (values: any) => { const submitSucceed = async (values: any) => {
const loc = const loc =
@@ -416,6 +443,10 @@ const AddUserAddress = () => {
Taro.showToast({ title: '请选择定位', icon: 'none' }) Taro.showToast({ title: '请选择定位', icon: 'none' })
return return
} }
if (!FormData.province || !FormData.city || !FormData.region) {
Taro.showToast({ title: '请先选择定位以自动填写所在地区', icon: 'none' })
return
}
try { try {
// 准备提交的数据 // 准备提交的数据
@@ -487,6 +518,12 @@ const AddUserAddress = () => {
}) })
}, [fromWx, isEditMode]); }, [fromWx, isEditMode]);
useEffect(() => {
if (!regionLocked) return
if (!visible) return
setVisible(false)
}, [regionLocked, visible])
// NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。 // NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
@@ -523,7 +560,7 @@ const AddUserAddress = () => {
onFinishFailed={(errors) => submitFailed(errors)} onFinishFailed={(errors) => submitFailed(errors)}
> >
<CellGroup className={'px-3'}> <CellGroup className={'px-3'}>
<div <View
style={{ style={{
border: '1px dashed #22c55e', border: '1px dashed #22c55e',
display: 'flex', display: 'flex',
@@ -549,7 +586,7 @@ const AddUserAddress = () => {
> >
</Button> </Button>
</div> </View>
</CellGroup> </CellGroup>
<View className={'bg-gray-100 h-3'}></View> <View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}> <CellGroup style={{padding: '4px 0'}}>
@@ -581,10 +618,10 @@ const AddUserAddress = () => {
rules={[{message: '请输入您的所在地区'}]} rules={[{message: '请输入您的所在地区'}]}
required required
> >
<div className={'flex justify-between items-center'} onClick={() => setVisible(true)}> <View className={'flex justify-between items-center'} onClick={openRegionPicker}>
<Input placeholder="选择所在地区" value={text} disabled/> <Input placeholder="选择所在地区" value={text} disabled/>
<ArrowRight className={'text-gray-400'}/> <ArrowRight className={'text-gray-400'}/>
</div> </View>
</Form.Item> </Form.Item>
<Form.Item name="address" label="收货地址" initialValue={FormData.address} required> <Form.Item name="address" label="收货地址" initialValue={FormData.address} required>
<TextArea maxLength={50} placeholder="请输入详细收货地址"/> <TextArea maxLength={50} placeholder="请输入详细收货地址"/>
@@ -598,15 +635,15 @@ const AddUserAddress = () => {
(selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '用于计算是否超出配送范围') (selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '用于计算是否超出配送范围')
} }
extra={( extra={(
<div className={'flex items-center gap-2'}> <View className={'flex items-center gap-2'}>
<div <View
className={'text-gray-900 text-sm'} className={'text-gray-900 text-sm'}
style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}} style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}
> >
{selectedLocation?.name || (selectedLocation ? '已选择' : '请选择')} {selectedLocation?.name || (selectedLocation ? '已选择' : '请选择')}
</div> </View>
<ArrowRight className={'text-gray-400'}/> <ArrowRight className={'text-gray-400'}/>
</div> </View>
)} )}
onClick={chooseGeoLocation} onClick={chooseGeoLocation}
/> />
@@ -618,6 +655,10 @@ const AddUserAddress = () => {
options={optionsDemo1} options={optionsDemo1}
title="选择地址" title="选择地址"
onChange={(value, _) => { onChange={(value, _) => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setFormData({ setFormData({
...FormData, ...FormData,
province: `${value[0]}`, province: `${value[0]}`,

View File

@@ -104,6 +104,10 @@ interface OrderListProps {
baseParams?: ShopOrderParam; baseParams?: ShopOrderParam;
// 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮 // 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮
readOnly?: boolean; readOnly?: boolean;
// 是否自动取消“支付已过期”的待支付订单(仅 user 模式生效)
autoCancelExpired?: boolean;
// 支付超时时间(小时),默认 24 小时
paymentTimeoutHours?: number;
} }
function OrderList(props: OrderListProps) { function OrderList(props: OrderListProps) {
@@ -111,6 +115,8 @@ function OrderList(props: OrderListProps) {
const pageRef = useRef(1) const pageRef = useRef(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [payingOrderId, setPayingOrderId] = useState<number | null>(null) const [payingOrderId, setPayingOrderId] = useState<number | null>(null)
const autoCanceledOrderIdsRef = useRef<Set<number>>(new Set())
const autoCancelRunningRef = useRef(false)
// 根据传入的statusFilter设置初始tab索引 // 根据传入的statusFilter设置初始tab索引
const getInitialTabIndex = () => { const getInitialTabIndex = () => {
if (props.searchParams?.statusFilter !== undefined) { if (props.searchParams?.statusFilter !== undefined) {
@@ -132,61 +138,92 @@ function OrderList(props: OrderListProps) {
const [orderToConfirmReceive, setOrderToConfirmReceive] = useState<ShopOrder | null>(null) const [orderToConfirmReceive, setOrderToConfirmReceive] = useState<ShopOrder | null>(null)
const isReadOnly = props.readOnly || props.mode === 'store' || props.mode === 'rider' const isReadOnly = props.readOnly || props.mode === 'store' || props.mode === 'rider'
const isOrderCompleted = (order: ShopOrder) => Number(order.orderStatus) === 1 || order.formId === 10074; const toNum = (v: any): number | undefined => {
if (v === null || v === undefined || v === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
const parseTime = (raw: any): dayjs.Dayjs | null => {
const text = String(raw ?? '').trim();
if (!text) return null;
const t = /^\d+$/.test(text)
? dayjs(Number(text) < 1e12 ? Number(text) * 1000 : Number(text))
: dayjs(text);
return t.isValid() ? t : null;
};
const isOrderPaymentExpiredSafe = (order: ShopOrder, timeoutHours: number) => {
if (order.payStatus) return false;
if (toNum(order.orderStatus) === 2) return false;
const expiration = parseTime(order.expirationTime);
if (expiration) return dayjs().isAfter(expiration);
if (order.createTime) return isPaymentExpired(order.createTime, timeoutHours);
return false;
};
// “已完成”应以订单状态为准不要用商品ID等字段推断完成态否则会造成 Tab(待发货/待收货) 与状态文案不同步
const isOrderCompleted = (order: ShopOrder) => toNum(order.orderStatus) === 1;
// 获取订单状态文本 // 获取订单状态文本
const getOrderStatusText = (order: ShopOrder) => { const getOrderStatusText = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
// 优先检查订单状态 // 优先检查订单状态
if (order.orderStatus === 2) return '已取消'; if (orderStatus === 2) return '已取消';
if (order.orderStatus === 4) return '退款申请中'; if (orderStatus === 4) return '退款申请中';
if (order.orderStatus === 5) return '退款被拒绝'; if (orderStatus === 5) return '退款被拒绝';
if (order.orderStatus === 6) return '退款成功'; if (orderStatus === 6) return '退款成功';
if (order.orderStatus === 7) return '客户端申请退款'; if (orderStatus === 7) return '客户端申请退款';
if (isOrderCompleted(order)) return '已完成'; if (isOrderCompleted(order)) return '已完成';
// 检查支付状态 (payStatus为boolean类型false/0表示未付款true/1表示已付款) // 检查支付状态 (payStatus为boolean类型false/0表示未付款true/1表示已付款)
if (!order.payStatus) return '等待买家付款'; if (!order.payStatus) return '等待买家付款';
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货'; if (deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) { if (deliveryStatus === 20) {
// 若订单没有配送员,沿用原“待收货”语义 // 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId || Number(order.riderId) === 0) return '待收货'; if (!order.riderId || Number(order.riderId) === 0) return '待收货';
// 配送员确认送达后sendEndTime有值才进入“待确认收货” // 配送员确认送达后sendEndTime有值才进入“待确认收货”
if (order.sendEndTime && !isOrderCompleted(order)) return '待确认收货'; if (order.sendEndTime && !isOrderCompleted(order)) return '待确认收货';
return '配送中'; return '配送中';
} }
if (order.deliveryStatus === 30) return '部分发货'; if (deliveryStatus === 30) return '部分发货';
if (order.orderStatus === 0) return '未使用'; if (orderStatus === 0) return '未使用';
return '未知状态'; return '未知状态';
}; };
// 获取订单状态颜色 // 获取订单状态颜色
const getOrderStatusColor = (order: ShopOrder) => { const getOrderStatusColor = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
// 优先检查订单状态 // 优先检查订单状态
if (order.orderStatus === 2) return 'text-gray-500'; // 已取消 if (orderStatus === 2) return 'text-gray-500'; // 已取消
if (order.orderStatus === 4) return 'text-orange-500'; // 退款申请中 if (orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (order.orderStatus === 5) return 'text-red-500'; // 退款被拒绝 if (orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (order.orderStatus === 6) return 'text-green-500'; // 退款成功 if (orderStatus === 6) return 'text-green-500'; // 退款成功
if (order.orderStatus === 7) return 'text-orange-500'; // 客户端申请退款 if (orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (isOrderCompleted(order)) return 'text-green-600'; // 已完成 if (isOrderCompleted(order)) return 'text-green-600'; // 已完成
// 检查支付状态 // 检查支付状态
if (!order.payStatus) return 'text-orange-500'; // 等待买家付款 if (!order.payStatus) return 'text-orange-500'; // 等待买家付款
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return 'text-blue-500'; // 待发货 if (deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (order.deliveryStatus === 20) { if (deliveryStatus === 20) {
if (!order.riderId || Number(order.riderId) === 0) return 'text-purple-500'; // 待收货 if (!order.riderId || Number(order.riderId) === 0) return 'text-purple-500'; // 待收货
if (order.sendEndTime && !isOrderCompleted(order)) return 'text-purple-500'; // 待确认收货 if (order.sendEndTime && !isOrderCompleted(order)) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中 return 'text-blue-500'; // 配送中
} }
if (order.deliveryStatus === 30) return 'text-blue-500'; // 部分发货 if (deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (order.orderStatus === 0) return 'text-gray-500'; // 未使用 if (orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600'; // 默认颜色 return 'text-gray-600'; // 默认颜色
}; };
@@ -238,23 +275,81 @@ function OrderList(props: OrderListProps) {
}); });
try { try {
const res = await pageShopOrder(searchConditions); const timeoutHours = typeof props.paymentTimeoutHours === 'number' ? props.paymentTimeoutHours : 24;
const canAutoCancelExpired =
!!props.autoCancelExpired &&
(!props.mode || props.mode === 'user') &&
!props.readOnly;
const isPendingPayList = statusParams.statusFilter === 0;
if (res?.list && res?.list.length > 0) { const fetchOrders = async () => pageShopOrder(searchConditions);
let res = await fetchOrders();
let incoming = (res?.list || []) as ShopOrder[];
let rawIncomingLength = incoming.length;
// 自动取消“支付已过期”的待支付订单(避免用户看到一堆不可支付的过期单)
if (canAutoCancelExpired && incoming.length && !autoCancelRunningRef.current) {
const expiredToCancel = incoming
.filter(o => !!o?.orderId)
.filter(o => !autoCanceledOrderIdsRef.current.has(o.orderId as number))
.filter(o => isOrderPaymentExpiredSafe(o, timeoutHours));
if (expiredToCancel.length) {
autoCancelRunningRef.current = true;
const justCanceled = new Set<number>();
try {
// 单次最多处理 20 笔,避免接口风暴
for (const order of expiredToCancel.slice(0, 20)) {
try {
await updateShopOrder({ orderId: order.orderId, orderStatus: 2 });
autoCanceledOrderIdsRef.current.add(order.orderId as number);
justCanceled.add(order.orderId as number);
} catch (e) {
console.warn('自动取消过期订单失败:', order?.orderId, e);
}
}
} finally {
autoCancelRunningRef.current = false;
}
if (justCanceled.size > 0) {
if (resetPage) {
// resetPage 时重新拉取一次,确保列表状态与服务端一致
res = await fetchOrders();
incoming = (res?.list || []) as ShopOrder[];
rawIncomingLength = incoming.length;
Taro.showToast({ title: '已自动取消过期订单', icon: 'none' });
} else {
// loadMore 时不重新拉取,避免破坏滚动;仅在本地列表中做最小同步
if (isPendingPayList) {
incoming = incoming.filter(o => !justCanceled.has(o.orderId as number));
} else {
incoming = incoming.map(o => (
justCanceled.has(o.orderId as number) ? { ...o, orderStatus: 2 } : o
));
}
}
}
}
}
if (rawIncomingLength > 0) {
// 订单分页接口已返回 orderGoods列表直接使用该字段 // 订单分页接口已返回 orderGoods列表直接使用该字段
const incoming = res.list as ShopOrder[];
// 使用函数式更新避免依赖 list // 使用函数式更新避免依赖 list
setList(prevList => { if (incoming.length > 0) {
const newList = resetPage ? incoming : (prevList || []).concat(incoming); setList(prevList => (resetPage ? incoming : (prevList || []).concat(incoming)));
return newList; } else {
}); // 本页数据全部被自动取消过滤掉:不清空历史列表,仅保持现状
setList(prevList => (resetPage ? [] : prevList));
}
// 正确判断是否还有更多数据 // 正确判断是否还有更多数据(以服务端返回条数为准)
const hasMoreData = incoming.length >= 10; // 假设每页10条数据 const hasMoreData = rawIncomingLength >= 10; // 假设每页10条数据
setHasMore(hasMoreData); setHasMore(hasMoreData);
} else { } else {
setList(prevList => resetPage ? [] : prevList); // 服务端已无更多数据
setList(prevList => (resetPage ? [] : prevList));
setHasMore(false); setHasMore(false);
} }
@@ -270,7 +365,7 @@ function OrderList(props: OrderListProps) {
icon: 'none' icon: 'none'
}); });
} }
}, [tapIndex, props.searchParams]); // 移除 list/page 依赖避免useEffect触发循环 }, [tapIndex, props.searchParams, props.baseParams, props.mode, props.readOnly, props.autoCancelExpired, props.paymentTimeoutHours]); // 移除 list/page 依赖避免useEffect触发循环
const reloadMore = useCallback(async () => { const reloadMore = useCallback(async () => {
if (loading || !hasMore) return; // 防止重复加载 if (loading || !hasMore) return; // 防止重复加载
@@ -712,17 +807,20 @@ function OrderList(props: OrderListProps) {
{/* 订单列表 */} {/* 订单列表 */}
{list.length > 0 && list {list.length > 0 && list
?.filter((item) => { ?.filter((item) => {
const orderStatus = toNum(item.orderStatus);
// “待收货”不展示退款中的/已退款订单,这些订单统一放到“退货/售后” // “待收货”不展示退款中的/已退款订单,这些订单统一放到“退货/售后”
if (tapIndex === 3 && (item.orderStatus === 4 || item.orderStatus === 6)) { if (tapIndex === 3 && (orderStatus === 4 || orderStatus === 6)) {
return false; return false;
} }
// “退货/售后”只展示售后相关状态 // “退货/售后”只展示售后相关状态
if (tapIndex === 5) { if (tapIndex === 5) {
return item.orderStatus === 4 || item.orderStatus === 5 || item.orderStatus === 6 || item.orderStatus === 7; return orderStatus === 4 || orderStatus === 5 || orderStatus === 6 || orderStatus === 7;
} }
return true; return true;
}) })
?.map((item, index) => { ?.map((item, index) => {
const orderStatus = toNum(item.orderStatus);
const deliveryStatus = toNum(item.deliveryStatus);
return ( return (
<Cell key={item.orderId ?? item.orderNo ?? index} style={{padding: '16px'}} <Cell key={item.orderId ?? item.orderNo ?? index} style={{padding: '16px'}}
onClick={() => Taro.navigateTo({url: `/shop/orderDetail/index?orderId=${item.orderId}`})}> onClick={() => Taro.navigateTo({url: `/shop/orderDetail/index?orderId=${item.orderId}`})}>
@@ -737,7 +835,7 @@ function OrderList(props: OrderListProps) {
</View> </View>
{/* 右侧显示合并的状态和倒计时 */} {/* 右侧显示合并的状态和倒计时 */}
<View className={`${getOrderStatusColor(item)} font-medium`}> <View className={`${getOrderStatusColor(item)} font-medium`}>
{!item.payStatus && item.orderStatus !== 2 ? ( {!item.payStatus && orderStatus !== 2 ? (
<PaymentCountdown <PaymentCountdown
expirationTime={item.expirationTime} expirationTime={item.expirationTime}
createTime={item.createTime} createTime={item.createTime}
@@ -801,23 +899,23 @@ function OrderList(props: OrderListProps) {
{!isReadOnly && ( {!isReadOnly && (
<Space className={'btn flex justify-end'}> <Space className={'btn flex justify-end'}>
{/* 待付款状态:显示取消订单和立即支付 */} {/* 待付款状态:显示取消订单和立即支付 */}
{(!item.payStatus) && item.orderStatus !== 2 && ( {(!item.payStatus) && orderStatus !== 2 && (
<Space> <Space>
<Button size={'small'} onClick={(e) => { <Button size={'small'} onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void cancelOrder(item); void cancelOrder(item);
}}></Button> }}></Button>
{(!item.createTime || !isPaymentExpired(item.createTime, 24)) && ( {(!item.createTime || !isPaymentExpired(item.createTime, 24)) && (
<Button size={'small'} type="primary" onClick={(e) => { <Button size={'small'} type="primary" onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void payOrder(item); void payOrder(item);
}}></Button> }}></Button>
)} )}
</Space> </Space>
)} )}
{/* 待发货状态:显示申请退款 */} {/* 待发货状态:显示申请退款 */}
{item.payStatus && isWithinRefundWindow(item.payTime, 60) && item.deliveryStatus === 10 && item.orderStatus !== 2 && item.orderStatus !== 4 && item.orderStatus !== 6 && item.orderStatus !== 7 && !isOrderCompleted(item) && ( {item.payStatus && isWithinRefundWindow(item.payTime, 60) && deliveryStatus === 10 && orderStatus !== 2 && orderStatus !== 4 && orderStatus !== 6 && orderStatus !== 7 && !isOrderCompleted(item) && (
<Button size={'small'} onClick={(e) => { <Button size={'small'} onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
applyRefund(item); applyRefund(item);
@@ -825,7 +923,7 @@ function OrderList(props: OrderListProps) {
)} )}
{/* 待收货状态:显示查看物流和确认收货 */} {/* 待收货状态:显示查看物流和确认收货 */}
{item.deliveryStatus === 20 && (!item.riderId || Number(item.riderId) === 0 || !!item.sendEndTime) && item.orderStatus !== 2 && item.orderStatus !== 6 && !isOrderCompleted(item) && ( {deliveryStatus === 20 && (!item.riderId || Number(item.riderId) === 0 || !!item.sendEndTime) && orderStatus !== 2 && orderStatus !== 6 && !isOrderCompleted(item) && (
<Space> <Space>
{/*<Button size={'small'} onClick={(e) => {*/} {/*<Button size={'small'} onClick={(e) => {*/}
{/* e.stopPropagation();*/} {/* e.stopPropagation();*/}
@@ -839,7 +937,7 @@ function OrderList(props: OrderListProps) {
)} )}
{/* 退款/售后状态:显示查看进度和撤销申请 */} {/* 退款/售后状态:显示查看进度和撤销申请 */}
{(item.orderStatus === 4 || item.orderStatus === 7) && ( {(orderStatus === 4 || orderStatus === 7) && (
<Space> <Space>
{/*<Button size={'small'} onClick={(e) => {*/} {/*<Button size={'small'} onClick={(e) => {*/}
{/* e.stopPropagation();*/} {/* e.stopPropagation();*/}

View File

@@ -164,6 +164,7 @@ function Order() {
onReload={() => reload(searchParams)} onReload={() => reload(searchParams)}
searchParams={searchParams} searchParams={searchParams}
showSearch={showSearch} showSearch={showSearch}
autoCancelExpired
onSearchParamsChange={(newParams) => { onSearchParamsChange={(newParams) => {
console.log('父组件接收到searchParams变化:', newParams); console.log('父组件接收到searchParams变化:', newParams);
setSearchParams(newParams); setSearchParams(newParams);

View File

@@ -16,7 +16,7 @@ import { ArrowRight, Location, Ticket } from '@nutui/icons-react-taro'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ShopGoods } from '@/api/shop/shopGoods/model' import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods' import { getShopGoods } from '@/api/shop/shopGoods'
import { listShopUserAddress } from '@/api/shop/shopUserAddress' import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model' import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
import './use.scss' import './use.scss'
import Gap from "@/components/Gap"; import Gap from "@/components/Gap";
@@ -27,6 +27,8 @@ import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/s
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model' import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket } from '@/api/glt/gltUserTicket' import { listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder' import { addGltTicketOrder } 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' import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
import { listShopStoreRider } from '@/api/shop/shopStoreRider' import { listShopStoreRider } from '@/api/shop/shopStoreRider'
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model' import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
@@ -34,6 +36,7 @@ import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence' import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
const MIN_START_QTY = 10 const MIN_START_QTY = 10
const ADDRESS_CHANGE_COOLDOWN_DAYS = 30
const OrderConfirm = () => { const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null); const [goods, setGoods] = useState<ShopGoods | null>(null);
@@ -81,6 +84,8 @@ const OrderConfirm = () => {
const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false) const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false)
const deliveryRangeCheckingRef = useRef(false) const deliveryRangeCheckingRef = useRef(false)
const [inDeliveryRange, setInDeliveryRange] = useState<boolean | undefined>(undefined) const [inDeliveryRange, setInDeliveryRange] = useState<boolean | undefined>(undefined)
// Prevent using stale `inDeliveryRange` from a previous address when user switches addresses.
const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState<number | undefined>(undefined)
const router = Taro.getCurrentInstance().router; const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId; const goodsId = router?.params?.goodsId;
@@ -95,6 +100,137 @@ const OrderConfirm = () => {
return Number.isFinite(id) && id > 0 ? id : undefined return Number.isFinite(id) && id > 0 ? id : undefined
}, []) }, [])
type TicketAddressModifyLimit = {
loaded: boolean
canModify: boolean
nextAllowedText?: string
lockedAddressId?: number
}
const [ticketAddressModifyLimit, setTicketAddressModifyLimit] = useState<TicketAddressModifyLimit>({
loaded: false,
canModify: true,
})
const ticketAddressModifyLimitPromiseRef = useRef<Promise<TicketAddressModifyLimit> | null>(null)
const parseTime = (raw?: unknown) => {
if (raw === undefined || raw === null || raw === '') return null
// Compatible with seconds/milliseconds timestamps.
if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw))) {
const n = Number(raw)
if (!Number.isFinite(n)) return null
return dayjs(n < 1e12 ? n * 1000 : n)
}
const d = dayjs(raw as any)
return d.isValid() ? d : null
}
const getOrderTime = (o?: Partial<GltTicketOrder> | null) => {
return parseTime(o?.createTime) || parseTime(o?.updateTime)
}
const getOrderAddressKey = (o?: Partial<GltTicketOrder> | null) => {
const id = Number(o?.addressId)
if (Number.isFinite(id) && id > 0) return `id:${id}`
const txt = String(o?.address || '').trim()
if (txt) return `txt:${txt}`
return ''
}
const loadTicketAddressModifyLimit = async (): Promise<TicketAddressModifyLimit> => {
if (ticketAddressModifyLimitPromiseRef.current) return ticketAddressModifyLimitPromiseRef.current
ticketAddressModifyLimitPromiseRef.current = (async () => {
if (!userId) return { loaded: true, canModify: true }
const now = dayjs()
const pageSize = 20
let page = 1
const all: GltTicketOrder[] = []
let latestKey = ''
let latestAddressId: number | undefined = undefined
while (true) {
const res = await pageGltTicketOrder({ page, limit: pageSize, userId })
const list = Array.isArray(res?.list) ? res.list : []
if (page === 1) {
const first = list[0]
latestKey = getOrderAddressKey(first)
const id = Number(first?.addressId)
latestAddressId = Number.isFinite(id) && id > 0 ? id : undefined
}
if (!list.length) break
all.push(...list)
// Find the oldest order in the newest contiguous block of the latest address key.
// That order's time represents the last time user "set/changed" the ticket delivery address.
const currentKey = latestKey
if (!currentKey) {
return { loaded: true, canModify: true }
}
let lastSameIndex = 0
let foundDifferent = false
for (let i = 1; i < all.length; i++) {
const k = getOrderAddressKey(all[i])
if (!k) continue
if (k === currentKey) {
lastSameIndex = i
continue
}
foundDifferent = true
break
}
if (foundDifferent) {
const lastSetAt = getOrderTime(all[lastSameIndex])
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
const canModify = now.isAfter(nextAllowed)
return {
loaded: true,
canModify,
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
lockedAddressId: latestAddressId,
}
}
const oldest = getOrderTime(all[all.length - 1])
if (oldest && now.diff(oldest, 'day') >= ADDRESS_CHANGE_COOLDOWN_DAYS) {
// We have enough history beyond the cooldown window, and still no different address found.
return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
}
const totalCount = typeof (res as any)?.count === 'number' ? Number((res as any).count) : undefined
if (totalCount !== undefined && all.length >= totalCount) break
if (list.length < pageSize) break
page += 1
if (page > 10) break // safety: avoid excessive paging
}
if (!all.length) return { loaded: true, canModify: true }
// If we can't prove the last-set time is older than the cooldown window, be conservative and lock.
const lastSetAt = getOrderTime(all[all.length - 1])
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
const canModify = now.isAfter(nextAllowed)
return {
loaded: true,
canModify,
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
lockedAddressId: latestAddressId,
}
})()
.finally(() => {
ticketAddressModifyLimitPromiseRef.current = null
})
return ticketAddressModifyLimitPromiseRef.current
}
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => { const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0 if (!t) return 0
const anyT: any = t const anyT: any = t
@@ -197,6 +333,22 @@ const OrderConfirm = () => {
return parseLngLatFromText((s.lngAndLat || s.location || '').trim()) return parseLngLatFromText((s.lngAndLat || s.location || '').trim())
} }
const openAddressPage = async () => {
const limit = ticketAddressModifyLimit.loaded
? ticketAddressModifyLimit
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
if (!limit.canModify) {
Taro.showToast({
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次${limit.nextAllowedText ? '' + limit.nextAllowedText + ' 后可修改' : ''}`,
icon: 'none',
})
return
}
Taro.navigateTo({ url: '/user/address/index' })
}
const loadFences = async (): Promise<ShopStoreFence[]> => { const loadFences = async (): Promise<ShopStoreFence[]> => {
if (fencesLoadedRef.current) return fences if (fencesLoadedRef.current) return fences
if (fencesPromiseRef.current) return fencesPromiseRef.current if (fencesPromiseRef.current) return fencesPromiseRef.current
@@ -249,12 +401,11 @@ const OrderConfirm = () => {
} }
const getCheckPoint = async (): Promise<{ lng: number; lat: number }> => { const getCheckPoint = async (): Promise<{ lng: number; lat: number }> => {
// Prefer address coords (delivery location). Fallback to current GPS if address doesn't have coords. // Immediate water delivery must validate by the delivery address coordinates.
// Falling back to current GPS may allow ordering with an out-of-fence address.
const byAddress = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) const byAddress = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (byAddress) return byAddress if (byAddress) return byAddress
throw new Error('该收货地址缺少经纬度,请在地址里选择地图定位后重试')
const loc = await Taro.getLocation({ type: 'gcj02' })
return { lng: loc.longitude, lat: loc.latitude }
} }
const ensureInDeliveryRange = async (): Promise<boolean> => { const ensureInDeliveryRange = async (): Promise<boolean> => {
@@ -265,6 +416,7 @@ const OrderConfirm = () => {
const p = await getCheckPoint() const p = await getCheckPoint()
const ok = await isPointInFence(p) const ok = await isPointInFence(p)
setInDeliveryRange(ok) setInDeliveryRange(ok)
setDeliveryRangeCheckedAddressId(address?.id)
if (!ok) { if (!ok) {
Taro.showToast({ title: '不在配送范围内,暂不支持下单', icon: 'none' }) Taro.showToast({ title: '不在配送范围内,暂不支持下单', icon: 'none' })
} }
@@ -272,30 +424,8 @@ const OrderConfirm = () => {
} catch (e: any) { } catch (e: any) {
console.error('配送范围校验失败:', e) console.error('配送范围校验失败:', e)
setInDeliveryRange(undefined) setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
const msg = String(e?.errMsg || e?.message || '') // Note: we validate by address coords only; no GPS permission prompt here.
const denied =
msg.includes('auth deny') ||
msg.includes('authorize') ||
msg.includes('permission') ||
msg.includes('denied') ||
msg.includes('scope.userLocation')
if (denied) {
const r = await Taro.showModal({
title: '需要定位权限',
content: '下单前需要校验是否在配送范围内,请在设置中开启定位权限后重试。',
confirmText: '去设置'
})
if (r.confirm) {
try {
await Taro.openSetting()
} catch (_e) {
// ignore
}
}
return false
}
Taro.showToast({ title: e?.message || '配送范围校验失败,请稍后重试', icon: 'none' }) Taro.showToast({ title: e?.message || '配送范围校验失败,请稍后重试', icon: 'none' })
return false return false
@@ -505,6 +635,29 @@ const OrderConfirm = () => {
return return
} }
// Ticket delivery address is based on order snapshot. Enforce "once per 30 days" by latest ticket-order history.
const limit = ticketAddressModifyLimit.loaded
? ticketAddressModifyLimit
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
if (!limit.canModify && limit.lockedAddressId && address.id !== limit.lockedAddressId) {
Taro.showToast({
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次,请使用上次下单地址${limit.nextAllowedText ? '' + limit.nextAllowedText + ' 后可修改)' : ''}`,
icon: 'none',
})
try {
const locked = await getShopUserAddress(limit.lockedAddressId)
if (locked?.id) setAddress(locked)
} catch (_e) {
// ignore: keep current address, but still block submission
}
return
}
if (!addressHasCoords) {
Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' })
return
}
// Ensure ticket list is loaded. // Ensure ticket list is loaded.
if (ticketLoading) { if (ticketLoading) {
Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' }) Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' })
@@ -634,6 +787,20 @@ const OrderConfirm = () => {
if (addressRes && addressRes.length > 0) { if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0]) setAddress(addressRes[0])
} }
// 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 {
const limit = await loadTicketAddressModifyLimit()
setTicketAddressModifyLimit(limit)
if (!limit.canModify && limit.lockedAddressId) {
const locked = await getShopUserAddress(limit.lockedAddressId)
if (locked?.id) setAddress(locked)
}
} catch (e) {
console.error('加载送水地址修改限制失败:', e)
setTicketAddressModifyLimit({ loaded: true, canModify: true })
}
// Tickets are non-blocking for first paint; load in background. // Tickets are non-blocking for first paint; load in background.
loadUserTickets() loadUserTickets()
} catch (err) { } catch (err) {
@@ -655,6 +822,11 @@ const OrderConfirm = () => {
loadAllData({ silent: hasInitialLoadedRef.current }) loadAllData({ silent: hasInitialLoadedRef.current })
}) })
const addressHasCoords = useMemo(() => {
if (!address?.id) return false
return !!parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
}, [address?.id, address?.lng, address?.lat])
// Auto-pick nearest store by delivery address (best-effort, won't override manual selection). // Auto-pick nearest store by delivery address (best-effort, won't override manual selection).
useEffect(() => { useEffect(() => {
if (!address?.id) return if (!address?.id) return
@@ -667,17 +839,35 @@ const OrderConfirm = () => {
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
if (!address?.id) {
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
return
}
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (!p) return if (!p) {
// Cannot validate without address coords -> treat as out of range to block ordering.
setInDeliveryRange(false)
setDeliveryRangeCheckedAddressId(address.id)
return
}
// Avoid keeping stale state from previous address while we validate this one.
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
let ok = true let ok = true
try { try {
ok = await isPointInFence(p) ok = await isPointInFence(p)
} catch (_e) { } catch (_e) {
// Pre-check is best-effort; don't block UI here. // Pre-check is best-effort; don't block UI here.
if (!cancelled) {
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
}
return return
} }
if (cancelled) return if (cancelled) return
setInDeliveryRange(ok) setInDeliveryRange(ok)
setDeliveryRangeCheckedAddressId(address.id)
})() })()
return () => { return () => {
cancelled = true cancelled = true
@@ -685,6 +875,29 @@ const OrderConfirm = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [address?.id, address?.lng, address?.lat]) }, [address?.id, address?.lng, address?.lat])
// When user changes the delivery address to an out-of-fence one, prompt immediately (once per address).
const outOfRangePromptedAddressIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
// Only prompt when user is allowed to change the ticket delivery address.
// Otherwise this toast is noisy (they can't fix it within the cooldown window).
if (!ticketAddressModifyLimit.loaded) return
if (!ticketAddressModifyLimit.canModify) return
const id = address?.id
if (!id) return
if (deliveryRangeCheckedAddressId !== id) return
if (inDeliveryRange !== false) return
if (outOfRangePromptedAddressIdRef.current === id) return
outOfRangePromptedAddressIdRef.current = id
Taro.showToast({ title: addressHasCoords ? '该地址不在配送范围,请更换围栏内地址' : '该地址缺少定位,请在地址里选择地图定位后重试', icon: 'none' })
}, [
address?.id,
addressHasCoords,
deliveryRangeCheckedAddressId,
inDeliveryRange,
ticketAddressModifyLimit.loaded,
ticketAddressModifyLimit.canModify
])
// When tickets/stock change, clamp quantity into [0..maxQuantity]. // When tickets/stock change, clamp quantity into [0..maxQuantity].
useEffect(() => { useEffect(() => {
setQuantity(prev => { setQuantity(prev => {
@@ -761,7 +974,10 @@ const OrderConfirm = () => {
<CellGroup> <CellGroup>
{ {
address && ( address && (
<Cell className={'address-bottom-line'}> <Cell
className={'address-bottom-line'}
onClick={openAddressPage}
>
<Space> <Space>
<Location className={'text-gray-500'}/> <Location className={'text-gray-500'}/>
<View className={'flex flex-col w-full justify-between items-start'}> <View className={'flex flex-col w-full justify-between items-start'}>
@@ -773,14 +989,22 @@ const OrderConfirm = () => {
<ArrowRight className={'text-gray-400'} size={14}/> <ArrowRight className={'text-gray-400'} size={14}/>
</View> </View>
</Space> </Space>
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View> <View className={'pt-1 pb-3'}>
<View className={'text-gray-500'}>{address.name} {address.phone}</View>
{ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && (
<View className={'pt-1 text-xs text-orange-500 hidden'}>
{ADDRESS_CHANGE_COOLDOWN_DAYS}
{ticketAddressModifyLimit.nextAllowedText ? `${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''}
</View>
)}
</View>
</View> </View>
</Space> </Space>
</Cell> </Cell>
) )
} }
{!address && ( {!address && (
<Cell className={''} onClick={() => Taro.navigateTo({url: '/user/address/index'})}> <Cell className={''} onClick={openAddressPage}>
<Space> <Space>
<Location/> <Location/>
@@ -814,6 +1038,8 @@ const OrderConfirm = () => {
value={displayQty} value={displayQty}
min={canStartOrder ? MIN_START_QTY : 0} min={canStartOrder ? MIN_START_QTY : 0}
max={canStartOrder ? maxQuantity : 0} max={canStartOrder ? maxQuantity : 0}
step={10}
readOnly
disabled={!canStartOrder} disabled={!canStartOrder}
onChange={handleQuantityChange} onChange={handleQuantityChange}
/> />
@@ -1037,7 +1263,9 @@ const OrderConfirm = () => {
loading={submitLoading || deliveryRangeChecking} loading={submitLoading || deliveryRangeChecking}
disabled={ disabled={
deliveryRangeChecking || deliveryRangeChecking ||
inDeliveryRange === false || !address?.id ||
!addressHasCoords ||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
availableTicketTotal <= 0 || availableTicketTotal <= 0 ||
!canStartOrder !canStartOrder
} }
@@ -1045,7 +1273,16 @@ const OrderConfirm = () => {
> >
{deliveryRangeChecking {deliveryRangeChecking
? '校验配送范围...' ? '校验配送范围...'
: (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交')) : (!address?.id
? '请选择地址'
: (!addressHasCoords
? '地址缺少定位'
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false)
? '不在配送范围'
: (submitLoading ? '提交中...' : '立即提交')
)
)
)
} }
</Button> </Button>
)} )}