- 订单卡片头部左侧新增「下单时间:YYYY-MM-DD HH:mm」显示,位于订单号下方 - 状态标签右侧对齐方式由垂直居中调整为顶端对齐,适配两行文本 - 新增辅助函数 formatOrderTime 兼容 ISO 和空格分隔时间格式,仅保留到分钟 - 复用后台返回的 ShopOrder.createTime 字段,无需后端改动,保证时间数据准确同步
1088 lines
50 KiB
TypeScript
1088 lines
50 KiB
TypeScript
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
||
import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components'
|
||
import Taro, {useDidShow} from '@tarojs/taro'
|
||
import {pageShopOrder, updateShopOrder, confirmOfflinePayment} from '@/api/shop/shopOrder'
|
||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
||
import {saveShopOrderDelivery} from '@/api/shop/shopOrderDelivery'
|
||
import {TenantId} from '@/config/app'
|
||
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
|
||
import { getCompressedImageUrl } from '@/utils/image'
|
||
import { getMyClerk, listShopStoreUser } from '@/api/shop/shopStoreUser'
|
||
import type {ShopStoreUser} from '@/api/shop/shopStoreUser/model'
|
||
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||
|
||
definePageConfig({
|
||
navigationBarTitleText: '订单管理',
|
||
})
|
||
|
||
// ─── Tab 配置(待处理 + 已完成 + 已关闭)──────────────────────────
|
||
type TabKey = 'pending' | 'completed' | 'closed'
|
||
|
||
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
|
||
// 待处理:合并 statusFilter=0(待付款)、1(待发货)、2(待核销)、3(待收货)
|
||
{key: 'pending', label: '待处理', params: [{statusFilter: 0}, {statusFilter: 1}, {statusFilter: 2}, {statusFilter: 3}]},
|
||
// 已完成:statusFilter=5(与后台管理一致)
|
||
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
|
||
// 已关闭:statusFilter=8
|
||
{key: 'closed', label: '已关闭', params: [{statusFilter: 8}]},
|
||
]
|
||
|
||
// ─── 操作类型 ─────────────────────────────────────────────────────
|
||
type OpType = 'confirmPay' | 'deliver' | 'complete' | 'ship' | 'editPrice'
|
||
|
||
const OP_LABEL: Record<OpType, string> = {
|
||
confirmPay: '收款',
|
||
deliver: '送达',
|
||
complete: '已完成',
|
||
ship: '发货',
|
||
editPrice: '改价',
|
||
}
|
||
|
||
const OP_DESC: Record<OpType, string> = {
|
||
confirmPay: '确认已收到线下付款',
|
||
deliver: '上传送达凭证照片,标记为已收货(不改变付款和订单完成状态)',
|
||
complete: '确认订单已完成,收款后点击此按钮标记订单完成',
|
||
ship: '选择发货人员并生成发货单,订单将进入已发货状态',
|
||
editPrice: '修改订单实付金额',
|
||
}
|
||
|
||
// ─── 图片上传 ─────────────────────────────────────────────────────
|
||
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}`
|
||
}
|
||
|
||
/** 格式化下单时间为「YYYY-MM-DD HH:mm」,兼容 "2026-07-23T02:00:18" 与 "2026-07-23 02:00:18" */
|
||
function formatOrderTime(raw?: string): string {
|
||
if (!raw) return ''
|
||
const s = raw.replace('T', ' ').trim()
|
||
const parts = s.split(' ')
|
||
if (parts.length < 2) return s
|
||
const timeParts = parts[1].split(':')
|
||
const hhmm = `${timeParts[0]}:${timeParts[1] || '00'}`
|
||
return `${parts[0]} ${hhmm}`
|
||
}
|
||
|
||
/** 解析 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.payType === 9 && order.deliveryStatus === 20) return '已发货待收款'
|
||
if (order.payStatus === false || order.payStatus === null) {
|
||
// 线下付款待确认收款
|
||
if (order.payType === 9) return '待确认收款'
|
||
return '待收款'
|
||
}
|
||
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
|
||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||
return '进行中'
|
||
}
|
||
|
||
function getStatusColor(order: ShopOrder): string {
|
||
if (order.orderStatus === 1) return '#0e932e'
|
||
if (order.orderStatus === 2) return '#999'
|
||
// 已发货待收款:红色(与用户端一致)
|
||
if (order.payType === 9 && order.deliveryStatus === 20) return '#ee0a24'
|
||
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
|
||
if (order.deliveryStatus === 20) return '#4b9cf5'
|
||
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 && order.payType === 9 && order.orderStatus === 0) {
|
||
actions.push({label: '改价', type: 'editPrice'})
|
||
actions.push({label: '确认收款', type: 'confirmPay'})
|
||
}
|
||
// 发货:
|
||
// - 已付款且未发货:正常发货
|
||
// - 线下付款未付款且未发货:支持先发货后结款
|
||
if (order.orderStatus !== 1) {
|
||
const notShipped = order.deliveryStatus == null || order.deliveryStatus < 20
|
||
if (notShipped && (order.payStatus || order.payType === 9)) {
|
||
actions.push({label: '发货', type: 'ship'})
|
||
}
|
||
}
|
||
// 送达:已发货但未上传送达照片时显示(deliveryStatus=20 且 sendEndImg 为空)
|
||
const shipped = order.deliveryStatus != null && order.deliveryStatus === 20
|
||
const hasDeliverProof = order.sendEndImg && order.sendEndImg !== '[]'
|
||
if (shipped && !hasDeliverProof) {
|
||
actions.push({label: '送达', type: 'deliver'})
|
||
}
|
||
// 确认完成:仅已付款才可标记订单为已完成
|
||
if (order.payStatus) {
|
||
actions.push({label: '确认完成', type: 'complete'})
|
||
}
|
||
return actions
|
||
}
|
||
|
||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||
export default function StoreOrdersPage() {
|
||
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 [payRemarks, setPayRemarks] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// 改价弹窗状态
|
||
const [showEditPriceModal, setShowEditPriceModal] = useState(false)
|
||
const [editPriceOrder, setEditPriceOrder] = useState<ShopOrder | null>(null)
|
||
const [editPriceValue, setEditPriceValue] = useState('')
|
||
const [editPriceRemarks, setEditPriceRemarks] = useState('')
|
||
const [editingPrice, setEditingPrice] = useState(false)
|
||
|
||
// 发货弹窗状态
|
||
const [showShipModal, setShowShipModal] = useState(false)
|
||
const [clerkList, setClerkList] = useState<ShopStoreUser[]>([])
|
||
const [selectedClerkId, setSelectedClerkId] = useState<number | null>(null)
|
||
const [loadingClerks, setLoadingClerks] = useState(false)
|
||
const [shipping, setShipping] = useState(false)
|
||
|
||
// 搜索:searchInput 受控输入框,searchKeyword 为已提交的搜索词
|
||
const [searchInput, setSearchInput] = useState('')
|
||
const [searchKeyword, setSearchKeyword] = useState('')
|
||
|
||
const pageSize = 10
|
||
const loadingRef = useRef(false)
|
||
|
||
// ─── 访问控制:仅门店店员可访问此页面 ──────────────────────
|
||
const [accessChecking, setAccessChecking] = useState(true)
|
||
|
||
useEffect(() => {
|
||
getMyClerk()
|
||
.then(data => {
|
||
if (!data) {
|
||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||
setTimeout(() => Taro.navigateBack(), 1500)
|
||
} else {
|
||
setAccessChecking(false)
|
||
}
|
||
})
|
||
.catch(() => {
|
||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||
setTimeout(() => Taro.navigateBack(), 1500)
|
||
})
|
||
}, [])
|
||
|
||
// ─── 新订单轮询检测 ──────────────────────────────────────────
|
||
const { newOrderCount, clearUnread } = useNewOrderDetector({
|
||
interval: 30000, // 30 秒轮询一次
|
||
fetchLatestOrders: useCallback(
|
||
() => pageShopOrder({ page: 1, limit: 5 }).then(r => r?.list || []),
|
||
[],
|
||
),
|
||
onNewOrders: useCallback((count) => {
|
||
// 震动提醒
|
||
Taro.vibrateShort({ type: 'medium' })
|
||
// Toast 提醒
|
||
Taro.showToast({
|
||
title: `您有 ${count} 条新订单`,
|
||
icon: 'none',
|
||
duration: 2500,
|
||
})
|
||
}, []),
|
||
})
|
||
|
||
/** 加载订单列表(待处理 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, keywords: searchKeyword || undefined})
|
||
)
|
||
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)
|
||
}
|
||
}, [searchKeyword])
|
||
|
||
// 切换 tab 时重新加载
|
||
useEffect(() => {
|
||
loadOrders(activeTab, 1)
|
||
}, [activeTab, loadOrders])
|
||
|
||
// 页面重新显示时刷新
|
||
useDidShow(() => {
|
||
loadOrders(activeTab, 1)
|
||
})
|
||
|
||
/** 加载更多 */
|
||
const handleLoadMore = () => {
|
||
if (hasMore && !loadingRef.current) {
|
||
loadOrders(activeTab, page + 1, true)
|
||
}
|
||
}
|
||
|
||
/** 提交搜索(点击搜索按钮或键盘回车) */
|
||
const handleSearch = () => {
|
||
setSearchKeyword(searchInput.trim())
|
||
}
|
||
|
||
/** 清空搜索 */
|
||
const handleClearSearch = () => {
|
||
setSearchInput('')
|
||
setSearchKeyword('')
|
||
}
|
||
|
||
/** 打开操作弹窗 */
|
||
const openModal = (order: ShopOrder, type: OpType) => {
|
||
// 改价走独立弹窗
|
||
if (type === 'editPrice') {
|
||
openEditPriceModal(order)
|
||
return
|
||
}
|
||
setCurrentOrder(order)
|
||
setOpType(type)
|
||
setProofImages([])
|
||
setPayRemarks('')
|
||
setShowModal(true)
|
||
}
|
||
|
||
/** 关闭弹窗 */
|
||
const closeModal = () => {
|
||
setShowModal(false)
|
||
setCurrentOrder(null)
|
||
setProofImages([])
|
||
setPayRemarks('')
|
||
}
|
||
|
||
/** 选择并上传凭证图片 */
|
||
const chooseProofImage = async () => {
|
||
const maxCount = opType === 'confirmPay' ? 1 : 3
|
||
const remaining = maxCount - proofImages.length
|
||
if (remaining <= 0) return
|
||
|
||
// 门店上传凭证需要访问相册/摄像头,先预检隐私协议授权
|
||
try {
|
||
await ensurePrivacyAuthorized()
|
||
} catch {
|
||
// 授权失败由 chooseImage 自行处理
|
||
}
|
||
|
||
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 === 'deliver' && proofImages.length === 0) {
|
||
Taro.showToast({title: '请上传送达凭证照片', icon: 'none'})
|
||
return
|
||
}
|
||
|
||
// 确认收款必须上传支付凭证
|
||
if (opType === 'confirmPay' && proofImages.length === 0) {
|
||
Taro.showToast({title: '请上传支付凭证', icon: 'none'})
|
||
return
|
||
}
|
||
|
||
setSubmitting(true)
|
||
try {
|
||
if (opType === 'confirmPay') {
|
||
// 确认线下收款
|
||
await confirmOfflinePayment(
|
||
currentOrder.orderId!,
|
||
payRemarks.trim() || undefined,
|
||
proofImages[0]
|
||
)
|
||
} else {
|
||
const updateData: any = {orderId: currentOrder.orderId}
|
||
|
||
if (opType === 'deliver') {
|
||
// 送达:上传送达照片(不改变付款状态、deliveryStatus 和订单完成状态)
|
||
updateData.deliveryTime = formatDateTime(new Date())
|
||
updateData.sendEndImg = JSON.stringify(proofImages)
|
||
} else if (opType === 'complete') {
|
||
// 确认完成:标记订单为已完成
|
||
updateData.orderStatus = 1 // 订单状态 → 已完成
|
||
}
|
||
|
||
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 openEditPriceModal = (order: ShopOrder) => {
|
||
setEditPriceOrder(order)
|
||
setEditPriceValue(String(order.payPrice || order.totalPrice || ''))
|
||
setEditPriceRemarks('')
|
||
setShowEditPriceModal(true)
|
||
}
|
||
|
||
/** 关闭改价弹窗 */
|
||
const closeEditPriceModal = () => {
|
||
setShowEditPriceModal(false)
|
||
setEditPriceOrder(null)
|
||
setEditPriceValue('')
|
||
setEditPriceRemarks('')
|
||
}
|
||
|
||
/** 提交改价 */
|
||
const submitEditPrice = async () => {
|
||
if (!editPriceOrder) return
|
||
const newPrice = parseFloat(editPriceValue)
|
||
if (isNaN(newPrice) || newPrice < 0) {
|
||
Taro.showToast({title: '请输入有效金额', icon: 'none'})
|
||
return
|
||
}
|
||
const oldPriceStr = String(editPriceOrder.payPrice || editPriceOrder.totalPrice || '0')
|
||
const oldPrice = parseFloat(oldPriceStr)
|
||
if (!isNaN(oldPrice) && Math.abs(newPrice - oldPrice) < 0.01) {
|
||
Taro.showToast({title: '金额未变化', icon: 'none'})
|
||
return
|
||
}
|
||
setEditingPrice(true)
|
||
try {
|
||
const changeRecord = `【门店修改金额】原实付¥${oldPriceStr} → 新实付¥${newPrice.toFixed(2)}`
|
||
await updateShopOrder({
|
||
orderId: editPriceOrder.orderId,
|
||
payPrice: newPrice.toFixed(2),
|
||
merchantRemarks: (editPriceOrder.merchantRemarks || '') + `\n${changeRecord}`,
|
||
} as ShopOrder)
|
||
Taro.showToast({title: '改价成功', icon: 'success'})
|
||
closeEditPriceModal()
|
||
loadOrders(activeTab, 1)
|
||
} catch (e: any) {
|
||
Taro.showToast({title: e.message || '改价失败', icon: 'none'})
|
||
} finally {
|
||
setEditingPrice(false)
|
||
}
|
||
}
|
||
|
||
/** 打开发货弹窗:拉取门店店员列表供选择发货人员 */
|
||
const openShipModal = async (order: ShopOrder) => {
|
||
setCurrentOrder(order)
|
||
setSelectedClerkId(null)
|
||
setClerkList([])
|
||
setShowShipModal(true)
|
||
setLoadingClerks(true)
|
||
try {
|
||
// 获取当前登录店员(含 storeId / userId),
|
||
// 用 storeId 拉取本门店店员列表,避免 ?storeId=0 拉不到数据
|
||
const myClerk = await getMyClerk()
|
||
const storeId = myClerk?.storeId
|
||
if (!storeId) {
|
||
Taro.showToast({title: '门店信息未就绪,请稍后再试', icon: 'none'})
|
||
return
|
||
}
|
||
const res = await listShopStoreUser({storeId})
|
||
setClerkList(res || [])
|
||
// 默认选中与当前登录用户 userId 一致的店员
|
||
if (res && myClerk?.userId) {
|
||
const match = res.find(c => c.userId === myClerk!.userId)
|
||
if (match) setSelectedClerkId(match.id ?? null)
|
||
}
|
||
} catch (e) {
|
||
Taro.showToast({title: '加载店员失败', icon: 'none'})
|
||
} finally {
|
||
setLoadingClerks(false)
|
||
}
|
||
}
|
||
|
||
/** 确认发货:创建发货单并记录发货人员,订单置为已发货 */
|
||
const handleConfirmShip = async () => {
|
||
if (!currentOrder) return
|
||
const clerk = clerkList.find(c => c.id === selectedClerkId)
|
||
if (!clerk) {
|
||
Taro.showToast({title: '请选择发货人员', icon: 'none'})
|
||
return
|
||
}
|
||
setShipping(true)
|
||
try {
|
||
// 1) 创建发货单,记录发货人信息
|
||
await saveShopOrderDelivery({
|
||
orderId: currentOrder.orderId,
|
||
deliveryMethod: 20, // 20=无需物流(门店自送/自提,无快递单号)
|
||
sendName: clerk.name,
|
||
sendPhone: clerk.phone,
|
||
// 发货地址取门店名称(店员实体无地址字段)
|
||
sendAddress: currentOrder.storeName || '',
|
||
})
|
||
// 2) 订单置为已发货(deliveryStatus=20),物流页与列表状态同步更新
|
||
await updateShopOrder({
|
||
orderId: currentOrder.orderId,
|
||
deliveryStatus: 20,
|
||
deliveryTime: formatDateTime(new Date()),
|
||
})
|
||
Taro.showToast({title: '发货成功', icon: 'success'})
|
||
setShowShipModal(false)
|
||
loadOrders(activeTab, 1) // 刷新列表(发货按钮随之隐藏)
|
||
} catch (e: any) {
|
||
Taro.showToast({title: e.message || '发货失败', icon: 'none'})
|
||
} finally {
|
||
setShipping(false)
|
||
}
|
||
}
|
||
|
||
/** 关闭订单 */
|
||
const handleCloseOrder = (order: ShopOrder) => {
|
||
Taro.showModal({
|
||
title: '提示',
|
||
content: `确定要关闭订单 ${order.orderNo} 吗?关闭后无法恢复。`,
|
||
confirmColor: '#ee0a24',
|
||
success: async (res) => {
|
||
if (!res.confirm) return
|
||
try {
|
||
Taro.showLoading({title: '关闭中...'})
|
||
await updateShopOrder({orderId: order.orderId, orderStatus: 2} as ShopOrder)
|
||
Taro.hideLoading()
|
||
Taro.showToast({title: '关闭成功', icon: 'success'})
|
||
loadOrders(activeTab, 1)
|
||
} catch (e: any) {
|
||
Taro.hideLoading()
|
||
Taro.showToast({title: e.message || '关闭失败', icon: 'none'})
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 一键导航到收货地址(调用微信内置地图) */
|
||
const handleNavigate = (order: ShopOrder) => {
|
||
const lat = parseFloat(order.addressLat || '')
|
||
const lng = parseFloat(order.addressLng || '')
|
||
if (isNaN(lat) || isNaN(lng)) {
|
||
Taro.showToast({title: '该订单未记录定位信息,无法导航', icon: 'none'})
|
||
return
|
||
}
|
||
Taro.openLocation({
|
||
latitude: lat,
|
||
longitude: lng,
|
||
name: order.realName || '收货地址',
|
||
address: order.address || '',
|
||
scale: 16,
|
||
})
|
||
}
|
||
|
||
/** 渲染单个订单卡片 */
|
||
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-start mb-3'>
|
||
<View className='flex flex-col'>
|
||
<Text className='text-xs text-gray-400'>订单号:{order.orderNo}</Text>
|
||
{order.createTime ? (
|
||
<Text className='text-xs text-gray-400 mt-1'>下单时间:{formatOrderTime(order.createTime)}</Text>
|
||
) : null}
|
||
</View>
|
||
<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.image && (
|
||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.image, { width: 80 })}
|
||
mode='aspectFill'/>
|
||
)}
|
||
<View className='flex-1'>
|
||
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
|
||
{goods.spec && (
|
||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.spec}</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.totalNum}</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
))}
|
||
|
||
{/* 收货信息(点击区域一键导航) */}
|
||
{(order.realName || order.phone) && (
|
||
<View
|
||
className='bg-gray-50 rounded-lg p-3 mb-3 flex items-center'
|
||
onClick={() => handleNavigate(order)}
|
||
>
|
||
<View className='flex-1'>
|
||
<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>
|
||
<View className='ml-2 flex flex-col items-center justify-center px-1'>
|
||
<Text className='text-lg text-green-500 leading-none'>›</Text>
|
||
<Text className='text-xs text-green-500 mt-0.5'>导航</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 买家备注 */}
|
||
{order.buyerRemarks && (
|
||
<View className='bg-orange-50 rounded-lg p-3 mb-3'>
|
||
<Text className='text-xs text-orange-500 mb-1 block'>买家备注</Text>
|
||
<Text className='text-sm text-gray-600 block'>{order.buyerRemarks}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 付款凭证(线下付款确认收款后显示) */}
|
||
{order.paymentVoucher && (
|
||
<View className='bg-green-50 rounded-lg p-3 mb-3'>
|
||
<Text className='text-xs text-green-500 mb-2 block'>付款凭证:</Text>
|
||
<Image
|
||
className='w-20 h-20 rounded-lg bg-gray-100'
|
||
src={getCompressedImageUrl(order.paymentVoucher)}
|
||
mode='aspectFill'
|
||
onClick={() => Taro.previewImage({
|
||
current: order.paymentVoucher!,
|
||
urls: [order.paymentVoucher!]
|
||
})}
|
||
/>
|
||
</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={getCompressedImageUrl(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 === 8 ? '货到付款' : order.payType === 9 ? '线下付款' : order.payType === 4 ? '现金支付' : order.payType === 1 ? '微信支付' : '其他'}
|
||
</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={() => handleCloseOrder(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'
|
||
: act.type === 'confirmPay'
|
||
? 'bg-blue-500'
|
||
: act.type === 'ship'
|
||
? 'bg-purple-500'
|
||
: act.type === 'editPrice'
|
||
? 'bg-orange-500'
|
||
: 'border border-blue-500'
|
||
}`}
|
||
onClick={() => act.type === 'ship'
|
||
? openShipModal(order)
|
||
: openModal(order, act.type)}
|
||
>
|
||
<Text className={`text-sm ${
|
||
act.type === 'complete' || act.type === 'confirmPay' || act.type === 'ship' || act.type === 'editPrice' ? 'text-white' : 'text-blue-500'
|
||
}`}>{act.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
// 访问校验中或已拒绝:不渲染页面内容
|
||
if (accessChecking) {
|
||
return <View className='min-h-full bg-gray-50' />
|
||
}
|
||
|
||
return (
|
||
<View className='min-h-full bg-gray-50'>
|
||
{/* 搜索栏 */}
|
||
<View className='bg-white px-3 py-2 flex items-center'>
|
||
<View className='flex-1 flex items-center bg-gray-100 rounded-full px-3 h-9'>
|
||
<Input
|
||
className='flex-1 text-sm text-gray-800'
|
||
value={searchInput}
|
||
onInput={(e) => setSearchInput(e.detail.value)}
|
||
onConfirm={handleSearch}
|
||
placeholder='搜索订单号 / 手机号 / 昵称'
|
||
confirmType='search'
|
||
/>
|
||
{searchInput ? (
|
||
<View
|
||
className='ml-2 w-5 h-5 flex items-center justify-center'
|
||
onClick={handleClearSearch}
|
||
>
|
||
<Text className='text-gray-400 text-base leading-none'>×</Text>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
<View className='ml-1 px-2 py-1' onClick={handleSearch}>
|
||
<Text className='text-sm text-green-500 font-medium'>搜索</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Tab 栏 */}
|
||
<View className='bg-white flex'>
|
||
{TABS.map(tab => (
|
||
<View
|
||
key={tab.key}
|
||
className={`flex-1 text-center py-3 border-b-2 relative ${
|
||
activeTab === tab.key ? 'border-green-500' : 'border-transparent'
|
||
}`}
|
||
onClick={() => {
|
||
setActiveTab(tab.key)
|
||
clearUnread()
|
||
}}
|
||
>
|
||
<Text
|
||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
|
||
{tab.label}
|
||
</Text>
|
||
{/* 新订单角标:在"待处理"Tab 上显示 */}
|
||
{tab.key === 'pending' && newOrderCount > 0 && (
|
||
<View className='absolute -top-0.5 right-2 min-w-[18px] h-[18px] rounded-full bg-red-500 flex items-center justify-center px-1'>
|
||
<Text className='text-white text-[10px] font-bold'>
|
||
{newOrderCount > 99 ? '99+' : newOrderCount}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{/* 订单列表 */}
|
||
<ScrollView
|
||
scrollY
|
||
style={{height: 'calc(100vh - 100px)'}}
|
||
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'>
|
||
{searchKeyword ? `未找到与“${searchKeyword}”相关的订单` : '暂无订单'}
|
||
</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>
|
||
)}
|
||
|
||
{/* 凭证上传(确认完成不需要凭证) */}
|
||
{opType !== 'complete' && (
|
||
<>
|
||
<Text className='text-sm text-gray-600 mb-3 block'>
|
||
{opType === 'confirmPay'
|
||
? '上传支付凭证(必填,如微信转账截图)'
|
||
: opType === 'deliver'
|
||
? '上传送达凭证(必填,配送货物到达后拍照上传,最多3张)'
|
||
: '上传凭证照片(选填,最多3张)'}
|
||
</Text>
|
||
<View className='flex flex-wrap gap-3 mb-4'>
|
||
{proofImages.map((url, idx) => (
|
||
<View key={idx} className='relative'>
|
||
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={getCompressedImageUrl(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 < (opType === 'confirmPay' ? 1 : 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>
|
||
</>
|
||
)}
|
||
|
||
{/* 备注(仅确认收款时显示) */}
|
||
{opType === 'confirmPay' && (
|
||
<View className='mb-6'>
|
||
<Text className='text-sm text-gray-600 mb-2 block'>备注(选填)</Text>
|
||
<Textarea
|
||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
|
||
style={{minHeight: '60px'}}
|
||
value={payRemarks}
|
||
onInput={(e) => setPayRemarks(e.detail.value)}
|
||
placeholder='可填写备注(如:微信转账已收到)'
|
||
maxlength={200}
|
||
/>
|
||
</View>
|
||
)}
|
||
|
||
{/* 确认完成不需要备注时补充间距 */}
|
||
{opType !== 'confirmPay' && <View className='mb-2'/>}
|
||
|
||
{/* 操作按钮 */}
|
||
<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>
|
||
)}
|
||
|
||
{/* 发货弹窗:选择发货人员 */}
|
||
{showShipModal && currentOrder && (
|
||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||
<View className='absolute inset-0 bg-black/50' onClick={() => setShowShipModal(false)}/>
|
||
<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'>
|
||
选择发货人员
|
||
</Text>
|
||
|
||
{/* 订单信息 */}
|
||
<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>
|
||
</View>
|
||
|
||
{/* 店员列表 */}
|
||
<View className='max-h-80 overflow-y-auto mb-5'>
|
||
{loadingClerks ? (
|
||
<View className='py-8 flex justify-center'>
|
||
<Text className='text-sm text-gray-400'>加载中...</Text>
|
||
</View>
|
||
) : clerkList.length === 0 ? (
|
||
<View className='py-8 flex justify-center'>
|
||
<Text className='text-sm text-gray-400'>暂无可选择的店员</Text>
|
||
</View>
|
||
) : (
|
||
clerkList.map(clerk => {
|
||
const selected = clerk.id === selectedClerkId
|
||
return (
|
||
<View
|
||
key={clerk.id}
|
||
className={`flex items-center justify-between px-4 py-3 rounded-xl mb-2 border ${
|
||
selected ? 'border-purple-500 bg-purple-50' : 'border-gray-200'
|
||
}`}
|
||
onClick={() => setSelectedClerkId(clerk.id ?? null)}
|
||
>
|
||
<View className='flex-1'>
|
||
<Text className='text-sm text-gray-800'>
|
||
{clerk.name}
|
||
<Text className='text-xs text-gray-400 ml-2'>
|
||
{clerk.roleType === 1 ? '经理' : '店员'}
|
||
</Text>
|
||
</Text>
|
||
<Text className='text-xs text-gray-500 mt-0.5 block'>{clerk.phone}</Text>
|
||
</View>
|
||
{selected && (
|
||
<Text className='text-purple-500 text-sm'>✓</Text>
|
||
)}
|
||
</View>
|
||
)
|
||
})
|
||
)}
|
||
</View>
|
||
|
||
{/* 操作按钮 */}
|
||
<View className='flex gap-3'>
|
||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={() => setShowShipModal(false)}>
|
||
<Text className='text-sm text-gray-600'>取消</Text>
|
||
</View>
|
||
<View
|
||
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
|
||
selectedClerkId != null ? 'bg-purple-500' : 'bg-gray-300'
|
||
}`}
|
||
onClick={selectedClerkId != null && !shipping ? handleConfirmShip : undefined}
|
||
>
|
||
{shipping ? (
|
||
<Text className='text-sm text-white'>发货中...</Text>
|
||
) : (
|
||
<Text className='text-sm text-white'>确认发货</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 改价弹窗 */}
|
||
{showEditPriceModal && editPriceOrder && (
|
||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||
<View className='absolute inset-0 bg-black/50' onClick={closeEditPriceModal}/>
|
||
<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'>
|
||
修改订单金额
|
||
</Text>
|
||
|
||
{/* 订单信息 */}
|
||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||
<Text className='text-sm text-gray-700 block'>订单号:{editPriceOrder.orderNo}</Text>
|
||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||
原实付金额:<Text className='text-red-500 font-medium'>¥{editPriceOrder.payPrice || editPriceOrder.totalPrice}</Text>
|
||
</Text>
|
||
</View>
|
||
|
||
{/* 新金额输入 */}
|
||
<View className='mb-6'>
|
||
<Text className='text-sm text-gray-600 mb-2 block'>新实付金额(元)</Text>
|
||
<Input
|
||
type='digit'
|
||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-lg text-gray-800'
|
||
value={editPriceValue}
|
||
onInput={(e) => setEditPriceValue(e.detail.value)}
|
||
placeholder='请输入新的实付金额'
|
||
/>
|
||
</View>
|
||
|
||
{/* 改价备注 */}
|
||
<View className='mb-6'>
|
||
<Text className='text-sm text-gray-600 mb-2 block'>改价原因(选填)</Text>
|
||
<Textarea
|
||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
|
||
style={{minHeight: '60px'}}
|
||
value={editPriceRemarks}
|
||
onInput={(e) => setEditPriceRemarks(e.detail.value)}
|
||
placeholder='如:客户协商优惠、多收退款等'
|
||
maxlength={200}
|
||
/>
|
||
</View>
|
||
|
||
{/* 操作按钮 */}
|
||
<View className='flex gap-3'>
|
||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeEditPriceModal}>
|
||
<Text className='text-sm text-gray-600'>取消</Text>
|
||
</View>
|
||
<View
|
||
className='flex-1 py-3 rounded-xl bg-orange-500 text-center flex items-center justify-center'
|
||
onClick={editingPrice ? undefined : submitEditPrice}
|
||
>
|
||
{editingPrice ? (
|
||
<Text className='text-sm text-white'>提交中...</Text>
|
||
) : (
|
||
<Text className='text-sm text-white'>确认改价</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
)
|
||
}
|