feat(store): 新增门店中心及VIP会员升级功能

- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示
- 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式
- 新增升级VIP会员入口,突出展示并添加推荐标签
- 新建VIP会员升级页面,包含门店信息表单和VIP权益预览
- 提交升级申请调用新增api,支持状态检测表单只读
- 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口
- 页面加载校验店员身份,非店员拒绝访问并提示
- 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice
- 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
This commit is contained in:
2026-07-04 10:26:47 +08:00
parent 2deaaffe13
commit 994695c405
19 changed files with 1744 additions and 632 deletions

View File

@@ -1,564 +1,163 @@
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'
import React, { useState, useEffect } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
definePageConfig({
navigationBarTitleText: '门店中心',
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}]},
// 功能卡片定义(未来新增功能只需在这里加一项)
const FEATURE_CARDS = [
{
key: 'orders',
icon: '📦',
title: '订单管理',
desc: '查看和处理门店订单',
url: '/pages/store/orders/index',
color: '#15803d',
bgColor: '#dcfce7',
},
{
key: 'vip-review',
icon: '👑',
title: 'VIP会员审核',
desc: '审核客户VIP会员申请',
url: '/pages/user/vip-review/index',
color: '#7c3aed',
bgColor: '#ede9fe',
// 显示待审核数量角标
showBadge: true,
},
]
// ─── 操作类型 ─────────────────────────────────────────────────────
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 }[] = []
// 货到付款:未付款 / 已付款 都直接"确认完成"
// 确认完成会同时设置 payStatus=true无需单独的"确认收款"按钮
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
actions.push({label: '确认完成', type: 'complete'})
}
return actions
}
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreCenterPage() {
const [activeTab, setActiveTab] = useState<TabKey>('all')
const [storeInfo, setStoreInfo] = useState<any>(null)
const [pendingCount, setPendingCount] = useState(0)
const [loading, setLoading] = useState(true)
// 订单列表与分页
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)
useEffect(() => {
// 验证店员身份
getMyClerk()
.then(data => {
if (!data) {
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
return
}
}, [])
setStoreInfo(data)
// 加载待审核 VIP 申请数量
loadPendingCount()
})
.catch(() => {
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
})
.finally(() => 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 = 30 // 发货状态 → 已收货
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
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>
)
}
const loadPendingCount = async () => {
try {
const data = await listShopDealerApply({ applyStatus: 10 })
setPendingCount((data || []).length)
} catch { /* ignore */ }
}
if (loading) {
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>
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
if (!storeInfo) return null
return (
<View className='min-h-full bg-gray-50'>
{/* 顶部信息区:店员头像 + 门店名称 */}
<View
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
>
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
<View className='relative z-10 flex items-center gap-4'>
{/* 店员头像 */}
<View className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
{storeInfo.avatar ? (
<Image
className='w-full h-full'
src={storeInfo.avatar}
mode='aspectFill'
/>
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-2xl'>👤</Text>
</View>
)}
</View>
{/* 门店信息 */}
<View className='flex-1 min-w-0'>
<Text className='text-white text-lg font-bold block truncate'>
{storeInfo.storeName || '门店中心'}
</Text>
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
</Text>
</View>
</View>
</View>
{/* 功能卡片区 */}
<View className='mx-3 -mt-6 relative z-20'>
<View className='flex flex-col gap-3'>
{FEATURE_CARDS.map(card => (
<View
key={card.key}
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
onClick={() => Taro.navigateTo({ url: card.url })}
>
{/* 图标 */}
<View
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
style={{ background: card.bgColor }}
>
<Text className='text-2xl'>{card.icon}</Text>
</View>
{/* 文字 */}
<View className='flex-1 min-w-0'>
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
</View>
{/* 待审核角标 */}
{card.showBadge && pendingCount > 0 && (
<View className='absolute -top-1 -right-1 min-w-5 h-5 rounded-full bg-red-500 flex items-center justify-center px-1'>
<Text className='text-white text-xs font-bold'>{pendingCount > 99 ? '99+' : pendingCount}</Text>
</View>
)}
{/* 箭头 */}
<Text className='text-gray-300 text-lg flex-shrink-0'></Text>
</View>
))}
</View>
</View>
{/* 底部提示 */}
<View className='mx-3 mt-6 mb-4'>
<Text className='text-gray-300 text-xs text-center block'>
线
</Text>
</View>
</View>
)
}

View File

@@ -0,0 +1,563 @@
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 }[] = []
// 货到付款:未付款 / 已付款 都直接"确认完成"
// 确认完成会同时设置 payStatus=true无需单独的"确认收款"按钮
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
actions.push({label: '确认完成', type: 'complete'})
}
return actions
}
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreOrdersPage() {
const [activeTab, setActiveTab] = useState<TabKey>('all')
// 订单列表与分页
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 = 30 // 发货状态 → 已收货
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
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>
)
}