From 142dd26ddc16390c9fc5de9e1140a639615fd8ca Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 20 Mar 2026 09:30:41 +0800 Subject: [PATCH] add refund list page --- .../src/pages/refunds/__tests__/index.spec.ts | 516 ++++++++++++++++++ frontend/src/pages/refunds/index.vue | 425 +++++++++++++++ 2 files changed, 941 insertions(+) create mode 100644 frontend/src/pages/refunds/__tests__/index.spec.ts create mode 100644 frontend/src/pages/refunds/index.vue diff --git a/frontend/src/pages/refunds/__tests__/index.spec.ts b/frontend/src/pages/refunds/__tests__/index.spec.ts new file mode 100644 index 0000000..c50c805 --- /dev/null +++ b/frontend/src/pages/refunds/__tests__/index.spec.ts @@ -0,0 +1,516 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { useRefundStore } from '@/stores/refund' +import type { RefundRecord } from '@/stores/refund' + +// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + }, +})) + +const mockRefunds: RefundRecord[] = [ + { + id: 201, + company_id: 1, + platform_id: 1, + store_id: 1, + platform_refund_id: 'RF-20260101-001', + platform_order_id: 'ORD-20260101-001', + refund_status_id: 1, + refund_type_id: 1, + refund_amount: '99.99', + freight_refund: '10.00', + refund_total: '109.99', + currency: 'CNY', + created_date: '2026-01-01T10:00:00Z', + completed_date: null, + }, + { + id: 202, + company_id: 2, + platform_id: 1, + store_id: 2, + platform_refund_id: 'RF-20260201-002', + platform_order_id: 'ORD-20260201-002', + refund_status_id: 5, + refund_type_id: 2, + refund_amount: '59.00', + freight_refund: '0', + refund_total: '59.00', + currency: 'USD', + created_date: '2026-02-01T14:00:00Z', + completed_date: '2026-02-03T09:00:00Z', + }, +] + +const mockPaginatedResponse = { + items: mockRefunds, + total: 2, + page: 1, + per_page: 15, +} + +const mockRefundDetail = { + ...mockRefunds[0], + order_id: 1, + buyer_user_id: 'buyer-001', + reason: '商品质量问题', + order_created_date: '2025-12-30T08:00:00Z', + order_paid_date: '2025-12-30T08:05:00Z', + updated_date: '2026-01-02T10:00:00Z', + ext: null, + created_at: '2026-01-01T10:00:00Z', + updated_at: '2026-01-02T10:00:00Z', +} + +const mockLookupCompanies = [ + { id: 1, name: 'Company A', label: '公司A' }, + { id: 2, name: 'Company B', label: '公司B' }, +] +const mockLookupPlatforms = [{ id: 1, developer_id: 1 }] +const mockLookupStores = [ + { id: 1, company_id: 1, platform_id: 1, name: 'Store 1', label: '店铺1' }, + { id: 2, company_id: 2, platform_id: 1, name: 'Store 2', label: '店铺2' }, +] + +// ─── Store Tests ─────────────────────────────────────── + +describe('useRefundStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with empty refunds and default pagination', () => { + const store = useRefundStore() + expect(store.refunds).toEqual([]) + expect(store.loading).toBe(false) + expect(store.pagination.page).toBe(1) + expect(store.pagination.per_page).toBe(15) + expect(store.pagination.total).toBe(0) + expect(store.filters.refund_status_id).toBeUndefined() + expect(store.filters.refund_type_id).toBeUndefined() + expect(store.filters.platform_refund_id).toBe('') + expect(store.filters.platform_order_id).toBe('') + expect(store.filters.created_date_range).toBeNull() + }) + }) + + describe('fetchRefunds', () => { + it('loads data and updates state', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useRefundStore() + await store.fetchRefunds() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/refunds', + expect.objectContaining({ + page: 1, + per_page: 15, + refund_status_id: undefined, + refund_type_id: undefined, + platform_refund_id: undefined, + platform_order_id: undefined, + created_date_from: undefined, + created_date_to: undefined, + }), + ) + expect(store.refunds).toEqual(mockRefunds) + expect(store.pagination.total).toBe(2) + }) + + it('passes filter params correctly', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useRefundStore() + store.filters.refund_status_id = 1 + store.filters.refund_type_id = 2 + store.filters.platform_refund_id = 'RF-001' + store.filters.platform_order_id = 'ORD-001' + store.filters.created_date_range = ['2026-01-01', '2026-01-31'] + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + + await store.fetchRefunds() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/refunds', + expect.objectContaining({ + refund_status_id: 1, + refund_type_id: 2, + platform_refund_id: 'RF-001', + platform_order_id: 'ORD-001', + created_date_from: '2026-01-01', + created_date_to: '2026-01-31', + company_id: 1, + platform_id: 2, + store_id: 3, + }), + ) + }) + + it('shows error message on failure', async () => { + const { api } = await import('@/utils/request') + const { message } = await import('ant-design-vue') + const errorSpy = vi.spyOn(message, 'error') + vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error')) + + const store = useRefundStore() + await store.fetchRefunds() + + expect(errorSpy).toHaveBeenCalledWith('Network error') + expect(store.refunds).toEqual([]) + }) + + it('clears refunds on fetch failure', async () => { + const { api } = await import('@/utils/request') + const { message } = await import('ant-design-vue') + vi.spyOn(message, 'error') + + const store = useRefundStore() + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + await store.fetchRefunds() + expect(store.refunds.length).toBe(2) + + vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error')) + await store.fetchRefunds() + + expect(store.refunds).toEqual([]) + expect(store.pagination.total).toBe(0) + }) + }) + + describe('resetFilters', () => { + it('clears all filters and resets page to 1', () => { + const store = useRefundStore() + store.filters.refund_status_id = 1 + store.filters.refund_type_id = 2 + store.filters.platform_refund_id = 'RF-001' + store.filters.platform_order_id = 'ORD-001' + store.filters.created_date_range = ['2026-01-01', '2026-01-31'] + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + store.pagination.page = 5 + + store.resetFilters() + + expect(store.filters.refund_status_id).toBeUndefined() + expect(store.filters.refund_type_id).toBeUndefined() + expect(store.filters.platform_refund_id).toBe('') + expect(store.filters.platform_order_id).toBe('') + expect(store.filters.created_date_range).toBeNull() + expect(store.cascadeValue.company_id).toBeUndefined() + expect(store.cascadeValue.platform_id).toBeUndefined() + expect(store.cascadeValue.store_id).toBeUndefined() + expect(store.pagination.page).toBe(1) + }) + }) + + describe('name maps', () => { + it('builds lookup maps from loaded data', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get) + .mockResolvedValueOnce(mockLookupCompanies) + .mockResolvedValueOnce(mockLookupPlatforms) + .mockResolvedValueOnce(mockLookupStores) + + const store = useRefundStore() + await store.loadLookups() + + expect(store.companyMap.get(1)).toBe('公司A') + expect(store.platformMap.get(1)).toBe('平台 #1') + expect(store.storeMap.get(1)).toBe('店铺1') + }) + + it('handles loadLookups failure gracefully', async () => { + const { api } = await import('@/utils/request') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error')) + + const store = useRefundStore() + await store.loadLookups() + + expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error)) + expect(store.companyMap.size).toBe(0) + warnSpy.mockRestore() + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('RefundsPage', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + async function mountPage() { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockImplementation((url: string) => { + if (url === '/api/v1/refunds') return Promise.resolve(mockPaginatedResponse) as never + if (url === '/api/v1/companies') return Promise.resolve(mockLookupCompanies) as never + if (url === '/api/v1/platforms') return Promise.resolve(mockLookupPlatforms) as never + if (url === '/api/v1/stores') return Promise.resolve(mockLookupStores) as never + return Promise.resolve([]) as never + }) + + const { default: RefundsPage } = await import('../index.vue') + + wrapper = mount(RefundsPage, { + attachTo: document.body, + global: { + stubs: { + SearchOutlined: { template: '' }, + ReloadOutlined: { template: '' }, + EyeOutlined: { template: '' }, + CopyOutlined: { template: '' }, + CascadeFilter: { template: '
' }, + }, + }, + }) + await flushPromises() + await nextTick() + return { wrapper, api } + } + + it('renders page title and action buttons', async () => { + await mountPage() + + expect(wrapper.text()).toContain('退款管理') + const buttons = wrapper.findAll('.ant-btn') + const buttonTexts = buttons.map((b) => b.text()) + expect(buttonTexts.some((t) => t.includes('搜索'))).toBe(true) + expect(buttonTexts.some((t) => t.includes('重置'))).toBe(true) + }, 15000) + + it('calls fetchRefunds and loadLookups on mount', async () => { + const { api } = await mountPage() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/refunds', + expect.objectContaining({ + page: 1, + per_page: 15, + }), + ) + expect(api.get).toHaveBeenCalledWith('/api/v1/companies') + expect(api.get).toHaveBeenCalledWith('/api/v1/platforms') + expect(api.get).toHaveBeenCalledWith('/api/v1/stores') + }) + + it('renders table with refund data', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('RF-20260101-001') + expect(html).toContain('ORD-20260101-001') + expect(html).toContain('99.99') + expect(html).toContain('109.99') + }) + + it('renders refund status tags correctly', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('退款申请中') + expect(html).toContain('退款成功') + }) + + it('renders refund type tags correctly', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('未发货前退款') + expect(html).toContain('退货退款') + }) + + it('renders company/platform/store names from lookup maps', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('公司A') + expect(html).toContain('店铺1') + }) + + it('formats amounts correctly (dash for zero values)', async () => { + await mountPage() + + // mockRefunds[1] has freight_refund '0' which should render as '-' + const html = wrapper.html() + expect(html).toContain('59.00') + }) + + it('renders CascadeFilter component', async () => { + await mountPage() + + expect(wrapper.find('.cascade-filter-stub').exists()).toBe(true) + }) + + it('search button resets page to 1 and fetches', async () => { + const { api } = await mountPage() + + const store = useRefundStore() + store.pagination.page = 3 + store.filters.platform_refund_id = 'RF-001' + vi.mocked(api.get).mockClear() + vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const searchBtn = buttons.find((b) => b.textContent?.trim().includes('搜索')) + searchBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(store.pagination.page).toBe(1) + expect(api.get).toHaveBeenCalledWith( + '/api/v1/refunds', + expect.objectContaining({ + page: 1, + platform_refund_id: 'RF-001', + }), + ) + }) + + it('resets filters when clicking reset button', async () => { + await mountPage() + + const store = useRefundStore() + store.filters.refund_status_id = 1 + store.filters.refund_type_id = 2 + store.filters.platform_refund_id = 'RF-001' + store.filters.platform_order_id = 'ORD-001' + store.filters.created_date_range = ['2026-01-01', '2026-01-31'] + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const resetBtn = buttons.find((b) => b.textContent?.trim().includes('重置')) + expect(resetBtn).toBeDefined() + + resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(store.filters.refund_status_id).toBeUndefined() + expect(store.filters.refund_type_id).toBeUndefined() + expect(store.filters.platform_refund_id).toBe('') + expect(store.filters.platform_order_id).toBe('') + expect(store.filters.created_date_range).toBeNull() + }) + + it('opens detail drawer when clicking view button', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce(mockRefundDetail) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + expect(viewBtn).toBeDefined() + + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawer = document.body.querySelector('.ant-drawer') + expect(drawer).toBeTruthy() + }) + + it('displays refund detail info in drawer', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce(mockRefundDetail) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' + expect(drawerHtml).toContain('退款详情') + expect(drawerHtml).toContain('RF-20260101-001') + expect(drawerHtml).toContain('商品质量问题') + expect(drawerHtml).toContain('buyer-001') + expect(drawerHtml).toContain('退款申请中') + }) + + it('displays ext JSON in drawer when available', async () => { + const { api } = await mountPage() + const detailWithExt = { + ...mockRefundDetail, + ext: { return_logistics: 'SF1234567890', warehouse: 'WH-01' }, + } + vi.mocked(api.get).mockResolvedValueOnce(detailWithExt) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' + expect(drawerHtml).toContain('return_logistics') + expect(drawerHtml).toContain('SF1234567890') + }) + + it('shows placeholder when ext is null', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce({ ...mockRefundDetail, ext: null }) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' + expect(drawerHtml).toContain('暂无数据') + }) + + it('shows error message when detail request fails', async () => { + const { api } = await mountPage() + const { message } = await import('ant-design-vue') + const errorSpy = vi.spyOn(message, 'error') + vi.mocked(api.get).mockRejectedValueOnce(new Error('Not found')) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(errorSpy).toHaveBeenCalledWith('获取退款详情失败') + }) +}) diff --git a/frontend/src/pages/refunds/index.vue b/frontend/src/pages/refunds/index.vue new file mode 100644 index 0000000..1146f9c --- /dev/null +++ b/frontend/src/pages/refunds/index.vue @@ -0,0 +1,425 @@ + + +