2
0
Эх сурвалжийг харах

✨ feat(cancel-order): 实现取消订单原因对话框组件

- 创建独立的CancelReasonDialog组件,支持预定义原因选择和自定义输入
- 添加原因长度验证(5-200字符)和错误提示功能
- 使用UI组件库重构界面,提升视觉一致性和用户体验

♻️ refactor(order-button): 重构取消订单流程

- 将取消订单逻辑从OrderButtonBar迁移到专用对话框组件
- 优化状态管理,使用useState管理对话框显示状态
- 添加网络检查和加载状态处理

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

- 为CancelReasonDialog组件添加单元测试
- 为OrderButtonBar组件添加取消订单流程测试
- 模拟UI组件和API调用,确保测试稳定性

📝 docs(dialog): 添加DialogDescription组件

- 扩展Dialog组件,增加描述文本支持
- 更新相关组件文档注释

🔧 chore(mocks): 添加UI组件测试模拟

- 为Button、Input、Label等UI组件添加测试模拟
- 优化测试环境配置,提升测试可靠性
yourname 1 сар өмнө
parent
commit
62eba8ef05

+ 96 - 81
mini/src/components/common/CancelReasonDialog/index.tsx

@@ -1,10 +1,19 @@
-import { View, Text, Input, Button } from '@tarojs/components'
-import Taro from '@tarojs/taro'
 import { useState } from 'react'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
 
 interface CancelReasonDialogProps {
-  visible: boolean
-  onCancel: () => void
+  open: boolean
+  onOpenChange: (open: boolean) => void
   onConfirm: (reason: string) => void
   loading?: boolean
 }
@@ -19,18 +28,20 @@ const CANCEL_REASONS = [
 ]
 
 export default function CancelReasonDialog({
-  visible,
-  onCancel,
+  open,
+  onOpenChange,
   onConfirm,
   loading = false
 }: CancelReasonDialogProps) {
   const [reason, setReason] = useState('')
   const [selectedReason, setSelectedReason] = useState('')
+  const [error, setError] = useState('')
 
   // 处理原因选择
   const handleReasonSelect = (reasonText: string) => {
     setSelectedReason(reasonText)
     setReason(reasonText)
+    if (error) setError('')
   }
 
   // 处理自定义原因输入
@@ -39,113 +50,117 @@ export default function CancelReasonDialog({
     if (value && !CANCEL_REASONS.includes(value)) {
       setSelectedReason('')
     }
+    if (error) setError('')
   }
 
   // 确认取消
   const handleConfirm = () => {
-    if (!reason.trim()) {
-      Taro.showToast({
-        title: '请填写取消原因',
-        icon: 'error',
-        duration: 2000
-      })
+    const trimmedReason = reason.trim()
+
+    if (!trimmedReason) {
+      setError('请输入取消原因')
       return
     }
 
-    if (reason.trim().length > 500) {
-      Taro.showToast({
-        title: '取消原因不能超过500字',
-        icon: 'error',
-        duration: 2000
-      })
+    if (trimmedReason.length < 5) {
+      setError('取消原因至少需要5个字符')
       return
     }
 
-    onConfirm(reason.trim())
+    if (trimmedReason.length > 200) {
+      setError('取消原因不能超过200个字符')
+      return
+    }
+
+    setError('')
+    onConfirm(trimmedReason)
   }
 
   // 重置对话框状态
   const handleReset = () => {
     setReason('')
     setSelectedReason('')
+    setError('')
   }
 
   // 处理取消
   const handleCancel = () => {
     handleReset()
-    onCancel()
+    onOpenChange(false)
   }
 
-  if (!visible) {
-    return null
+  const handleOpenChange = (newOpen: boolean) => {
+    if (!newOpen) {
+      handleReset()
+    }
+    onOpenChange(newOpen)
   }
 
   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 ${
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-[425px]">
+        <DialogHeader>
+          <DialogTitle>取消订单</DialogTitle>
+          <DialogDescription>
+            请选择或填写取消原因,这将帮助我们改进服务
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="grid gap-4 py-4">
+          {/* 预定义原因选项 */}
+          <div className="space-y-2">
+            {CANCEL_REASONS.map((reasonText) => (
+              <div
+                key={reasonText}
+                className={`px-3 py-2 rounded border cursor-pointer transition-colors ${
                   selectedReason === reasonText
-                    ? 'text-primary'
-                    : 'text-gray-700'
+                    ? 'border-primary bg-primary/10'
+                    : 'border-gray-300 bg-white hover:bg-gray-50'
                 }`}
+                onClick={() => handleReasonSelect(reasonText)}
               >
-                {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}
-          >
+                <span
+                  className={`text-sm ${
+                    selectedReason === reasonText
+                      ? 'text-primary'
+                      : 'text-gray-700'
+                  }`}
+                >
+                  {reasonText}
+                </span>
+              </div>
+            ))}
+          </div>
+
+          {/* 自定义原因输入 */}
+          <div className="grid grid-cols-4 items-center gap-4">
+            <Label htmlFor="reason" className="text-right">
+              其他原因
+            </Label>
+            <div className="col-span-3">
+              <Input
+                id="reason"
+                placeholder="请输入其他取消原因..."
+                value={reason}
+                onChange={(e) => handleCustomReasonChange(e.target.value)}
+                className={error ? 'border-destructive' : ''}
+              />
+              {error && (
+                <p className="text-sm text-destructive mt-1">{error}</p>
+              )}
+            </div>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={handleCancel} disabled={loading}>
             取消
           </Button>
-          <Button
-            className="flex-1 py-2 bg-primary text-white rounded text-sm"
-            onClick={handleConfirm}
-            disabled={loading}
-          >
+          <Button onClick={handleConfirm} disabled={loading}>
             {loading ? '提交中...' : '确认取消'}
           </Button>
-        </View>
-      </View>
-    </View>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
   )
 }

+ 48 - 52
mini/src/components/order/OrderButtonBar/index.tsx

@@ -3,6 +3,8 @@ import Taro from '@tarojs/taro'
 import { useMutation, useQueryClient } from '@tanstack/react-query'
 import { InferResponseType } from 'hono'
 import { orderClient } from '@/api'
+import { useState } from 'react'
+import CancelReasonDialog from '@/components/common/CancelReasonDialog'
 
 type OrderResponse = InferResponseType<typeof orderClient.$get, 200>
 type Order = OrderResponse['data'][0]
@@ -21,6 +23,7 @@ interface ActionButton {
 
 export default function OrderButtonBar({ order, onViewDetail, onCancelOrder }: OrderButtonBarProps) {
   const queryClient = useQueryClient()
+  const [showCancelDialog, setShowCancelDialog] = useState(false)
 
   // 取消订单mutation
   const cancelOrderMutation = useMutation({
@@ -80,6 +83,24 @@ export default function OrderButtonBar({ order, onViewDetail, onCancelOrder }: O
     }
   })
 
+  // 处理取消原因确认
+  const handleCancelReasonConfirm = (reason: string) => {
+    // 显示确认对话框
+    Taro.showModal({
+      title: '确认取消',
+      content: `确定要取消订单吗?\n取消原因:${reason}`,
+      success: (confirmRes) => {
+        if (confirmRes.confirm) {
+          // 调用取消订单API
+          cancelOrderMutation.mutate({
+            orderId: order.id,
+            reason
+          })
+        }
+      }
+    })
+  }
+
   // 取消订单
   const handleCancelOrder = () => {
     // 检查网络连接
@@ -99,41 +120,7 @@ export default function OrderButtonBar({ order, onViewDetail, onCancelOrder }: O
           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
-                      })
-                    }
-                  }
-                })
-              }
-            }
-          })
+          setShowCancelDialog(true)
         }
       },
       fail: () => {
@@ -270,22 +257,31 @@ export default function OrderButtonBar({ order, onViewDetail, onCancelOrder }: O
   const actionButtons = getActionButtons(order)
 
   return (
-    <View className="flex justify-end space-x-2">
-      {actionButtons.map((button, index) => (
-        <View
-          key={index}
-          className={`px-4 py-2 rounded-full text-sm font-medium border ${
-            button.type === 'primary'
-              ? 'bg-primary text-white border-primary'
-              : 'bg-white text-gray-600 border-gray-300'
-          } ${cancelOrderMutation.isPending && button.text === '取消订单' ? 'opacity-50' : ''}`}
-          onClick={cancelOrderMutation.isPending && button.text === '取消订单' ? undefined : button.onClick}
-        >
-          <Text>
-            {cancelOrderMutation.isPending && button.text === '取消订单' ? '取消中...' : button.text}
-          </Text>
-        </View>
-      ))}
-    </View>
+    <>
+      <View className="flex justify-end space-x-2">
+        {actionButtons.map((button, index) => (
+          <View
+            key={index}
+            className={`px-4 py-2 rounded-full text-sm font-medium border ${
+              button.type === 'primary'
+                ? 'bg-primary text-white border-primary'
+                : 'bg-white text-gray-600 border-gray-300'
+            } ${cancelOrderMutation.isPending && button.text === '取消订单' ? 'opacity-50' : ''}`}
+            onClick={cancelOrderMutation.isPending && button.text === '取消订单' ? undefined : button.onClick}
+          >
+            <Text>
+              {cancelOrderMutation.isPending && button.text === '取消订单' ? '取消中...' : button.text}
+            </Text>
+          </View>
+        ))}
+      </View>
+
+      <CancelReasonDialog
+        open={showCancelDialog}
+        onOpenChange={setShowCancelDialog}
+        onConfirm={handleCancelReasonConfirm}
+        loading={cancelOrderMutation.isPending}
+      />
+    </>
   )
 }

+ 13 - 0
mini/src/components/ui/dialog.tsx

@@ -81,6 +81,19 @@ export function DialogTitle({ className, children }: DialogTitleProps) {
   )
 }
 
+interface DialogDescriptionProps {
+  className?: string
+  children: React.ReactNode
+}
+
+export function DialogDescription({ className, children }: DialogDescriptionProps) {
+  return (
+    <Text className={cn("text-sm text-gray-600", className)}>
+      {children}
+    </Text>
+  )
+}
+
 interface DialogFooterProps {
   className?: string
   children: React.ReactNode

+ 96 - 0
mini/tests/setup.ts

@@ -427,7 +427,103 @@ jest.mock('@/components/ui/dialog', () => {
     DialogContent: ({ children, className }: any) => React.createElement('div', { className }, children),
     DialogHeader: ({ children, className }: any) => React.createElement('div', { className }, children),
     DialogTitle: ({ children, className }: any) => React.createElement('div', { className }, children),
+    DialogDescription: ({ children, className }: any) => React.createElement('div', { className }, children),
     DialogFooter: ({ children, className }: any) => React.createElement('div', { className }, children)
   }
 })
 
+// Mock Button 组件
+jest.mock('@/components/ui/button', () => {
+  const React = require('react')
+  const MockButton = React.forwardRef(({ children, onClick, disabled, className, ...props }: any, ref: any) => {
+    return React.createElement('button', {
+      onClick,
+      disabled,
+      className,
+      ref,
+      ...props
+    }, children)
+  })
+  MockButton.displayName = 'MockButton'
+  return MockButton
+})
+
+// Mock Input 组件
+jest.mock('@/components/ui/input', () => {
+  const React = require('react')
+  const MockInput = React.forwardRef(({ value, onChange, placeholder, className, ...props }: any, ref: any) => {
+    return React.createElement('input', {
+      value,
+      onChange: (e: any) => onChange?.(e.target.value, e),
+      placeholder,
+      className,
+      ref,
+      ...props
+    })
+  })
+  MockInput.displayName = 'MockInput'
+  return MockInput
+})
+
+// Mock Label 组件
+jest.mock('@/components/ui/label', () => {
+  const React = require('react')
+  const MockLabel = React.forwardRef(({ children, htmlFor, className, ...props }: any, ref: any) => {
+    return React.createElement('label', {
+      htmlFor,
+      className,
+      ref,
+      ...props
+    }, children)
+  })
+  MockLabel.displayName = 'MockLabel'
+  return MockLabel
+})
+
+// Mock CancelReasonDialog 组件
+jest.mock('@/components/common/CancelReasonDialog', () => {
+  const React = require('react')
+  const MockCancelReasonDialog = ({ open, onOpenChange, onConfirm, loading }: any) => {
+    const [reason, setReason] = React.useState('')
+    const [error, setError] = React.useState('')
+
+    if (!open) return null
+
+    const handleConfirm = () => {
+      const trimmedReason = reason.trim()
+      if (!trimmedReason) {
+        setError('请输入取消原因')
+        return
+      }
+      setError('')
+      onConfirm(trimmedReason)
+    }
+
+    return React.createElement('div', { 'data-testid': 'cancel-reason-dialog' }, [
+      React.createElement('div', { key: 'title' }, '取消订单'),
+      React.createElement('div', { key: 'description' }, '请选择或填写取消原因,这将帮助我们改进服务'),
+      React.createElement('input', {
+        key: 'reason-input',
+        placeholder: '请输入其他取消原因...',
+        value: reason,
+        onChange: (e: any) => {
+          setReason(e.target.value)
+          if (error) setError('')
+        }
+      }),
+      error && React.createElement('div', { key: 'error', 'data-testid': 'error-message' }, error),
+      React.createElement('button', {
+        key: 'confirm',
+        onClick: handleConfirm,
+        disabled: loading
+      }, loading ? '提交中...' : '确认取消'),
+      React.createElement('button', {
+        key: 'cancel',
+        onClick: () => onOpenChange(false)
+      }, '取消')
+    ])
+  }
+  MockCancelReasonDialog.displayName = 'MockCancelReasonDialog'
+  return MockCancelReasonDialog
+})
+

+ 109 - 38
mini/tests/unit/components/order/OrderButtonBar.test.tsx

@@ -1,6 +1,6 @@
 import { render, fireEvent, waitFor } from '@testing-library/react'
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { mockShowModal, mockShowToast, mockGetNetworkType } from '~/__mocks__/taroMock'
+import { mockShowModal, mockShowToast, mockGetNetworkType, mockGetEnv } from '~/__mocks__/taroMock'
 import OrderButtonBar from '@/components/order/OrderButtonBar'
 
 
@@ -54,6 +54,8 @@ describe('OrderButtonBar', () => {
       }
       return Promise.resolve()
     })
+    // 模拟环境检查
+    mockGetEnv.mockReturnValue('WEB')
   })
 
   it('should render cancel button for unpaid order', () => {
@@ -69,9 +71,7 @@ describe('OrderButtonBar', () => {
   })
 
   it('should show cancel reason dialog when cancel button is clicked', async () => {
-    mockShowModal.mockResolvedValue({ confirm: true, content: '测试取消原因' })
-
-    const { getByText } = render(
+    const { getByText, getByTestId } = render(
       <TestWrapper>
         <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
       </TestWrapper>
@@ -80,32 +80,53 @@ describe('OrderButtonBar', () => {
     fireEvent.click(getByText('取消订单'))
 
     await waitFor(() => {
-      expect(mockShowModal).toHaveBeenCalledWith({
-        title: '取消订单',
-        content: '请填写取消原因:',
-        editable: true,
-        placeholderText: '请输入取消原因(必填)'
-      })
+      expect(getByTestId('cancel-reason-dialog')).toBeTruthy()
+      // 检查对话框内容
+      expect(getByText('请选择或填写取消原因,这将帮助我们改进服务')).toBeTruthy()
     })
   })
 
   it('should call API when cancel order is confirmed', async () => {
     const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
 
-    mockShowModal
-      .mockResolvedValueOnce({ confirm: true, content: '测试取消原因' }) // 原因输入
-      .mockResolvedValueOnce({ confirm: true }) // 确认取消
+    mockShowModal.mockResolvedValueOnce({ confirm: true }) // 确认取消
 
     mockApiCall.mockResolvedValue({ status: 200, json: () => Promise.resolve({ success: true, message: '取消成功' }) })
 
-    const { getByText } = render(
+    const { getByText, getByPlaceholderText, getByTestId } = render(
       <TestWrapper>
         <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
       </TestWrapper>
     )
 
+    // 打开取消对话框
     fireEvent.click(getByText('取消订单'))
 
+    await waitFor(() => {
+      expect(getByTestId('cancel-reason-dialog')).toBeTruthy()
+    })
+
+    // 输入取消原因
+    const reasonInput = getByPlaceholderText('请输入其他取消原因...')
+    fireEvent.change(reasonInput, { target: { value: '测试取消原因' } })
+
+    // 点击确认取消按钮
+    fireEvent.click(getByText('确认取消'))
+
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '确认取消',
+        content: '确定要取消订单吗?\n取消原因:测试取消原因',
+        success: expect.any(Function)
+      })
+    })
+
+    // 模拟确认对话框确认
+    const modalCall = mockShowModal.mock.calls[0][0]
+    if (modalCall.success) {
+      modalCall.success({ confirm: true })
+    }
+
     await waitFor(() => {
       expect(mockApiCall).toHaveBeenCalledWith({
         json: {
@@ -117,42 +138,69 @@ describe('OrderButtonBar', () => {
   })
 
   it('should show error when cancel reason is empty', async () => {
-    mockShowModal.mockResolvedValue({ confirm: true, content: '' })
-
-    const { getByText } = render(
+    const { getByText, getByTestId } = render(
       <TestWrapper>
         <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
       </TestWrapper>
     )
 
+    // 打开取消对话框
     fireEvent.click(getByText('取消订单'))
 
     await waitFor(() => {
-      expect(mockShowToast).toHaveBeenCalledWith({
-        title: '请填写取消原因',
-        icon: 'error',
-        duration: 2000
-      })
+      expect(getByTestId('cancel-reason-dialog')).toBeTruthy()
+    })
+
+    // 直接点击确认取消按钮(不输入原因)
+    fireEvent.click(getByText('确认取消'))
+
+    await waitFor(() => {
+      expect(getByTestId('error-message')).toBeTruthy()
+      expect(getByText('请输入取消原因')).toBeTruthy()
     })
   })
 
   it('should handle network error gracefully', async () => {
     const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
 
-    mockShowModal
-      .mockResolvedValueOnce({ confirm: true, content: '测试取消原因' })
-      .mockResolvedValueOnce({ confirm: true })
+    mockShowModal.mockResolvedValueOnce({ confirm: true })
 
     mockApiCall.mockRejectedValue(new Error('网络连接失败'))
 
-    const { getByText } = render(
+    const { getByText, getByPlaceholderText, getByTestId } = render(
       <TestWrapper>
         <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
       </TestWrapper>
     )
 
+    // 打开取消对话框
     fireEvent.click(getByText('取消订单'))
 
+    await waitFor(() => {
+      expect(getByTestId('cancel-reason-dialog')).toBeTruthy()
+    })
+
+    // 输入取消原因
+    const reasonInput = getByPlaceholderText('请输入其他取消原因...')
+    fireEvent.change(reasonInput, { target: { value: '测试取消原因' } })
+
+    // 点击确认取消按钮
+    fireEvent.click(getByText('确认取消'))
+
+    // 模拟确认对话框确认
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '确认取消',
+        content: '确定要取消订单吗?\n取消原因:测试取消原因',
+        success: expect.any(Function)
+      })
+    })
+
+    const modalCall = mockShowModal.mock.calls[0][0]
+    if (modalCall.success) {
+      modalCall.success({ confirm: true })
+    }
+
     await waitFor(() => {
       expect(mockShowToast).toHaveBeenCalledWith({
         title: '网络连接失败,请检查网络后重试',
@@ -164,24 +212,47 @@ describe('OrderButtonBar', () => {
 
   it('should disable cancel button during mutation', async () => {
     // 模拟mutation正在进行中
-    // mockUseMutation.mockReturnValueOnce({
-    //   mutate: jest.fn(),
-    //   mutateAsync: jest.fn(),
-    //   isLoading: true,
-    //   isPending: true,
-    //   isError: false,
-    //   isSuccess: false,
-    //   error: null,
-    //   data: null
-    // })
+    const mockApiCall = require('@/api').orderClient.cancelOrder.$post as jest.Mock
+    mockApiCall.mockImplementation(() => new Promise(() => {})) // 永不resolve的promise
 
-    const { getByText } = render(
+    const { getByText, getByPlaceholderText, getByTestId } = render(
       <TestWrapper>
         <OrderButtonBar order={mockOrder} onViewDetail={jest.fn()} />
       </TestWrapper>
     )
 
-    expect(getByText('取消中...')).toBeTruthy()
+    // 打开取消对话框
+    fireEvent.click(getByText('取消订单'))
+
+    await waitFor(() => {
+      expect(getByTestId('cancel-reason-dialog')).toBeTruthy()
+    })
+
+    // 输入取消原因
+    const reasonInput = getByPlaceholderText('请输入其他取消原因...')
+    fireEvent.change(reasonInput, { target: { value: '测试取消原因' } })
+
+    // 点击确认取消按钮
+    fireEvent.click(getByText('确认取消'))
+
+    // 模拟确认对话框确认
+    await waitFor(() => {
+      expect(mockShowModal).toHaveBeenCalledWith({
+        title: '确认取消',
+        content: '确定要取消订单吗?\n取消原因:测试取消原因',
+        success: expect.any(Function)
+      })
+    })
+
+    const modalCall = mockShowModal.mock.calls[0][0]
+    if (modalCall.success) {
+      modalCall.success({ confirm: true })
+    }
+
+    // 检查按钮状态
+    await waitFor(() => {
+      expect(getByText('取消中...')).toBeTruthy()
+    })
   })
 
   it('should not show cancel button for shipped order', () => {