From 419778a53b5f2ad9ebfa26e7e5c712974abfbfbe Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Thu, 19 Mar 2026 16:29:26 +0800 Subject: [PATCH] add order items --- .../pages/order-items/__tests__/index.spec.ts | 503 ++++++++++++++++++ frontend/src/pages/order-items/index.vue | 345 ++++++++++++ frontend/src/stores/order-item.ts | 142 +++++ 3 files changed, 990 insertions(+) create mode 100644 frontend/src/pages/order-items/__tests__/index.spec.ts create mode 100644 frontend/src/pages/order-items/index.vue create mode 100644 frontend/src/stores/order-item.ts diff --git a/frontend/src/pages/order-items/__tests__/index.spec.ts b/frontend/src/pages/order-items/__tests__/index.spec.ts new file mode 100644 index 0000000..e6b31f1 --- /dev/null +++ b/frontend/src/pages/order-items/__tests__/index.spec.ts @@ -0,0 +1,503 @@ +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 { useOrderItemStore } from '@/stores/order-item' +import type { OrderItemRecord } from '@/stores/order' + +// 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 mockOrderItems: OrderItemRecord[] = [ + { + id: 101, + company_id: 1, + platform_id: 1, + store_id: 1, + order_id: 1, + platform_order_id: 'ORD-20260101-001', + sub_order_id: 'SUB-001', + sub_order_type_id: 1, + product_id: 500, + platform_product_id: 'PROD-001', + product_sku: 'SKU-ABC-001', + product_barcode: '6901234567890', + unit_price: '99.99', + quantity: 2, + discount: '10.00', + total: '189.98', + created_date: '2026-01-01T10:00:00Z', + ext: null, + created_at: '2026-01-01T10:00:00Z', + updated_at: '2026-01-01T10:00:00Z', + }, + { + id: 102, + company_id: 2, + platform_id: 1, + store_id: 2, + order_id: 2, + platform_order_id: 'ORD-20260201-002', + sub_order_id: null, + sub_order_type_id: 1, + product_id: 501, + platform_product_id: 'PROD-002', + product_sku: null, + product_barcode: null, + unit_price: '59.00', + quantity: 1, + discount: '0', + total: '59.00', + created_date: '2026-02-01T14:00:00Z', + ext: { color: 'red' }, + created_at: '2026-02-01T14:00:00Z', + updated_at: '2026-02-01T14:00:00Z', + }, +] + +const mockPaginatedResponse = { + items: mockOrderItems, + total: 2, + page: 1, + per_page: 15, +} + +const mockItemDetail = { + ...mockOrderItems[0], + parent_order: { + id: 1, + platform_order_id: 'ORD-20260101-001', + order_status_id: 3, + total_amount: '199.99', + total_paid: '189.99', + created_date: '2026-01-01T10:00:00Z', + paid_date: '2026-01-01T10:05: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('useOrderItemStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with empty orderItems and default pagination', () => { + const store = useOrderItemStore() + expect(store.orderItems).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.platform_order_id).toBe('') + expect(store.filters.platform_product_id).toBe('') + expect(store.filters.product_sku).toBe('') + expect(store.filters.created_date_range).toBeNull() + }) + }) + + describe('fetchOrderItems', () => { + it('loads data and updates state', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useOrderItemStore() + await store.fetchOrderItems() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/order-items', + expect.objectContaining({ + page: 1, + per_page: 15, + platform_order_id: undefined, + platform_product_id: undefined, + product_sku: undefined, + created_date_from: undefined, + created_date_to: undefined, + }), + ) + expect(store.orderItems).toEqual(mockOrderItems) + 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 = useOrderItemStore() + store.filters.platform_order_id = 'ORD-001' + store.filters.platform_product_id = 'PROD-001' + store.filters.product_sku = 'SKU-ABC' + 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.fetchOrderItems() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/order-items', + expect.objectContaining({ + platform_order_id: 'ORD-001', + platform_product_id: 'PROD-001', + product_sku: 'SKU-ABC', + 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 = useOrderItemStore() + await store.fetchOrderItems() + + expect(errorSpy).toHaveBeenCalledWith('Network error') + expect(store.orderItems).toEqual([]) + }) + + it('clears orderItems on fetch failure', async () => { + const { api } = await import('@/utils/request') + const { message } = await import('ant-design-vue') + vi.spyOn(message, 'error') + + const store = useOrderItemStore() + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + await store.fetchOrderItems() + expect(store.orderItems.length).toBe(2) + + vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error')) + await store.fetchOrderItems() + + expect(store.orderItems).toEqual([]) + expect(store.pagination.total).toBe(0) + }) + }) + + describe('resetFilters', () => { + it('clears all filters and resets page to 1', () => { + const store = useOrderItemStore() + store.filters.platform_order_id = 'ORD-001' + store.filters.platform_product_id = 'PROD-001' + store.filters.product_sku = 'SKU-ABC' + 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.platform_order_id).toBe('') + expect(store.filters.platform_product_id).toBe('') + expect(store.filters.product_sku).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 = useOrderItemStore() + 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 = useOrderItemStore() + await store.loadLookups() + + expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error)) + expect(store.companyMap.size).toBe(0) + warnSpy.mockRestore() + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('OrderItemsPage', () => { + 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/order-items') 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: OrderItemsPage } = await import('../index.vue') + + wrapper = mount(OrderItemsPage, { + attachTo: document.body, + global: { + stubs: { + SearchOutlined: { template: '' }, + ReloadOutlined: { template: '' }, + EyeOutlined: { 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 fetchOrderItems and loadLookups on mount', async () => { + const { api } = await mountPage() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/order-items', + 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 order item data', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('ORD-20260101-001') + expect(html).toContain('SKU-ABC-001') + expect(html).toContain('99.99') + expect(html).toContain('189.98') + }) + + 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() + + // mockOrderItems[1] has discount '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 = useOrderItemStore() + store.pagination.page = 3 + store.filters.platform_order_id = 'ORD-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/order-items', + expect.objectContaining({ + page: 1, + platform_order_id: 'ORD-001', + }), + ) + }) + + it('resets filters when clicking reset button', async () => { + await mountPage() + + const store = useOrderItemStore() + store.filters.platform_order_id = 'ORD-001' + store.filters.platform_product_id = 'PROD-001' + store.filters.product_sku = 'SKU-ABC' + 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.platform_order_id).toBe('') + expect(store.filters.platform_product_id).toBe('') + expect(store.filters.product_sku).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(mockItemDetail) + + 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 parent order summary in drawer', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce(mockItemDetail) + + 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('ORD-20260101-001') + expect(drawerHtml).toContain('199.99') + expect(drawerHtml).toContain('189.99') + expect(drawerHtml).toContain('已支付') + }) + + it('displays ext JSON in drawer when available', async () => { + const { api } = await mountPage() + const detailWithExt = { + ...mockItemDetail, + ext: { color: 'red', size: 'XL' }, + } + 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('color') + expect(drawerHtml).toContain('red') + }) + + it('shows placeholder when ext is null', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce({ ...mockItemDetail, 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/order-items/index.vue b/frontend/src/pages/order-items/index.vue new file mode 100644 index 0000000..f874b68 --- /dev/null +++ b/frontend/src/pages/order-items/index.vue @@ -0,0 +1,345 @@ + + + diff --git a/frontend/src/stores/order-item.ts b/frontend/src/stores/order-item.ts new file mode 100644 index 0000000..3ca9ef2 --- /dev/null +++ b/frontend/src/stores/order-item.ts @@ -0,0 +1,142 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' +import type { OrderItemRecord } from '@/stores/order' + +/** 父订单摘要(详情接口嵌套返回) */ +export interface ParentOrderSummary { + id: number + platform_order_id: string + order_status_id: number + total_amount: string + total_paid: string + created_date: string | null + paid_date: string | null +} + +/** 订单项详情(含父订单摘要) */ +export interface OrderItemDetail extends OrderItemRecord { + parent_order: ParentOrderSummary +} + +export interface OrderItemFilters { + platform_order_id: string + platform_product_id: string + product_sku: string + created_date_range: [string, string] | null +} + +/** 名称映射用的查找表 */ +interface LookupItem { + id: number + name: string + label?: string +} + +export const useOrderItemStore = defineStore('order-item', () => { + const orderItems = ref([]) + const loading = ref(false) + const pagination = reactive({ + page: 1, + per_page: 15, + total: 0, + }) + const cascadeValue = reactive({ + company_id: undefined as number | undefined, + platform_id: undefined as number | undefined, + store_id: undefined as number | undefined, + }) + const filters = reactive({ + platform_order_id: '', + platform_product_id: '', + product_sku: '', + created_date_range: null, + }) + + // 名称映射数据 + const companies = ref([]) + const platforms = ref<{ id: number; developer_id: number }[]>([]) + const stores = ref<(LookupItem & { company_id: number; platform_id: number })[]>([]) + + const companyMap = computed( + () => new Map(companies.value.map((c) => [c.id, c.label || c.name])), + ) + const platformMap = computed( + () => new Map(platforms.value.map((p) => [p.id, `平台 #${p.id}`])), + ) + const storeMap = computed( + () => new Map(stores.value.map((s) => [s.id, s.label || s.name])), + ) + + async function loadLookups() { + try { + const [c, p, s] = await Promise.all([ + api.get('/api/v1/companies'), + api.get<{ id: number; developer_id: number }[]>('/api/v1/platforms'), + api.get<(LookupItem & { company_id: number; platform_id: number })[]>( + '/api/v1/stores', + ), + ]) + companies.value = c + platforms.value = p + stores.value = s + } catch (err: unknown) { + console.warn('加载查找表数据失败', err) + } + } + + async function fetchOrderItems() { + loading.value = true + try { + const data = await api.get>('/api/v1/order-items', { + page: pagination.page, + per_page: pagination.per_page, + company_id: cascadeValue.company_id, + platform_id: cascadeValue.platform_id, + store_id: cascadeValue.store_id, + platform_order_id: filters.platform_order_id || undefined, + platform_product_id: filters.platform_product_id || undefined, + product_sku: filters.product_sku || undefined, + created_date_from: filters.created_date_range?.[0] || undefined, + created_date_to: filters.created_date_range?.[1] || undefined, + }) + orderItems.value = data.items + pagination.total = data.total + pagination.page = data.page + } catch (err: unknown) { + orderItems.value = [] + pagination.total = 0 + const msg = err instanceof Error ? err.message : '获取订单项列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.platform_order_id = '' + filters.platform_product_id = '' + filters.product_sku = '' + filters.created_date_range = null + cascadeValue.company_id = undefined + cascadeValue.platform_id = undefined + cascadeValue.store_id = undefined + pagination.page = 1 + } + + return { + orderItems, + loading, + pagination, + cascadeValue, + filters, + companies, + platforms, + stores, + companyMap, + platformMap, + storeMap, + loadLookups, + fetchOrderItems, + resetFilters, + } +})