add product manage
This commit is contained in:
@@ -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<typeof mount>
|
||||||
|
|
||||||
|
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: '<span />' },
|
||||||
|
ReloadOutlined: { template: '<span />' },
|
||||||
|
EyeOutlined: { template: '<span />' },
|
||||||
|
CascadeFilter: { template: '<div class="cascade-filter-stub" />' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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('暂无数据')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
import {
|
||||||
|
useProductStore,
|
||||||
|
type ProductRecord,
|
||||||
|
type ProductDetail,
|
||||||
|
type RawProductDetail,
|
||||||
|
} from '@/stores/product'
|
||||||
|
import CascadeFilter from '@/components/CascadeFilter.vue'
|
||||||
|
import { SearchOutlined, ReloadOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const store = useProductStore()
|
||||||
|
|
||||||
|
// Detail drawer
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerLoading = ref(false)
|
||||||
|
const productDetail = ref<ProductDetail | null>(null)
|
||||||
|
const rawDetail = ref<RawProductDetail | null>(null)
|
||||||
|
|
||||||
|
const statusMap: Record<number, { text: string; color: string }> = {
|
||||||
|
1: { text: '上架', color: 'green' },
|
||||||
|
0: { text: '下架', color: 'red' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '公司', key: 'company', width: 120 },
|
||||||
|
{ title: '平台', key: 'platform', width: 100 },
|
||||||
|
{ title: '店铺', key: 'store', width: 140 },
|
||||||
|
{ title: '商品名称', dataIndex: 'name', ellipsis: true },
|
||||||
|
{ title: '价格', key: 'price', width: 120 },
|
||||||
|
{ title: '库存', dataIndex: 'num', width: 80 },
|
||||||
|
{ title: '状态', key: 'status', width: 100 },
|
||||||
|
{ title: '更新时间', key: 'updated_date', width: 160 },
|
||||||
|
{ title: '操作', key: 'action', width: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadLookups()
|
||||||
|
store.fetchProducts()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
store.pagination.page = 1
|
||||||
|
store.fetchProducts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
store.resetFilters()
|
||||||
|
store.fetchProducts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
store.pagination.page = page
|
||||||
|
store.pagination.per_page = pageSize
|
||||||
|
store.fetchProducts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(record: { price: string; currency: string }) {
|
||||||
|
const symbols: Record<string, string> = {
|
||||||
|
CNY: '¥',
|
||||||
|
USD: '$',
|
||||||
|
EUR: '€',
|
||||||
|
GBP: '£',
|
||||||
|
JPY: '¥',
|
||||||
|
}
|
||||||
|
const symbol = symbols[record.currency] || record.currency + ' '
|
||||||
|
return `${symbol}${record.price}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string | null) {
|
||||||
|
if (!time) return '-'
|
||||||
|
return time.replace('T', ' ').substring(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewDetail(record: { id: number }) {
|
||||||
|
drawerVisible.value = true
|
||||||
|
drawerLoading.value = true
|
||||||
|
productDetail.value = null
|
||||||
|
rawDetail.value = null
|
||||||
|
|
||||||
|
const [normalResult, rawResult] = await Promise.allSettled([
|
||||||
|
api.get<ProductDetail>(`/api/v1/products/${record.id}`),
|
||||||
|
api.get<RawProductDetail>(`/api/v1/raw/products/${record.id}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (normalResult.status === 'fulfilled') {
|
||||||
|
productDetail.value = normalResult.value
|
||||||
|
} else {
|
||||||
|
message.error('获取产品详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawResult.status === 'fulfilled') {
|
||||||
|
rawDetail.value = rawResult.value
|
||||||
|
}
|
||||||
|
|
||||||
|
drawerLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">产品管理</h2>
|
||||||
|
|
||||||
|
<!-- Filter area -->
|
||||||
|
<a-card class="mb-4">
|
||||||
|
<a-form layout="inline" @submit.prevent="handleSearch">
|
||||||
|
<a-form-item>
|
||||||
|
<CascadeFilter v-model="store.cascadeValue" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="状态">
|
||||||
|
<a-select
|
||||||
|
v-model:value="store.filters.status_id"
|
||||||
|
placeholder="全部"
|
||||||
|
allow-clear
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<a-select-option :value="1">上架</a-select-option>
|
||||||
|
<a-select-option :value="0">下架</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="商品名称">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.name"
|
||||||
|
placeholder="模糊搜索"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="平台商品ID">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.platform_item_id"
|
||||||
|
placeholder="精确搜索"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<a-card>
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="store.products"
|
||||||
|
:loading="store.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'company'">
|
||||||
|
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'platform'">
|
||||||
|
{{ store.platformMap.get(record.platform_id) || record.platform_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'store'">
|
||||||
|
{{ store.storeMap.get(record.store_id) || record.store_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'name'">
|
||||||
|
<a-tooltip :title="record.name">
|
||||||
|
{{ record.name || '-' }}
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'price'">
|
||||||
|
{{ formatPrice(record as ProductRecord) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-tag :color="statusMap[record.status_id]?.color || 'default'">
|
||||||
|
{{ statusMap[record.status_id]?.text || `状态 ${record.status_id}` }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'updated_date'">
|
||||||
|
{{ formatTime(record.updated_date) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-button type="link" size="small" @click="handleViewDetail(record as ProductRecord)">
|
||||||
|
<template #icon><EyeOutlined /></template>
|
||||||
|
查看
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<a-pagination
|
||||||
|
:current="store.pagination.page"
|
||||||
|
:page-size="store.pagination.per_page"
|
||||||
|
:total="store.pagination.total"
|
||||||
|
show-size-changer
|
||||||
|
show-quick-jumper
|
||||||
|
:show-total="(total: number) => `共 ${total} 条`"
|
||||||
|
@change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Detail drawer -->
|
||||||
|
<a-drawer
|
||||||
|
title="产品详情"
|
||||||
|
:open="drawerVisible"
|
||||||
|
:width="640"
|
||||||
|
@close="drawerVisible = false"
|
||||||
|
>
|
||||||
|
<a-spin :spinning="drawerLoading">
|
||||||
|
<template v-if="productDetail">
|
||||||
|
<a-descriptions title="基本信息" :column="2" bordered class="mb-4">
|
||||||
|
<a-descriptions-item label="ID">{{ productDetail.id }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="状态">
|
||||||
|
<a-tag :color="statusMap[productDetail.status_id]?.color || 'default'">
|
||||||
|
{{ statusMap[productDetail.status_id]?.text || `状态 ${productDetail.status_id}` }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="商品名称" :span="2">
|
||||||
|
{{ productDetail.name || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="平台商品ID">
|
||||||
|
{{ productDetail.platform_item_id }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="平台规格ID">
|
||||||
|
{{ productDetail.platform_model_id || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="价格">
|
||||||
|
{{ formatPrice(productDetail) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="库存">
|
||||||
|
{{ productDetail.num }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="公司">
|
||||||
|
{{ store.companyMap.get(productDetail.company_id) || productDetail.company_id }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="平台">
|
||||||
|
{{ store.platformMap.get(productDetail.platform_id) || productDetail.platform_id }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="店铺" :span="2">
|
||||||
|
{{ store.storeMap.get(productDetail.store_id) || productDetail.store_id }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="原始SKU">
|
||||||
|
{{ productDetail.origin_sku_id || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="映射SKU">
|
||||||
|
{{ productDetail.mapped_sku_id || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="条形码">
|
||||||
|
{{ productDetail.barcode || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="HS编码">
|
||||||
|
{{ productDetail.hscode || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="商品链接" :span="2">
|
||||||
|
<a v-if="productDetail.url" :href="productDetail.url" target="_blank">
|
||||||
|
{{ productDetail.url }}
|
||||||
|
</a>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="平台创建时间">
|
||||||
|
{{ formatTime(productDetail.created_date) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="平台更新时间">
|
||||||
|
{{ formatTime(productDetail.updated_date) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="系统创建时间">
|
||||||
|
{{ formatTime(productDetail.created_at) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="系统更新时间">
|
||||||
|
{{ formatTime(productDetail.updated_at) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<!-- ext JSON -->
|
||||||
|
<a-descriptions title="扩展数据 (ext)" :column="1" bordered class="mb-4">
|
||||||
|
<a-descriptions-item>
|
||||||
|
<pre v-if="productDetail.ext" class="m-0 text-xs max-h-80 overflow-auto">{{
|
||||||
|
JSON.stringify(productDetail.ext, null, 2)
|
||||||
|
}}</pre>
|
||||||
|
<span v-else class="text-gray-400">暂无数据</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<!-- raw JSON -->
|
||||||
|
<a-descriptions title="原始数据 (raw)" :column="1" bordered>
|
||||||
|
<a-descriptions-item>
|
||||||
|
<pre v-if="rawDetail?.raw" class="m-0 text-xs max-h-80 overflow-auto">{{
|
||||||
|
JSON.stringify(rawDetail.raw, null, 2)
|
||||||
|
}}</pre>
|
||||||
|
<span v-else class="text-gray-400">暂无数据</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</template>
|
||||||
|
</a-spin>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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<string, unknown> | 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<string, unknown> | null
|
||||||
|
hash: string
|
||||||
|
ext: Record<string, unknown> | 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<ProductRecord[]>([])
|
||||||
|
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<ProductFilters>({
|
||||||
|
name: '',
|
||||||
|
platform_item_id: '',
|
||||||
|
status_id: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 名称映射数据
|
||||||
|
const companies = ref<LookupItem[]>([])
|
||||||
|
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<LookupItem[]>('/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<PaginatedData<ProductRecord>>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user