Files
xinlong-shop-taro/src/pages/store/center/index.tsx
赵忠林 ce7f5177eb feat(store): 优化门店中心订单管理和Checkout订单创建
- Checkout创建订单时直接传递payStatus和payTime字段防止未付款订单被删除
- 门店中心Tab改为三个(全部、待处理、已完成),并合并多个statusFilter查询
- 操作按钮逻辑调整为根据订单状态判断,已完成及已关闭订单无操作和删除按钮
- 状态文案和颜色适配完善,添加订单送达凭证照片预览功能
- 确认完成操作强制上传配送凭证照片,凭证支持JSON数组格式存储
- 收款操作支持上传凭证照片并追加至备注中
- 优化订单列表加载逻辑,多个请求结果合并去重并按创建时间降序排序
- 代码格式调整及UI细节优化,提升代码一致性和用户体验
2026-07-01 16:54:10 +08:00

568 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {useState, useEffect, useCallback, useRef} from 'react'
import {View, Text, Image, ScrollView} from '@tarojs/components'
import Taro, {useDidShow} from '@tarojs/taro'
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
import {TenantId} from '../../../config/app'
definePageConfig({
navigationBarTitleText: '门店中心',
})
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
type TabKey = 'all' | 'pending' | 'completed'
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
// 全部:不传 statusFilter
{key: 'all', label: '全部', params: [{}]},
// 已完成statusFilter=5与后台管理一致
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
// 待处理:合并 statusFilter=1待发货和 statusFilter=8已关闭
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
]
// ─── 操作类型 ─────────────────────────────────────────────────────
type OpType = 'pay' | 'complete'
const OP_LABEL: Record<OpType, string> = {
pay: '已收款',
complete: '已完成',
}
const OP_DESC: Record<OpType, string> = {
pay: '变更支付状态为已付款',
complete: '同时变更支付状态为已付款、发货状态为已完成、订单状态为已完成',
}
// ─── 图片上传 ─────────────────────────────────────────────────────
const uploadImage = (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
filePath,
name: 'file',
header: {'content-type': 'application/json', TenantId},
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 0 && data.data?.url) {
resolve(data.data.url)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch {
reject(new Error('解析上传响应失败'))
}
},
fail: () => reject(new Error('上传请求失败')),
})
})
}
// ─── 状态辅助函数 ──────────────────────────────────────────────────
/** 格式化日期为 Spring Boot LocalDateTime 可接受的格式yyyy-MM-dd HH:mm:ss */
function formatDateTime(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${mi}:${s}`
}
/** 解析 sendEndImg兼容 JSON 数组(新格式)和逗号分隔(旧格式) */
function parseSendEndImg(raw: string): string[] {
if (!raw || !raw.trim()) return []
const trimmed = raw.trim()
// 尝试 JSON 解析
if (trimmed.startsWith('[')) {
try {
const arr = JSON.parse(trimmed)
if (Array.isArray(arr)) return arr.filter(Boolean)
} catch { /* fallback to comma split */
}
}
// 旧格式:逗号分隔(兼容单张图不含逗号的 URL
return trimmed.split(',').map(s => s.trim()).filter(Boolean)
}
function getStatusText(order: ShopOrder): string {
if (order.orderStatus === 1) return '已完成'
if (order.orderStatus === 2) return '已关闭'
if (order.payStatus === false || order.payStatus === null) return '待收款'
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
return '进行中'
}
function getStatusColor(order: ShopOrder): string {
if (order.orderStatus === 1) return '#0e932e'
if (order.orderStatus === 2) return '#999'
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
if (order.payStatus) return '#ff7d00'
return '#999'
}
/** 判断订单是否可操作(非已完成、非已关闭) */
function isOrderActionable(order: ShopOrder): boolean {
return order.orderStatus !== 1 && order.orderStatus !== 2
}
/** 获取订单可执行的操作 */
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
if (!isOrderActionable(order)) return []
const actions: { label: string; type: OpType }[] = []
// 未付款 → 可确认收款,也可直接确认完成
if (order.payStatus === false || order.payStatus === null) {
actions.push({label: '确认收款', type: 'pay'})
actions.push({label: '确认完成', type: 'complete'})
}
// 已付款但未完成 → 可确认完成
if (order.payStatus && order.orderStatus !== 1 && order.orderStatus !== 2) {
actions.push({label: '确认完成', type: 'complete'})
}
return actions
}
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreCenterPage() {
const [activeTab, setActiveTab] = useState<TabKey>('pending')
// 订单列表与分页
const [orderList, setOrderList] = useState<ShopOrder[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
// 操作弹窗状态
const [showModal, setShowModal] = useState(false)
const [currentOrder, setCurrentOrder] = useState<ShopOrder | null>(null)
const [opType, setOpType] = useState<OpType>('pay')
const [proofImages, setProofImages] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const pageSize = 10
const loadingRef = useRef(false)
/** 加载订单列表(待处理 Tab 会合并多个 statusFilter 的结果) */
const loadOrders = useCallback(async (tab: TabKey, pageNo: number = 1, append = false) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const tabConfig = TABS.find(t => t.key === tab)
if (!tabConfig) return
// 并行请求所有 params 组合,合并去重
const allRequests = tabConfig.params.map(p =>
pageShopOrder({...p, page: pageNo, limit: pageSize})
)
const allResults = await Promise.all(allRequests)
// 合并所有结果列表,按 orderId 去重
const mergedList = allResults.reduce<ShopOrder[]>((acc, res) => {
const list = res?.list || []
list.forEach(item => {
if (!acc.some(existing => existing.orderId === item.orderId)) {
acc.push(item)
}
})
return acc
}, [])
// 按 createTime 降序排列
mergedList.sort((a, b) => {
const ta = a.createTime ? new Date(a.createTime).getTime() : 0
const tb = b.createTime ? new Date(b.createTime).getTime() : 0
return tb - ta
})
setOrderList(prev => append ? [...prev, ...mergedList] : mergedList)
setPage(pageNo)
// 只要任意一个请求还有数据就允许继续加载
const maxLen = Math.max(...allResults.map(r => (r?.list || []).length))
setHasMore(maxLen >= pageSize)
} catch (e: any) {
Taro.showToast({title: e.message || '加载失败', icon: 'none'})
if (!append) setOrderList([])
} finally {
loadingRef.current = false
setLoading(false)
}
}, [])
// 切换 tab 时重新加载
useEffect(() => {
loadOrders(activeTab, 1)
}, [activeTab, loadOrders])
// 页面重新显示时刷新
useDidShow(() => {
loadOrders(activeTab, 1)
})
/** 加载更多 */
const handleLoadMore = () => {
if (hasMore && !loadingRef.current) {
loadOrders(activeTab, page + 1, true)
}
}
/** 打开操作弹窗 */
const openModal = (order: ShopOrder, type: OpType) => {
setCurrentOrder(order)
setOpType(type)
setProofImages([])
setShowModal(true)
}
/** 关闭弹窗 */
const closeModal = () => {
setShowModal(false)
setCurrentOrder(null)
setProofImages([])
}
/** 选择并上传凭证图片 */
const chooseProofImage = () => {
const maxCount = 3
const remaining = maxCount - proofImages.length
if (remaining <= 0) return
Taro.chooseImage({
count: remaining,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: async (res) => {
for (const filePath of res.tempFilePaths) {
try {
Taro.showLoading({title: '上传中...'})
const url = await uploadImage(filePath)
Taro.hideLoading()
setProofImages(prev => [...prev, url])
} catch (e: any) {
Taro.hideLoading()
Taro.showToast({title: e.message || '上传失败', icon: 'none'})
}
}
},
})
}
/** 移除凭证图片 */
const removeProofImage = (idx: number) => {
setProofImages(prev => prev.filter((_, i) => i !== idx))
}
/** 提交操作 */
const submitOperation = async () => {
if (!currentOrder) return
// 确认完成必须上传凭证照片
if (opType === 'complete' && proofImages.length === 0) {
Taro.showToast({title: '请上传配送凭证照片', icon: 'none'})
return
}
setSubmitting(true)
try {
const updateData: any = {orderId: currentOrder.orderId}
if (opType === 'pay') {
// 确认收款:变更支付状态为已付款
updateData.payStatus = true
updateData.payTime = formatDateTime(new Date())
} else if (opType === 'complete') {
// 确认完成:变更支付状态为已付款、发货状态为已完成、订单状态为已完成
updateData.payStatus = true
updateData.payTime = formatDateTime(new Date())
updateData.deliveryStatus = 20 // 发货状态 → 已完成
updateData.orderStatus = 1 // 订单状态 → 已完成
updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照JSON数组
}
// 收款操作如有凭证也追加到备注
if (opType === 'pay' && proofImages.length > 0) {
const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}`
updateData.comments = (currentOrder.comments || '') + `\n${proofText}`
}
await updateShopOrder(updateData)
Taro.showToast({title: '操作成功', icon: 'success'})
closeModal()
loadOrders(activeTab, 1) // 刷新列表
} catch (e: any) {
Taro.showToast({title: e.message || '操作失败', icon: 'none'})
} finally {
setSubmitting(false)
}
}
/** 删除订单 */
const handleDelete = (order: ShopOrder) => {
Taro.showModal({
title: '提示',
content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`,
confirmColor: '#ee0a24',
success: async (res) => {
if (!res.confirm) return
try {
Taro.showLoading({title: '删除中...'})
await removeShopOrder(order.orderId)
Taro.hideLoading()
Taro.showToast({title: '删除成功', icon: 'success'})
loadOrders(activeTab, 1)
} catch (e: any) {
Taro.hideLoading()
Taro.showToast({title: e.message || '删除失败', icon: 'none'})
}
},
})
}
/** 渲染单个订单卡片 */
const renderOrderCard = (order: ShopOrder) => {
const actions = getOrderActions(order)
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
const orderGoods = (order as any).orderGoods || []
return (
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
{/* 订单头部 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-400'>{order.orderNo}</Text>
<Text className='text-xs' style={{color: getStatusColor(order)}}>
{getStatusText(order)}
</Text>
</View>
{/* 商品列表 */}
{orderGoods.map((goods: any, idx: number) => (
<View key={idx} className='flex items-center gap-3 mb-3'>
{goods.coverImage && (
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={goods.coverImage}
mode='aspectFill'/>
)}
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
{goods.specInfo && (
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
)}
<View className='flex justify-between items-center mt-1'>
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
</View>
</View>
</View>
))}
{/* 收货信息 */}
{(order.realName || order.phone) && (
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
{order.address && (
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
)}
</View>
)}
{/* 送达凭证(已完成订单) */}
{order.sendEndImg && (() => {
const imgs = parseSendEndImg(order.sendEndImg)
return imgs.length > 0 && (
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<Text className='text-xs text-gray-500 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{imgs.map((img, i) => (
<Image
key={i}
className='w-20 h-20 rounded-lg bg-gray-100'
src={img}
mode='aspectFill'
onClick={() => Taro.previewImage({
current: img,
urls: imgs
})}
/>
))}
</View>
</View>
)
})()}
{/* 金额 + 支付方式 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-400'>
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
</Text>
<Text className='text-sm text-gray-700'>
<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
</Text>
</View>
{/* 操作按钮 */}
<View className='flex justify-between items-center border-t border-gray-50 pt-3'>
{/* 删除按钮:仅未完成/未关闭的订单显示 */}
{canDelete ? (
<View
className='px-3 py-1.5'
onClick={() => handleDelete(order)}
>
<Text className='text-xs text-gray-400'></Text>
</View>
) : (
<View/>
)}
<View className={`flex gap-2 ${!canDelete ? 'w-full justify-end' : ''}`}>
{actions.map(act => (
<View
key={act.type}
className={`px-4 py-2 rounded-lg ${
act.type === 'complete'
? 'bg-green-500'
: 'border border-blue-500'
}`}
onClick={() => openModal(order, act.type)}
>
<Text className={`text-sm ${
act.type === 'complete' ? 'text-white' : 'text-blue-500'
}`}>{act.label}</Text>
</View>
))}
</View>
</View>
</View>
)
}
return (
<View className='min-h-full bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{TABS.map(tab => (
<View
key={tab.key}
className={`flex-1 text-center py-3 border-b-2 ${
activeTab === tab.key ? 'border-green-500' : 'border-transparent'
}`}
onClick={() => setActiveTab(tab.key)}
>
<Text
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
{tab.label}
</Text>
</View>
))}
</View>
{/* 订单列表 */}
<ScrollView
scrollY
style={{height: 'calc(100vh - 50px)'}}
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
{loading && orderList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'>...</Text>
</View>
) : orderList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'></Text>
</View>
) : (
orderList.map(renderOrderCard)
)}
{loading && orderList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)}
{!hasMore && orderList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
<View className='h-6'/>
</ScrollView>
{/* 操作弹窗 */}
{showModal && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
{/* 遮罩 */}
<View className='absolute inset-0 bg-black/50' onClick={closeModal}/>
{/* 弹窗内容 */}
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
{OP_LABEL[opType]}
</Text>
{/* 订单信息 */}
{currentOrder && (
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
<Text className='text-sm text-gray-700 block'>{currentOrder.orderNo}</Text>
<Text className='text-sm text-gray-700 mt-1 block'>
<Text
className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
</Text>
<Text className='text-xs text-gray-500 mt-2 block'>
{OP_DESC[opType]}
</Text>
</View>
)}
{/* 凭证上传 */}
<Text className='text-sm text-gray-600 mb-3 block'>
{opType === 'complete' ? '必填配送货物到达后拍照上传最多3张' : '选填最多3张'}
</Text>
<View className='flex flex-wrap gap-3 mb-6'>
{proofImages.map((url, idx) => (
<View key={idx} className='relative'>
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={url} mode='aspectFill'/>
<View
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => removeProofImage(idx)}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
{proofImages.length < 3 && (
<View
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
onClick={chooseProofImage}
>
<Text className='text-2xl text-gray-300'>+</Text>
</View>
)}
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeModal}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
opType === 'complete' ? 'bg-green-500' : 'bg-blue-500'
}`}
onClick={submitting ? undefined : submitOperation}
>
{submitting ? (
<Text className='text-sm text-white'>...</Text>
) : (
<Text className='text-sm text-white'>{OP_LABEL[opType]}</Text>
)}
</View>
</View>
</View>
</View>
)}
</View>
)
}