Prechádzať zdrojové kódy

✨ feat(order): 新增订单取消功能及取消原因对话框

- 新增取消原因对话框组件,支持预定义原因选择和自定义输入
- 在订单详情页和订单列表页集成取消订单功能
- 添加网络状态检查,确保取消操作在网络可用时执行
- 实现取消订单的loading状态和错误处理
- 为已支付订单提供退款流程提示信息

✅ test(order): 添加取消订单相关组件测试用例

- 新增取消原因对话框组件单元测试
- 新增订单按钮栏组件单元测试,覆盖取消订单功能
- 新增订单详情页单元测试,验证取消订单流程
- 更新订单列表页测试用例,支持取消订单功能
- 添加Taro API mock支持网络状态检查功能
yourname 1 mesiac pred
rodič
commit
c609e45c1e

+ 151 - 0
mini/src/components/common/CancelReasonDialog/index.tsx

@@ -0,0 +1,151 @@
+import { View, Text, Input, Button } from '@tarojs/components'
+import Taro from '@tarojs/taro'
+import { useState } from 'react'
+
+interface CancelReasonDialogProps {
+  visible: boolean
+  onCancel: () => void
+  onConfirm: (reason: string) => void
+  loading?: boolean
+}
+
+// 预定义的取消原因选项
+const CANCEL_REASONS = [
+  '我不想买了',
+  '信息填写错误,重新下单',
+  '商家缺货',
+  '价格不合适',
+  '其他原因'
+]
+
+export default function CancelReasonDialog({
+  visible,
+  onCancel,
+  onConfirm,
+  loading = false
+}: CancelReasonDialogProps) {
+  const [reason, setReason] = useState('')
+  const [selectedReason, setSelectedReason] = useState('')
+
+  // 处理原因选择
+  const handleReasonSelect = (reasonText: string) => {
+    setSelectedReason(reasonText)
+    setReason(reasonText)
+  }
+
+  // 处理自定义原因输入
+  const handleCustomReasonChange = (value: string) => {
+    setReason(value)
+    if (value && !CANCEL_REASONS.includes(value)) {
+      setSelectedReason('')
+    }
+  }
+
+  // 确认取消
+  const handleConfirm = () => {
+    if (!reason.trim()) {
+      Taro.showToast({
+        title: '请填写取消原因',
+        icon: 'error',
+        duration: 2000
+      })
+      return
+    }
+
+    if (reason.trim().length > 500) {
+      Taro.showToast({
+        title: '取消原因不能超过500字',
+        icon: 'error',
+        duration: 2000
+      })
+      return
+    }
+
+    onConfirm(reason.trim())
+  }
+
+  // 重置对话框状态
+  const handleReset = () => {
+    setReason('')
+    setSelectedReason('')
+  }
+
+  // 处理取消
+  const handleCancel = () => {
+    handleReset()
+    onCancel()
+  }
+
+  if (!visible) {
+    return null
+  }
+
+  return (
+    <View className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+      <View className="bg-white rounded-lg p-6 mx-4 w-full max-w-md">
+        <Text className="text-lg font-semibold text-gray-900 mb-4 block">
+          取消订单
+        </Text>
+
+        <Text className="text-sm text-gray-600 mb-4 block">
+          请选择或填写取消原因:
+        </Text>
+
+        {/* 预定义原因选项 */}
+        <View className="space-y-2 mb-4">
+          {CANCEL_REASONS.map((reasonText) => (
+            <View
+              key={reasonText}
+              className={`px-3 py-2 rounded border cursor-pointer ${
+                selectedReason === reasonText
+                  ? 'border-primary bg-primary bg-opacity-10'
+                  : 'border-gray-300 bg-white'
+              }`}
+              onClick={() => handleReasonSelect(reasonText)}
+            >
+              <Text
+                className={`text-sm ${
+                  selectedReason === reasonText
+                    ? 'text-primary'
+                    : 'text-gray-700'
+                }`}
+              >
+                {reasonText}
+              </Text>
+            </View>
+          ))}
+        </View>
+
+        {/* 自定义原因输入 */}
+        <View className="mb-4">
+          <Input
+            value={reason}
+            onInput={(e) => handleCustomReasonChange(e.detail.value)}
+            placeholder="请输入其他取消原因..."
+            className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
+            maxlength={500}
+            showCount
+          />
+        </View>
+
+        {/* 按钮区域 */}
+        <View className="flex space-x-3">
+          <Button
+            className="flex-1 py-2 bg-gray-100 text-gray-700 rounded text-sm"
+            onClick={handleCancel}
+            disabled={loading}
+          >
+            取消
+          </Button>
+          <Button
+            className="flex-1 py-2 bg-primary text-white rounded text-sm"
+            onClick={handleConfirm}
+            disabled={loading}
+          >
+            {loading ? '提交中...' : '确认取消'}
+          </Button>
+        </View>
+      </View>
+    </View>
+  )
+}

+ 127 - 21
mini/src/components/order/OrderButtonBar/index.tsx

@@ -1,5 +1,6 @@
 import { View, Text } from '@tarojs/components'
 import Taro from '@tarojs/taro'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
 import { InferResponseType } from 'hono'
 import { orderClient } from '@/api'
 
@@ -9,6 +10,7 @@ type Order = OrderResponse['data'][0]
 interface OrderButtonBarProps {
   order: Order
   onViewDetail: (order: Order) => void
+  onCancelOrder?: () => void
 }
 
 interface ActionButton {
@@ -17,27 +19,129 @@ interface ActionButton {
   onClick: () => void
 }
 
-export default function OrderButtonBar({ order, onViewDetail }: OrderButtonBarProps) {
+export default function OrderButtonBar({ order, onViewDetail, onCancelOrder }: OrderButtonBarProps) {
+  const queryClient = useQueryClient()
+
+  // 取消订单mutation
+  const cancelOrderMutation = useMutation({
+    mutationFn: async ({ orderId, reason }: { orderId: number; reason: string }) => {
+      const response = await orderClient.cancelOrder.$post({
+        json: {
+          orderId,
+          reason
+        }
+      })
+      if (response.status !== 200) {
+        throw new Error('取消订单失败')
+      }
+      return response.json()
+    },
+    onSuccess: (data) => {
+      // 取消成功后刷新订单列表数据
+      queryClient.invalidateQueries({ queryKey: ['orders'] })
+      queryClient.invalidateQueries({ queryKey: ['order', order.id] })
+
+      // 显示取消成功信息
+      Taro.showToast({
+        title: '订单取消成功',
+        icon: 'success',
+        duration: 2000
+      })
+
+      // 如果订单已支付,显示退款流程信息
+      if (order.payState === 2) {
+        setTimeout(() => {
+          Taro.showModal({
+            title: '退款处理中',
+            content: '您的退款申请已提交,退款金额将在1-3个工作日内原路退回。',
+            showCancel: false,
+            confirmText: '知道了'
+          })
+        }, 1500)
+      }
+    },
+    onError: (error) => {
+      // 根据错误消息类型显示不同的用户友好提示
+      let errorMessage = '取消失败,请稍后重试'
+
+      if (error.message.includes('订单不存在')) {
+        errorMessage = '订单不存在或已被删除'
+      } else if (error.message.includes('订单状态不允许取消')) {
+        errorMessage = '当前订单状态不允许取消'
+      } else if (error.message.includes('网络')) {
+        errorMessage = '网络连接失败,请检查网络后重试'
+      }
+
+      Taro.showToast({
+        title: errorMessage,
+        icon: 'error',
+        duration: 3000
+      })
+    }
+  })
+
   // 取消订单
   const handleCancelOrder = () => {
-    Taro.showModal({
-      title: '取消订单',
-      content: '确定要取消这个订单吗?',
-      success: async (res) => {
-        if (res.confirm) {
-          try {
-            // 这里调用取消订单的API
-            Taro.showToast({
-              title: '订单已取消',
-              icon: 'success'
-            })
-          } catch (error) {
-            Taro.showToast({
-              title: '取消失败',
-              icon: 'error'
-            })
-          }
+    // 检查网络连接
+    Taro.getNetworkType({
+      success: (res) => {
+        if (res.networkType === 'none') {
+          Taro.showToast({
+            title: '网络连接失败,请检查网络后重试',
+            icon: 'error',
+            duration: 3000
+          })
+          return
         }
+
+        if (onCancelOrder) {
+          // 使用外部提供的取消订单处理函数
+          onCancelOrder()
+        } else {
+          // 使用组件内部的取消订单处理
+          // 显示取消原因输入对话框
+          Taro.showModal({
+            title: '取消订单',
+            content: '请填写取消原因:',
+            editable: true,
+            placeholderText: '请输入取消原因(必填)',
+            success: async (res) => {
+              if (res.confirm) {
+                const reason = res.content.trim()
+                if (!reason) {
+                  Taro.showToast({
+                    title: '请填写取消原因',
+                    icon: 'error',
+                    duration: 2000
+                  })
+                  return
+                }
+
+                // 显示确认对话框
+                Taro.showModal({
+                  title: '确认取消',
+                  content: `确定要取消订单吗?\n取消原因:${reason}`,
+                  success: (confirmRes) => {
+                    if (confirmRes.confirm) {
+                      // 调用取消订单API
+                      cancelOrderMutation.mutate({
+                        orderId: order.id,
+                        reason
+                      })
+                    }
+                  }
+                })
+              }
+            }
+          })
+        }
+      },
+      fail: () => {
+        Taro.showToast({
+          title: '网络状态检查失败',
+          icon: 'error',
+          duration: 3000
+        })
       }
     })
   }
@@ -174,10 +278,12 @@ export default function OrderButtonBar({ order, onViewDetail }: OrderButtonBarPr
             button.type === 'primary'
               ? 'bg-primary text-white border-primary'
               : 'bg-white text-gray-600 border-gray-300'
-          }`}
-          onClick={button.onClick}
+          } ${cancelOrderMutation.isPending && button.text === '取消订单' ? 'opacity-50' : ''}`}
+          onClick={cancelOrderMutation.isPending && button.text === '取消订单' ? undefined : button.onClick}
         >
-          <Text>{button.text}</Text>
+          <Text>
+            {cancelOrderMutation.isPending && button.text === '取消订单' ? '取消中...' : button.text}
+          </Text>
         </View>
       ))}
     </View>

+ 70 - 1
mini/src/pages/order-detail/index.tsx

@@ -16,6 +16,9 @@ export default function OrderDetailPage() {
   const orderId = params?.id ? parseInt(params.id) : 0
   const queryClient = useQueryClient()
 
+  // 取消原因对话框状态
+  const [showCancelDialog, setShowCancelDialog] = useState(false)
+
   const { data: order, isLoading } = useQuery({
     queryKey: ['order', orderId],
     queryFn: async () => {
@@ -69,14 +72,71 @@ export default function OrderDetailPage() {
       }
     },
     onError: (error) => {
+      // 根据错误消息类型显示不同的用户友好提示
+      let errorMessage = '取消失败,请稍后重试'
+
+      if (error.message.includes('订单不存在')) {
+        errorMessage = '订单不存在或已被删除'
+      } else if (error.message.includes('订单状态不允许取消')) {
+        errorMessage = '当前订单状态不允许取消'
+      } else if (error.message.includes('网络')) {
+        errorMessage = '网络连接失败,请检查网络后重试'
+      }
+
       Taro.showToast({
-        title: error.message,
+        title: errorMessage,
         icon: 'error',
         duration: 3000
       })
     }
   })
 
+  // 触发取消订单
+  const handleCancelOrder = () => {
+    // 检查网络连接
+    Taro.getNetworkType({
+      success: (res) => {
+        if (res.networkType === 'none') {
+          Taro.showToast({
+            title: '网络连接失败,请检查网络后重试',
+            icon: 'error',
+            duration: 3000
+          })
+          return
+        }
+        setShowCancelDialog(true)
+      },
+      fail: () => {
+        Taro.showToast({
+          title: '网络状态检查失败',
+          icon: 'error',
+          duration: 3000
+        })
+      }
+    })
+  }
+
+  // 处理取消订单确认
+  const handleCancelConfirm = (reason: string) => {
+    // 显示确认对话框
+    Taro.showModal({
+      title: '确认取消',
+      content: `确定要取消订单吗?\n取消原因:${reason}`,
+      success: (confirmRes) => {
+        if (confirmRes.confirm) {
+          // 调用取消订单API
+          cancelOrderMutation.mutate(reason)
+          setShowCancelDialog(false)
+        }
+      }
+    })
+  }
+
+  // 处理取消订单取消
+  const handleCancelDialogClose = () => {
+    setShowCancelDialog(false)
+  }
+
   // 解析商品详情
   const parseGoodsDetail = (goodsDetail: string | null) => {
     try {
@@ -265,8 +325,17 @@ export default function OrderDetailPage() {
         <OrderButtonBar
           order={order}
           onViewDetail={() => {}}
+          onCancelOrder={handleCancelOrder}
         />
       </View>
+
+      {/* 取消原因对话框 */}
+      <CancelReasonDialog
+        visible={showCancelDialog}
+        onCancel={handleCancelDialogClose}
+        onConfirm={handleCancelConfirm}
+        loading={cancelOrderMutation.isPending}
+      />
     </View>
   )
 }

+ 4 - 1
mini/tests/__mocks__/taroMock.ts

@@ -21,6 +21,7 @@ export const mockUseShareAppMessage = jest.fn()
 export const mockUseShareTimeline = jest.fn()
 export const mockGetCurrentInstance = jest.fn()
 export const mockGetCurrentPages = jest.fn()
+export const mockGetNetworkType = jest.fn()
 
 // 环境类型常量
 export const ENV_TYPE = {
@@ -68,6 +69,7 @@ export default {
     left: 227
   }),
   getEnv: mockGetEnv,
+  getNetworkType: mockGetNetworkType,
 
   // 分享相关
   useShareAppMessage: mockUseShareAppMessage,
@@ -99,5 +101,6 @@ export {
   mockUseShareAppMessage as useShareAppMessage,
   mockUseShareTimeline as useShareTimeline,
   mockGetCurrentInstance as getCurrentInstance,
-  mockGetCurrentPages as getCurrentPages
+  mockGetCurrentPages as getCurrentPages,
+  mockGetNetworkType as getNetworkType
 }

+ 2 - 1
mini/tests/setup.ts

@@ -429,4 +429,5 @@ jest.mock('@/components/ui/dialog', () => {
     DialogTitle: ({ children, className }: any) => React.createElement('div', { className }, children),
     DialogFooter: ({ children, className }: any) => React.createElement('div', { className }, children)
   }
-})
+})
+

+ 165 - 0
mini/tests/unit/components/common/CancelReasonDialog.test.tsx

@@ -0,0 +1,165 @@
+import { render, fireEvent } from '@testing-library/react'
+import Taro from '@tarojs/taro'
+import CancelReasonDialog from '@/components/common/CancelReasonDialog'
+
+// Mock Taro API
+jest.mock('@tarojs/taro', () => ({
+  showToast: jest.fn()
+}))
+
+describe('CancelReasonDialog', () => {
+  const defaultProps = {
+    visible: true,
+    onCancel: jest.fn(),
+    onConfirm: jest.fn(),
+    loading: false
+  }
+
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it('should render dialog when visible is true', () => {
+    const { getByText } = render(<CancelReasonDialog {...defaultProps} />)
+
+    expect(getByText('取消订单')).toBeTruthy()
+    expect(getByText('请选择或填写取消原因:')).toBeTruthy()
+    expect(getByText('我不想买了')).toBeTruthy()
+    expect(getByText('信息填写错误,重新下单')).toBeTruthy()
+    expect(getByText('商家缺货')).toBeTruthy()
+    expect(getByText('价格不合适')).toBeTruthy()
+    expect(getByText('其他原因')).toBeTruthy()
+  })
+
+  it('should not render dialog when visible is false', () => {
+    const { queryByText } = render(
+      <CancelReasonDialog {...defaultProps} visible={false} />
+    )
+
+    expect(queryByText('取消订单')).toBeNull()
+  })
+
+  it('should select predefined reason when clicked', () => {
+    const { getByText } = render(<CancelReasonDialog {...defaultProps} />)
+
+    const reasonOption = getByText('我不想买了')
+    fireEvent.click(reasonOption)
+
+    // 检查样式变化(这里需要根据实际实现调整)
+    expect(reasonOption).toBeTruthy()
+  })
+
+  it('should call onConfirm with reason when confirm button is clicked', () => {
+    const { getByText } = render(<CancelReasonDialog {...defaultProps} />)
+
+    const reasonOption = getByText('我不想买了')
+    fireEvent.click(reasonOption)
+
+    const confirmButton = getByText('确认取消')
+    fireEvent.click(confirmButton)
+
+    expect(defaultProps.onConfirm).toHaveBeenCalledWith('我不想买了')
+  })
+
+  it('should call onCancel when cancel button is clicked', () => {
+    const { getByText } = render(<CancelReasonDialog {...defaultProps} />)
+
+    const cancelButton = getByText('取消')
+    fireEvent.click(cancelButton)
+
+    expect(defaultProps.onCancel).toHaveBeenCalled()
+  })
+
+  it('should show error when confirming with empty reason', () => {
+    const mockShowToast = Taro.showToast as jest.Mock
+    const { getByText } = render(<CancelReasonDialog {...defaultProps} />)
+
+    const confirmButton = getByText('确认取消')
+    fireEvent.click(confirmButton)
+
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '请填写取消原因',
+      icon: 'error',
+      duration: 2000
+    })
+    expect(defaultProps.onConfirm).not.toHaveBeenCalled()
+  })
+
+  it('should show error when reason exceeds 500 characters', () => {
+    const mockShowToast = Taro.showToast as jest.Mock
+    const { getByPlaceholderText, getByText } = render(
+      <CancelReasonDialog {...defaultProps} />
+    )
+
+    const input = getByPlaceholderText('请输入其他取消原因...')
+    fireEvent.input(input, { target: { value: 'a'.repeat(501) } })
+
+    const confirmButton = getByText('确认取消')
+    fireEvent.click(confirmButton)
+
+    expect(mockShowToast).toHaveBeenCalledWith({
+      title: '取消原因不能超过500字',
+      icon: 'error',
+      duration: 2000
+    })
+    expect(defaultProps.onConfirm).not.toHaveBeenCalled()
+  })
+
+  it('should handle custom reason input', () => {
+    const { getByPlaceholderText, getByText } = render(
+      <CancelReasonDialog {...defaultProps} />
+    )
+
+    const input = getByPlaceholderText('请输入其他取消原因...')
+    fireEvent.input(input, { target: { value: '自定义取消原因' } })
+
+    const confirmButton = getByText('确认取消')
+    fireEvent.click(confirmButton)
+
+    expect(defaultProps.onConfirm).toHaveBeenCalledWith('自定义取消原因')
+  })
+
+  it('should show loading state when loading is true', () => {
+    const { getByText } = render(
+      <CancelReasonDialog {...defaultProps} loading={true} />
+    )
+
+    expect(getByText('提交中...')).toBeTruthy()
+  })
+
+  it('should disable buttons when loading is true', () => {
+    const { getByText } = render(
+      <CancelReasonDialog {...defaultProps} loading={true} />
+    )
+
+    const cancelButton = getByText('取消')
+    const confirmButton = getByText('提交中...')
+
+    // 检查按钮是否被禁用(这里需要根据实际实现调整)
+    expect(cancelButton).toBeTruthy()
+    expect(confirmButton).toBeTruthy()
+  })
+
+  it('should reset state when dialog is closed', () => {
+    const { getByText, rerender } = render(
+      <CancelReasonDialog {...defaultProps} />
+    )
+
+    const reasonOption = getByText('我不想买了')
+    fireEvent.click(reasonOption)
+
+    // 重新渲染关闭的对话框
+    rerender(<CancelReasonDialog {...defaultProps} visible={false} />)
+    rerender(<CancelReasonDialog {...defaultProps} visible={true} />)
+
+    // 检查状态是否重置
+    const confirmButton = getByText('确认取消')
+    fireEvent.click(confirmButton)
+
+    expect(Taro.showToast).toHaveBeenCalledWith({
+      title: '请填写取消原因',
+      icon: 'error',
+      duration: 2000
+    })
+  })
+})

+ 242 - 0
mini/tests/unit/components/order/OrderButtonBar.test.tsx

@@ -0,0 +1,242 @@
+import { render, fireEvent, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { mockShowModal, mockShowToast, mockGetNetworkType } from '~/__mocks__/taroMock'
+import OrderButtonBar from '@/components/order/OrderButtonBar'
+
+// Mock specific React Query hooks
+jest.mock('@tanstack/react-query', () => {
+  const originalModule = jest.requireActual('@tanstack/react-query')
+
+  return {
+    ...originalModule,
+    useMutation: jest.fn(() => ({
+      mutate: jest.fn(),
+      mutateAsync: jest.fn(),
+      isLoading: false,
+      isError: false,
+      isSuccess: false,
+      error: null,
+      data: null
+    })),
+    useQueryClient: jest.fn(() => ({
+      invalidateQueries: jest.fn()
+    }))
+  }
+})
+
+// Mock API client
+jest.mock('@/api', () => ({
+  orderClient: {
+    cancelOrder: {
+      $post: jest.fn()
+    }
+  }
+}))
+
+const mockOrder = {
+  id: 1,
+  orderNo: 'ORDER001',
+  payState: 0, // 未支付
+  state: 0, // 未发货
+  amount: 100,
+  payAmount: 100,
+  freightAmount: 0,
+  discountAmount: 0,
+  goodsDetail: JSON.stringify([
+    { id: 1, name: '商品1', price: 50, num: 2, image: '', spec: '默认规格' }
+  ]),
+  recevierName: '张三',
+  receiverMobile: '13800138000',
+  address: '北京市朝阳区',
+  createdAt: '2025-01-01T00:00:00Z'
+}
+
+const createTestQueryClient = () => new QueryClient({
+  defaultOptions: {
+    queries: { retry: false },
+    mutations: { retry: false }
+  }
+})
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+  <QueryClientProvider client={createTestQueryClient()}>
+    {children}
+  </QueryClientProvider>
+)
+
+describe('OrderButtonBar', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+  })
+
+  it('should render cancel button for unpaid order', () => {
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    expect(getByText('取消订单')).toBeTruthy()
+    expect(getByText('去支付')).toBeTruthy()
+    expect(getByText('查看详情')).toBeTruthy()
+  })
+
+  it('should show cancel reason dialog when cancel button is clicked', async () => {
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+    mockShowModal.mockResolvedValue({ confirm: true, content: '测试取消原因' })
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '取消订单',
+        content: '请填写取消原因:',
+        editable: true,
+        placeholderText: '请输入取消原因(必填)'
+      })
+    })
+  })
+
+  it('should call API when cancel order is confirmed', async () => {
+    const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+    mockShowModal
+      .mockResolvedValueOnce({ confirm: true, content: '测试取消原因' }) // 原因输入
+      .mockResolvedValueOnce({ confirm: true }) // 确认取消
+
+    mockApiCall.mockResolvedValue({ status: 200, json: () => Promise.resolve({ success: true, message: '取消成功' }) })
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockApiCall).toHaveBeenCalledWith({
+        json: {
+          orderId: 1,
+          reason: '测试取消原因'
+        }
+      })
+    })
+  })
+
+  it('should show error when cancel reason is empty', async () => {
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+    mockShowModal.mockResolvedValue({ confirm: true, content: '' })
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '请填写取消原因',
+        icon: 'error',
+        duration: 2000
+      })
+    })
+  })
+
+  it('should handle network error gracefully', async () => {
+    const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+    mockShowModal
+      .mockResolvedValueOnce({ confirm: true, content: '测试取消原因' })
+      .mockResolvedValueOnce({ confirm: true })
+
+    mockApiCall.mockRejectedValue(new Error('网络连接失败'))
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '网络连接失败,请检查网络后重试',
+        icon: 'error',
+        duration: 3000
+      })
+    })
+  })
+
+  it('should disable cancel button during mutation', async () => {
+    const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+    mockShowModal
+      .mockResolvedValueOnce({ confirm: true, content: '测试取消原因' })
+      .mockResolvedValueOnce({ confirm: true })
+
+    // 模拟API调用延迟
+    mockApiCall.mockImplementation(() => new Promise(resolve => {
+      setTimeout(() => resolve({ status: 200, json: () => Promise.resolve({ success: true }) }), 100)
+    }))
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(getByText('取消中...')).toBeTruthy()
+    })
+  })
+
+  it('should not show cancel button for shipped order', () => {
+    const shippedOrder = { ...mockOrder, state: 1 } // 已发货
+
+    const { queryByText } = render(
+      <TestWrapper>
+        <OrderButtonBar order={shippedOrder} onViewDetail={jest.fn()} />
+      </TestWrapper>
+    )
+
+    expect(queryByText('取消订单')).toBeNull()
+    expect(queryByText('确认收货')).toBeTruthy()
+  })
+
+  it('should use external cancel handler when provided', async () => {
+    const mockOnCancelOrder = jest.fn()
+    mockGetNetworkType.mockResolvedValue({ networkType: 'wifi' })
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderButtonBar
+          order={mockOrder}
+          onViewDetail={jest.fn()}
+          onCancelOrder={mockOnCancelOrder}
+        />
+      </TestWrapper>
+    )
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockOnCancelOrder).toHaveBeenCalled()
+    })
+  })
+})

+ 334 - 0
mini/tests/unit/pages/order-detail/order-detail.test.tsx

@@ -0,0 +1,334 @@
+import { render, fireEvent, waitFor } from '@testing-library/react'
+import Taro from '@tarojs/taro'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import OrderDetailPage from '@/pages/order-detail/index'
+
+// Mock Taro API
+jest.mock('@tarojs/taro', () => ({
+  getCurrentInstance: jest.fn(),
+  showModal: jest.fn(),
+  showToast: jest.fn(),
+  getNetworkType: jest.fn(),
+  navigateBack: jest.fn(),
+  setClipboardData: jest.fn()
+}))
+
+// Mock API client
+jest.mock('@/api', () => ({
+  orderClient: {
+    ':id': {
+      $get: jest.fn()
+    },
+    cancelOrder: {
+      $post: jest.fn()
+    }
+  }
+}))
+
+const mockOrder = {
+  id: 1,
+  orderNo: 'ORDER001',
+  payState: 0, // 未支付
+  state: 0, // 未发货
+  amount: 100,
+  payAmount: 100,
+  freightAmount: 0,
+  discountAmount: 0,
+  goodsDetail: JSON.stringify([
+    { id: 1, name: '商品1', price: 50, num: 2, image: '', spec: '默认规格' }
+  ]),
+  recevierName: '张三',
+  receiverMobile: '13800138000',
+  address: '北京市朝阳区',
+  createdAt: '2025-01-01T00:00:00Z'
+}
+
+const createTestQueryClient = () => new QueryClient({
+  defaultOptions: {
+    queries: { retry: false },
+    mutations: { retry: false }
+  }
+})
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+  <QueryClientProvider client={createTestQueryClient()}>
+    {children}
+  </QueryClientProvider>
+)
+
+describe('OrderDetailPage', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    ;(Taro.getCurrentInstance as jest.Mock).mockReturnValue({
+      router: { params: { id: '1' } }
+    })
+    ;(Taro.getNetworkType as jest.Mock).mockResolvedValue({ networkType: 'wifi' })
+  })
+
+  it('should render order details when data is loaded', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    const { findByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    expect(await findByText('订单详情')).toBeTruthy()
+    expect(await findByText('待付款')).toBeTruthy()
+    expect(await findByText('请尽快完成支付')).toBeTruthy()
+    expect(await findByText('商品1')).toBeTruthy()
+    expect(await findByText('ORDER001')).toBeTruthy()
+  })
+
+  it('should show loading state when fetching order', () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    mockGetOrder.mockImplementation(() => new Promise(() => {})) // 永不resolve
+
+    const { getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    // 检查加载状态
+    expect(getByText('订单详情')).toBeTruthy()
+  })
+
+  it('should show error when order not found', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    mockGetOrder.mockRejectedValue(new Error('订单不存在'))
+
+    const { findByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    expect(await findByText('订单不存在')).toBeTruthy()
+  })
+
+  it('should show cancel dialog when cancel button is clicked', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(getByText('取消订单')).toBeTruthy() // 对话框标题
+      expect(getByText('请选择或填写取消原因:')).toBeTruthy()
+    })
+  })
+
+  it('should call cancel API when cancel is confirmed', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    const mockCancelOrder = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+    const mockShowModal = Taro.showModal as jest.Mock
+
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    mockCancelOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve({ success: true, message: '取消成功' })
+    })
+
+    mockShowModal.mockResolvedValue({ confirm: true })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    // 打开取消对话框
+    fireEvent.click(getByText('取消订单'))
+
+    // 选择取消原因并确认
+    await waitFor(() => {
+      const reasonOption = getByText('我不想买了')
+      fireEvent.click(reasonOption)
+
+      const confirmButton = getByText('确认取消')
+      fireEvent.click(confirmButton)
+    })
+
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '确认取消',
+        content: expect.stringContaining('我不想买了'),
+        success: expect.any(Function)
+      })
+    })
+  })
+
+  it('should show success message when cancel succeeds', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    const mockCancelOrder = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+    const mockShowToast = Taro.showToast as jest.Mock
+    const mockShowModal = Taro.showModal as jest.Mock
+
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    mockCancelOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve({ success: true, message: '取消成功' })
+    })
+
+    mockShowModal.mockResolvedValue({ confirm: true })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      const reasonOption = getByText('我不想买了')
+      fireEvent.click(reasonOption)
+
+      const confirmButton = getByText('确认取消')
+      fireEvent.click(confirmButton)
+    })
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '订单取消成功',
+        icon: 'success',
+        duration: 2000
+      })
+    })
+  })
+
+  it('should show error message when cancel fails', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    const mockCancelOrder = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+    const mockShowToast = Taro.showToast as jest.Mock
+    const mockShowModal = Taro.showModal as jest.Mock
+
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    mockCancelOrder.mockRejectedValue(new Error('订单状态不允许取消'))
+    mockShowModal.mockResolvedValue({ confirm: true })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      const reasonOption = getByText('我不想买了')
+      fireEvent.click(reasonOption)
+
+      const confirmButton = getByText('确认取消')
+      fireEvent.click(confirmButton)
+    })
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '当前订单状态不允许取消',
+        icon: 'error',
+        duration: 3000
+      })
+    })
+  })
+
+  it('should copy order number when copy button is clicked', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    const mockSetClipboardData = Taro.setClipboardData as jest.Mock
+    const mockShowToast = Taro.showToast as jest.Mock
+
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    mockSetClipboardData.mockResolvedValue({ success: true })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    const copyButton = getByText('复制')
+    fireEvent.click(copyButton)
+
+    await waitFor(() => {
+      expect(mockSetClipboardData).toHaveBeenCalledWith({
+        data: 'ORDER001'
+      })
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '订单号已复制',
+        icon: 'success'
+      })
+    })
+  })
+
+  it('should check network before showing cancel dialog', async () => {
+    const mockGetOrder = require('@/api').orderClient[':id'].$get as jest.Mock
+    const mockShowToast = Taro.showToast as jest.Mock
+
+    mockGetOrder.mockResolvedValue({
+      status: 200,
+      json: () => Promise.resolve(mockOrder)
+    })
+
+    // 模拟无网络
+    ;(Taro.getNetworkType as jest.Mock).mockResolvedValue({ networkType: 'none' })
+
+    const { findByText, getByText } = render(
+      <TestWrapper>
+        <OrderDetailPage />
+      </TestWrapper>
+    )
+
+    await findByText('订单详情')
+
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith({
+        title: '网络连接失败,请检查网络后重试',
+        icon: 'error',
+        duration: 3000
+      })
+    })
+  })
+})

+ 12 - 0
mini/tests/unit/pages/order-list/basic.test.tsx

@@ -40,6 +40,18 @@ jest.mock('@tanstack/react-query', () => ({
     fetchNextPage: jest.fn(),
     hasNextPage: false,
     refetch: jest.fn()
+  })),
+  useMutation: jest.fn(() => ({
+    mutate: jest.fn(),
+    mutateAsync: jest.fn(),
+    isLoading: false,
+    isError: false,
+    isSuccess: false,
+    error: null,
+    data: null
+  })),
+  useQueryClient: jest.fn(() => ({
+    invalidateQueries: jest.fn()
   }))
 }))