diff --git a/frontend/src/pages/products/__tests__/index.spec.ts b/frontend/src/pages/products/__tests__/index.spec.ts new file mode 100644 index 0000000..b00272d --- /dev/null +++ b/frontend/src/pages/products/__tests__/index.spec.ts @@ -0,0 +1,433 @@ +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 { useProductStore, type ProductRecord } from '@/stores/product' + +// 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 mockProducts: ProductRecord[] = [ + { + id: 1, + company_id: 1, + platform_id: 1, + store_id: 1, + status_id: 1, + platform_item_id: 'TB001', + platform_model_id: 'SKU001', + name: '测试商品A', + price: '99.00', + currency: 'CNY', + num: 100, + created_date: '2026-01-01T00:00:00Z', + updated_date: '2026-03-01T10:30:00Z', + }, + { + id: 2, + company_id: 2, + platform_id: 1, + store_id: 2, + status_id: 0, + platform_item_id: 'TB002', + platform_model_id: null, + name: '测试商品B - 这是一个非常长的商品名称用于测试省略号效果', + price: '199.50', + currency: 'USD', + num: 0, + created_date: '2026-02-01T00:00:00Z', + updated_date: '2026-03-15T08:00:00Z', + }, +] + +const mockPaginatedResponse = { + items: mockProducts, + total: 2, + page: 1, + per_page: 15, +} + +const mockProductDetail = { + ...mockProducts[0], + type_id: 1, + warehouse_id: null, + sub_warehouse_id: null, + origin_sku_id: 'ORIG001', + mapped_sku_id: 'MAP001', + barcode: '1234567890', + hscode: '6403', + bundled: null, + url: 'https://example.com/product/1', + picture: 'https://example.com/img/1.jpg', + ext: { custom_field: 'value', nested: { a: 1 } }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-03-01T10:30:00Z', +} + +const mockRawDetail = { + id: 1, + platform_item_id: 'TB001', + platform_model_id: 'SKU001', + name: '测试商品A', + store_id: 1, + company_id: 1, + platform_id: 1, + raw: { item_id: 'tb_001', title: 'Raw Title', attrs: [1, 2, 3] }, + hash: 'abc123', + ext: null, + created_date: '2026-01-01T00:00:00Z', + updated_date: '2026-03-01T10:30: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('useProductStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with empty products and default pagination', () => { + const store = useProductStore() + expect(store.products).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.name).toBe('') + expect(store.filters.platform_item_id).toBe('') + expect(store.filters.status_id).toBeUndefined() + }) + }) + + describe('fetchProducts', () => { + it('loads data and updates state', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useProductStore() + await store.fetchProducts() + + expect(api.get).toHaveBeenCalledWith('/api/v1/products', expect.objectContaining({ + page: 1, + per_page: 15, + name: undefined, + platform_item_id: undefined, + status_id: undefined, + })) + expect(store.products).toEqual(mockProducts) + 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 = useProductStore() + store.filters.name = '测试' + store.filters.platform_item_id = 'TB001' + store.filters.status_id = 1 + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + + await store.fetchProducts() + + expect(api.get).toHaveBeenCalledWith('/api/v1/products', expect.objectContaining({ + name: '测试', + platform_item_id: 'TB001', + status_id: 1, + 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 = useProductStore() + await store.fetchProducts() + + expect(errorSpy).toHaveBeenCalledWith('Network error') + expect(store.products).toEqual([]) + }) + }) + + describe('resetFilters', () => { + it('clears all filters and resets page to 1', () => { + const store = useProductStore() + store.filters.name = '测试' + store.filters.platform_item_id = 'TB001' + store.filters.status_id = 1 + store.cascadeValue.company_id = 1 + store.cascadeValue.platform_id = 2 + store.cascadeValue.store_id = 3 + store.pagination.page = 5 + + store.resetFilters() + + expect(store.filters.name).toBe('') + expect(store.filters.platform_item_id).toBe('') + expect(store.filters.status_id).toBeUndefined() + 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 = useProductStore() + await store.loadLookups() + + expect(store.companyMap.get(1)).toBe('公司A') + expect(store.platformMap.get(1)).toBe('平台 #1') + expect(store.storeMap.get(1)).toBe('店铺1') + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('ProductsPage', () => { + 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/products') 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: ProductsPage } = await import('../index.vue') + + wrapper = mount(ProductsPage, { + 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 fetchProducts on mount', async () => { + const { api } = await mountPage() + + expect(api.get).toHaveBeenCalledWith('/api/v1/products', expect.objectContaining({ + page: 1, + per_page: 15, + })) + }) + + it('renders table with product data', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('测试商品A') + expect(html).toContain('¥99.00') + expect(html).toContain('$199.50') + }) + + 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 company/platform/store names from lookup maps', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('公司A') + expect(html).toContain('店铺1') + }) + + 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 = useProductStore() + store.pagination.page = 3 + store.filters.name = '测试' + 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/products', expect.objectContaining({ + page: 1, + name: '测试', + })) + }) + + it('resets filters when clicking reset button', async () => { + await mountPage() + + const store = useProductStore() + store.filters.name = '测试' + store.filters.status_id = 1 + + 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.name).toBe('') + expect(store.filters.status_id).toBeUndefined() + }) + + it('opens detail drawer when clicking view button', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce(mockProductDetail) + .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(mockProductDetail) + .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('custom_field') + expect(drawerHtml).toContain('"value"') + }) + + it('displays raw JSON in drawer when available', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce(mockProductDetail) + .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('item_id') + expect(drawerHtml).toContain('tb_001') + }) + + it('shows placeholder when ext/raw is null', async () => { + const { api } = await mountPage() + vi.mocked(api.get) + .mockResolvedValueOnce({ ...mockProductDetail, ext: null }) + .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/products/index.vue b/frontend/src/pages/products/index.vue new file mode 100644 index 0000000..e6e7dc8 --- /dev/null +++ b/frontend/src/pages/products/index.vue @@ -0,0 +1,305 @@ + + + diff --git a/frontend/src/stores/product.ts b/frontend/src/stores/product.ts new file mode 100644 index 0000000..6cc4a5e --- /dev/null +++ b/frontend/src/stores/product.ts @@ -0,0 +1,162 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' + +/** 产品列表记录(13 字段) */ +export interface ProductRecord { + id: number + company_id: number + platform_id: number + store_id: number + status_id: number + platform_item_id: string + platform_model_id: string | null + name: string | null + price: string + currency: string + num: number + created_date: string | null + updated_date: string | null +} + +/** Normal 产品详情(完整字段) */ +export interface ProductDetail extends ProductRecord { + type_id: number + warehouse_id: number | null + sub_warehouse_id: number | null + origin_sku_id: string | null + mapped_sku_id: string | null + barcode: string | null + hscode: string | null + bundled: unknown | null + url: string | null + picture: string | null + ext: Record | null + created_at: string + updated_at: string +} + +/** Raw 产品详情 */ +export interface RawProductDetail { + id: number + platform_item_id: string + platform_model_id: string | null + name: string | null + store_id: number + company_id: number + platform_id: number + raw: Record | null + hash: string + ext: Record | null + created_date: string | null + updated_date: string | null +} + +export interface ProductFilters { + name: string + platform_item_id: string + status_id: number | undefined +} + +/** 名称映射用的查找表 */ +interface LookupItem { + id: number + name: string + label?: string +} + +export const useProductStore = defineStore('product', () => { + const products = 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({ + name: '', + platform_item_id: '', + status_id: undefined, + }) + + // 名称映射数据 + 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]))) + // platforms API 不返回 name 字段,使用 ID 作为显示标签 + 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 fetchProducts() { + loading.value = true + try { + const data = await api.get>('/api/v1/products', { + page: pagination.page, + per_page: pagination.per_page, + company_id: cascadeValue.company_id, + platform_id: cascadeValue.platform_id, + store_id: cascadeValue.store_id, + name: filters.name || undefined, + platform_item_id: filters.platform_item_id || undefined, + status_id: filters.status_id, + }) + products.value = data.items + pagination.total = data.total + pagination.page = data.page + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取产品列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.name = '' + filters.platform_item_id = '' + filters.status_id = undefined + cascadeValue.company_id = undefined + cascadeValue.platform_id = undefined + cascadeValue.store_id = undefined + pagination.page = 1 + } + + return { + products, + loading, + pagination, + cascadeValue, + filters, + companies, + platforms, + stores, + companyMap, + platformMap, + storeMap, + loadLookups, + fetchProducts, + resetFilters, + } +})