diff --git a/frontend/src/pages/orders/__tests__/index.spec.ts b/frontend/src/pages/orders/__tests__/index.spec.ts new file mode 100644 index 0000000..caaf714 --- /dev/null +++ b/frontend/src/pages/orders/__tests__/index.spec.ts @@ -0,0 +1,519 @@ +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 { useOrderStore, type OrderRecord } 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 mockOrders: OrderRecord[] = [ + { + id: 1, + company_id: 1, + platform_id: 1, + store_id: 1, + order_status_id: 3, + platform_order_id: 'ORD-20260101-001', + total_amount: '199.99', + total_paid: '189.99', + total_discount: '10.00', + created_date: '2026-01-01T10:00:00Z', + paid_date: '2026-01-01T10:05:00Z', + shipping_date: '2026-01-02T08:00:00Z', + }, + { + id: 2, + company_id: 2, + platform_id: 1, + store_id: 2, + order_status_id: 1, + platform_order_id: 'ORD-20260201-002', + total_amount: '59.00', + total_paid: '0', + total_discount: '0', + created_date: '2026-02-01T14:00:00Z', + paid_date: null, + shipping_date: null, + }, +] + +const mockPaginatedResponse = { + items: mockOrders, + total: 2, + page: 1, + per_page: 15, +} + +const mockOrderDetail = { + ...mockOrders[0], + buyer_user_id: 'buyer_123', + payment_method_id: 1, + presale: false, + total_received: '189.99', + freight_fee: '0.00', + tax_fee: '0.00', + discount_fee: '10.00', + commission_fee: '5.00', + coupon_amount: '0.00', + voucher_amount: '0.00', + order_type_id: 1, + updated_date: '2026-01-02T08:00:00Z', + zipcode: '310000', + city: '杭州', + province: '浙江', + country: '中国', + ext: { remark: '加急发货', tags: ['vip'] }, + created_at: '2026-01-01T10:00:00Z', + updated_at: '2026-01-02T08:00:00Z', + order_items: [ + { + 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', + }, + ], +} + +const mockRawDetail = { + id: 1, + platform_order_id: 'ORD-20260101-001', + store_id: 1, + company_id: 1, + platform_id: 1, + order_status_id: 3, + raw: { tid: 'tmall_001', status: 'TRADE_FINISHED', orders: [{ oid: 'sub_1' }] }, + hash: 'abc123', + ext: null, + created_date: '2026-01-01T10: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('useOrderStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with empty orders and default pagination', () => { + const store = useOrderStore() + expect(store.orders).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.order_status_id).toBeUndefined() + expect(store.filters.platform_order_id).toBe('') + expect(store.filters.created_date_range).toBeNull() + expect(store.filters.paid_date_range).toBeNull() + }) + }) + + describe('fetchOrders', () => { + it('loads data and updates state', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useOrderStore() + await store.fetchOrders() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/orders', + expect.objectContaining({ + page: 1, + per_page: 15, + order_status_id: undefined, + platform_order_id: undefined, + created_date_from: undefined, + created_date_to: undefined, + paid_date_from: undefined, + paid_date_to: undefined, + }), + ) + expect(store.orders).toEqual(mockOrders) + expect(store.pagination.total).toBe(2) + }) + + it('passes date range params correctly', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useOrderStore() + store.filters.order_status_id = 3 + store.filters.platform_order_id = 'ORD-001' + store.filters.created_date_range = ['2026-01-01', '2026-01-31'] + store.filters.paid_date_range = ['2026-02-01', '2026-02-28'] + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + + await store.fetchOrders() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/orders', + expect.objectContaining({ + order_status_id: 3, + platform_order_id: 'ORD-001', + created_date_from: '2026-01-01', + created_date_to: '2026-01-31', + paid_date_from: '2026-02-01', + paid_date_to: '2026-02-28', + 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 = useOrderStore() + await store.fetchOrders() + + expect(errorSpy).toHaveBeenCalledWith('Network error') + expect(store.orders).toEqual([]) + }) + }) + + describe('resetFilters', () => { + it('clears all filters including date ranges and resets page to 1', () => { + const store = useOrderStore() + store.filters.order_status_id = 3 + store.filters.platform_order_id = 'ORD-001' + store.filters.created_date_range = ['2026-01-01', '2026-01-31'] + store.filters.paid_date_range = ['2026-02-01', '2026-02-28'] + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + store.pagination.page = 5 + + store.resetFilters() + + expect(store.filters.order_status_id).toBeUndefined() + expect(store.filters.platform_order_id).toBe('') + expect(store.filters.created_date_range).toBeNull() + expect(store.filters.paid_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 = useOrderStore() + 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 = useOrderStore() + await store.loadLookups() + + expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error)) + expect(store.companyMap.size).toBe(0) + warnSpy.mockRestore() + }) + }) + + describe('fetchOrders error handling', () => { + it('clears orders on fetch failure', async () => { + const { api } = await import('@/utils/request') + const { message } = await import('ant-design-vue') + vi.spyOn(message, 'error') + + const store = useOrderStore() + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + await store.fetchOrders() + expect(store.orders.length).toBe(2) + + vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error')) + await store.fetchOrders() + + expect(store.orders).toEqual([]) + expect(store.pagination.total).toBe(0) + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('OrdersPage', () => { + 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/orders') 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: OrdersPage } = await import('../index.vue') + + wrapper = mount(OrdersPage, { + 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 fetchOrders on mount', async () => { + const { api } = await mountPage() + + expect(api.get).toHaveBeenCalledWith( + '/api/v1/orders', + expect.objectContaining({ + page: 1, + per_page: 15, + }), + ) + }) + + it('renders table with order data', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('ORD-20260101-001') + expect(html).toContain('199.99') + expect(html).toContain('189.99') + }) + + 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('renders status tags with correct text', async () => { + await mountPage() + + const tags = wrapper.findAll('.ant-tag') + const tagTexts = tags.map((t) => t.text()) + expect(tagTexts).toContain('已支付') + expect(tagTexts).toContain('待付款') + }) + + 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 = useOrderStore() + store.pagination.page = 3 + store.filters.order_status_id = 3 + 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/orders', + expect.objectContaining({ + page: 1, + order_status_id: 3, + }), + ) + }) + + it('resets filters when clicking reset button', async () => { + await mountPage() + + const store = useOrderStore() + store.filters.order_status_id = 3 + 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.order_status_id).toBeUndefined() + 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(mockOrderDetail) + .mockResolvedValueOnce(mockRawDetail) + + 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 ext JSON in drawer when available', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce(mockOrderDetail) + .mockResolvedValueOnce(mockRawDetail) + + 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('remark') + expect(drawerHtml).toContain('加急发货') + }) + + it('displays raw JSON in drawer when available', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce(mockOrderDetail) + .mockResolvedValueOnce(mockRawDetail) + + 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('tmall_001') + expect(drawerHtml).toContain('TRADE_FINISHED') + }) + + it('shows placeholder when ext/raw is null', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce({ ...mockOrderDetail, ext: null, order_items: [] }) + .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() + + const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' + expect(drawerHtml).toContain('暂无数据') + }) +}) diff --git a/frontend/src/pages/orders/index.vue b/frontend/src/pages/orders/index.vue new file mode 100644 index 0000000..ff85e41 --- /dev/null +++ b/frontend/src/pages/orders/index.vue @@ -0,0 +1,460 @@ + + + diff --git a/frontend/src/stores/order.ts b/frontend/src/stores/order.ts new file mode 100644 index 0000000..740d8b9 --- /dev/null +++ b/frontend/src/stores/order.ts @@ -0,0 +1,216 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' + +/** 订单列表记录(12 字段,与 getListFields 对应) */ +export interface OrderRecord { + id: number + company_id: number + platform_id: number + store_id: number + order_status_id: number + platform_order_id: string + total_amount: string + total_paid: string + total_discount: string + created_date: string | null + paid_date: string | null + shipping_date: string | null +} + +/** 订单子项记录 */ +export interface OrderItemRecord { + id: number + company_id: number + platform_id: number + store_id: number + order_id: number + platform_order_id: string + sub_order_id: string | null + sub_order_type_id: number + product_id: number + platform_product_id: string + product_sku: string | null + product_barcode: string | null + unit_price: string + quantity: number + discount: string + total: string + created_date: string | null + ext: Record | null + created_at: string + updated_at: string +} + +/** 订单详情(含 order_items) */ +export interface OrderDetail { + id: number + company_id: number + platform_id: number + store_id: number + order_status_id: number + platform_order_id: string + buyer_user_id: string | null + payment_method_id: number + presale: boolean + total_amount: string + total_paid: string + total_discount: string + total_received: string + freight_fee: string + tax_fee: string + discount_fee: string + commission_fee: string + coupon_amount: string + voucher_amount: string + order_type_id: number + created_date: string | null + updated_date: string | null + paid_date: string | null + shipping_date: string | null + zipcode: string + city: string + province: string + country: string + ext: Record | null + created_at: string + updated_at: string + order_items: OrderItemRecord[] +} + +/** Raw 订单详情 */ +export interface RawOrderDetail { + id: number + platform_order_id: string + store_id: number + company_id: number + platform_id: number + order_status_id: number + raw: Record | null + hash: string + ext: Record | null + created_date: string | null +} + +export interface OrderFilters { + order_status_id: number | undefined + platform_order_id: string + created_date_range: [string, string] | null + paid_date_range: [string, string] | null +} + +/** 名称映射用的查找表 */ +interface LookupItem { + id: number + name: string + label?: string +} + +export const useOrderStore = defineStore('order', () => { + const orders = 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({ + order_status_id: undefined, + platform_order_id: '', + created_date_range: null, + paid_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 fetchOrders() { + loading.value = true + try { + const data = await api.get>('/api/v1/orders', { + page: pagination.page, + per_page: pagination.per_page, + company_id: cascadeValue.company_id, + platform_id: cascadeValue.platform_id, + store_id: cascadeValue.store_id, + order_status_id: filters.order_status_id, + platform_order_id: filters.platform_order_id || undefined, + created_date_from: filters.created_date_range?.[0] || undefined, + created_date_to: filters.created_date_range?.[1] || undefined, + paid_date_from: filters.paid_date_range?.[0] || undefined, + paid_date_to: filters.paid_date_range?.[1] || undefined, + }) + orders.value = data.items + pagination.total = data.total + pagination.page = data.page + } catch (err: unknown) { + orders.value = [] + pagination.total = 0 + const msg = err instanceof Error ? err.message : '获取订单列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.order_status_id = undefined + filters.platform_order_id = '' + filters.created_date_range = null + filters.paid_date_range = null + cascadeValue.company_id = undefined + cascadeValue.platform_id = undefined + cascadeValue.store_id = undefined + pagination.page = 1 + } + + return { + orders, + loading, + pagination, + cascadeValue, + filters, + companies, + platforms, + stores, + companyMap, + platformMap, + storeMap, + loadLookups, + fetchOrders, + resetFilters, + } +})