update sku mapping

This commit is contained in:
2026-04-14 15:45:29 +08:00
parent b1cd4ea0eb
commit 417ac5e1f9
11 changed files with 1454 additions and 75 deletions
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
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(),
delete: vi.fn(),
},
}))
import { api } from '@/utils/request'
const mockMappings = {
items: [
{ id: 1, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
],
total: 1,
page: 1,
per_page: 15,
}
const mockCompanies = [{ id: 3, name: 'Acme Corp', label: 'Acme' }]
const mockPlatforms = [{ id: 1, name: 'Amazon', label: null }]
const mockStores = [{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null }]
const mockSkuOrigins = {
items: [{ id: 1, company_id: 3, sku: '0032', name: 'Test SKU' }],
total: 1,
page: 1,
per_page: 100,
}
function setupApiMocks() {
vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/sku-mappings') return Promise.resolve(mockMappings) as never
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
if (url === '/api/v1/sku-origins') return Promise.resolve(mockSkuOrigins) as never
return Promise.resolve({ items: [], total: 0, page: 1, per_page: 15 }) as never
})
}
describe('SkuMappingsPage', () => {
let wrapper: ReturnType<typeof mount>
const stubs = {
SearchOutlined: { template: '<span />' },
ReloadOutlined: { template: '<span />' },
PlusOutlined: { template: '<span />' },
EditOutlined: { template: '<span />' },
DeleteOutlined: { template: '<span />' },
ThunderboltOutlined: { template: '<span />' },
QuestionCircleOutlined: { template: '<span />' },
CascadeFilter: { template: '<div class="cascade-stub" />' },
}
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
async function mountPage() {
setupApiMocks()
const { default: SkuMappingsPage } = await import('../index.vue')
wrapper = mount(SkuMappingsPage, {
attachTo: document.body,
global: { stubs },
})
await flushPromises()
await nextTick()
}
it('P1: renders page title as SKU 连接管理', async () => {
await mountPage()
expect(wrapper.text()).toContain('SKU 连接管理')
expect(wrapper.text()).not.toContain('SKU 映射管理')
}, 15000)
it('P2: calls fetchItems on mount', async () => {
await mountPage()
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-mappings', expect.any(Object))
})
it('P3: renders table with mapping data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('AMZ-0032')
expect(html).toContain('ITEM-001')
})
it('P4: create button uses 连接 terminology', async () => {
await mountPage()
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(false)
})
it('P5: platform_product_id has optional placeholder', async () => {
await mountPage()
// Open create modal
const newBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('新建连接'))
await newBtn?.trigger('click')
await flushPromises()
await nextTick()
// Modal teleports to document.body
const bodyHtml = document.body.innerHTML
expect(bodyHtml).toContain('可选')
})
it('P6: search and reset buttons work', async () => {
await mountPage()
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)
})
})
+113 -65
View File
@@ -33,7 +33,7 @@ const columns = [
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建映射')
const modalTitle = ref('新建连接')
const editingId = ref<number | null>(null)
const formRef = ref()
const saving = ref(false)
@@ -87,15 +87,41 @@ const filteredStoreOptions = computed(() => {
}))
})
// Filtered SKU origins by company
const filteredSkuOrigins = computed(() => {
if (!form.company_id) return store.skuOrigins
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
})
// Remote search for SKU origins
const skuSearchOptions = ref<{ value: number; label: string }[]>([])
const skuSearching = ref(false)
let skuSearchTimer: ReturnType<typeof setTimeout> | null = null
function handleSkuSearch(keyword: string) {
if (skuSearchTimer) clearTimeout(skuSearchTimer)
if (!form.company_id) {
skuSearchOptions.value = []
return
}
if (keyword.length < 3) {
skuSearchOptions.value = []
return
}
skuSearching.value = true
skuSearchTimer = setTimeout(async () => {
try {
const items = await store.searchSkuOrigins(form.company_id!, keyword)
skuSearchOptions.value = items.map((s) => ({
value: s.id,
label: `${s.sku} - ${s.name}`,
}))
} catch {
skuSearchOptions.value = []
} finally {
skuSearching.value = false
}
}, 300)
}
onMounted(() => {
store.loadLookups()
store.loadSkuOrigins()
store.fetchItems()
})
@@ -123,13 +149,14 @@ function formatTime(time: string | null) {
function openCreate() {
Object.assign(form, defaultForm())
editingId.value = null
modalTitle.value = '新建映射'
modalTitle.value = '新建连接'
skuSearchOptions.value = []
modalVisible.value = true
}
function openEdit(record: SkuMappingForm & { id: number }) {
editingId.value = record.id
modalTitle.value = '编辑映射'
modalTitle.value = '编辑连接'
Object.assign(form, {
company_id: record.company_id,
platform_id: record.platform_id,
@@ -143,9 +170,17 @@ function openEdit(record: SkuMappingForm & { id: number }) {
enabled: record.enabled ?? true,
note: record.note || '',
})
// 从已有列表数据预填 origin SKU 选项,无需额外 API 调用
skuSearchOptions.value = record.origin_sku_id
? [{ value: record.origin_sku_id as number, label: record.origin_sku || '' }]
: []
modalVisible.value = true
}
function handleModalClose() {
skuSearchOptions.value = []
}
async function handleSubmit() {
try {
await formRef.value.validate()
@@ -160,6 +195,7 @@ async function handleSubmit() {
await store.createItem({ ...form })
}
modalVisible.value = false
handleModalClose()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
@@ -168,10 +204,14 @@ async function handleSubmit() {
}
}
function handleDelete(record: { id: number; origin_sku: string; platform_outer_sku: string | null }) {
function handleDelete(record: {
id: number
origin_sku: string
platform_outer_sku: string | null
}) {
Modal.confirm({
title: '确认删除',
content: `确定删除映射${record.origin_sku}${record.platform_outer_sku || '-'}」?`,
content: `确定删除连接${record.origin_sku}${record.platform_outer_sku || '-'}」?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
@@ -196,7 +236,7 @@ function openGenerate() {
return
}
genForm.strategy = 'prefix'
genForm.prefix = ''
genForm.prefix = form.company_id ? `C${String(form.company_id).padStart(4, '0')}` : ''
genForm.random_length = 4
genForm.manual_value = ''
genResult.value = ''
@@ -244,18 +284,20 @@ function applyGenerated() {
}
}
// Watch company change to reload SKU origins
// Watch company change to reset SKU search
watch(
() => form.company_id,
(val) => {
store.loadSkuOrigins(val)
() => {
form.origin_sku_id = undefined
form.origin_sku = ''
skuSearchOptions.value = []
},
)
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
<h2 class="text-xl font-semibold mb-4">SKU 连接管理</h2>
<!-- Filter area -->
<a-card class="mb-6">
@@ -311,7 +353,7 @@ watch(
<span class="text-gray-500"> {{ store.pagination.total }} 条记录</span>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新建映射
新建连接
</a-button>
</div>
@@ -378,29 +420,27 @@ watch(
:title="modalTitle"
:confirm-loading="saving"
@ok="handleSubmit"
@cancel="modalVisible = false"
@cancel="modalVisible = false; handleModalClose()"
:width="640"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
class="mt-4"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" class="mt-4">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="公司" name="company_id">
<a-select
v-model:value="form.company_id"
placeholder="请选择公司"
:options="store.companies.map(c => ({
value: c.id,
label: (c.label && c.label !== 'null') ? c.label : c.name
}))"
:options="
store.companies.map((c) => ({
value: c.id,
label: c.label && c.label !== 'null' ? c.label : c.name,
}))
"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
:filter-option="
(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())
"
/>
</a-form-item>
</a-col>
@@ -409,13 +449,17 @@ watch(
<a-select
v-model:value="form.platform_id"
placeholder="请选择平台"
:options="store.platforms.map(p => ({
value: p.id,
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
}))"
:options="
store.platforms.map((p) => ({
value: p.id,
label: p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
}))
"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
:filter-option="
(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())
"
/>
</a-form-item>
</a-col>
@@ -430,8 +474,10 @@ watch(
:options="filteredStoreOptions"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
:filter-option="
(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())
"
/>
</a-form-item>
</a-col>
@@ -439,37 +485,35 @@ watch(
<a-form-item label="内部 SKU" name="origin_sku">
<a-select
v-model:value="form.origin_sku_id"
placeholder="选择已有 SKU"
:options="filteredSkuOrigins.map(s => ({
value: s.id,
label: `${s.sku} - ${s.name}`
}))"
:placeholder="!form.company_id ? '请先选择公司' : '搜索(最少3个字)或输入SKU'"
:options="skuSearchOptions"
:loading="skuSearching"
:disabled="!form.company_id"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
@change="(val: number | undefined) => {
const found = store.skuOrigins.find(s => s.id === val)
form.origin_sku = found?.sku || ''
}"
/>
<a-input
v-if="!form.origin_sku_id"
v-model:value="form.origin_sku"
placeholder="或手动输入内部 SKU"
class="mt-2"
:filter-option="false"
:not-found-content="skuSearching ? '搜索中...' : '无匹配结果'"
@search="handleSkuSearch"
@change="
(val: number | undefined) => {
const found = skuSearchOptions.find((s) => s.value === val)
form.origin_sku = found ? found.label.split(' - ')[0] : ''
}
"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="16">
<a-col :span="12">
<a-form-item name="platform_outer_sku">
<template #label>
<span>
平台 SKU
<a-tooltip title="电商平台侧使用的 SKU 编码需符合平台填写规则如不能以 0 开头不能含特殊字符等">
<a-tooltip
title="电商平台侧使用的 SKU 编码需符合平台填写规则如不能以 0 开头不能含特殊字符等"
>
<QuestionCircleOutlined class="ml-1 text-gray-400" />
</a-tooltip>
</span>
@@ -483,20 +527,24 @@ watch(
</a-input>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="状态">
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
<a-col :span="12">
<a-form-item label="平台商品 ID" name="platform_product_id">
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="平台商品 ID" name="platform_product_id">
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
</a-form-item>
<a-form-item label="状态">
<a-switch
v-model:checked="form.enabled"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
</a-form>
</a-modal>
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
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(),
delete: vi.fn(),
},
}))
import { api } from '@/utils/request'
const mockPaginated = {
items: [
{ id: 1, company_id: 3, sku: '0032', barcode: '6901234567890', name: 'Test SKU', label: null, hs: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
],
total: 1,
page: 1,
per_page: 15,
}
const mockCompanies = [{ id: 3, name: 'Acme Corp', label: 'Acme' }]
const mockPlatforms = [{ id: 1, name: 'Amazon', label: null }]
const mockStores = [{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null }]
const mockMappings = {
items: [
{ id: 10, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
],
total: 1,
page: 1,
per_page: 50,
}
function setupApiMocks() {
vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/sku-origins') return Promise.resolve(mockPaginated) as never
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
if (url === '/api/v1/sku-mappings') return Promise.resolve(mockMappings) as never
return Promise.resolve({ items: [], total: 0, page: 1, per_page: 15 }) as never
})
}
describe('SkuOriginsPage', () => {
let wrapper: ReturnType<typeof mount>
const stubs = {
SearchOutlined: { template: '<span />' },
ReloadOutlined: { template: '<span />' },
PlusOutlined: { template: '<span />' },
EditOutlined: { template: '<span />' },
DeleteOutlined: { template: '<span />' },
LinkOutlined: { template: '<span />' },
CascadeFilter: { template: '<div class="cascade-stub" />' },
}
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
async function mountPage() {
setupApiMocks()
const { default: SkuOriginsPage } = await import('../index.vue')
wrapper = mount(SkuOriginsPage, {
attachTo: document.body,
global: { stubs },
})
await flushPromises()
await nextTick()
}
it('P1: renders page title and action buttons', async () => {
await mountPage()
expect(wrapper.text()).toContain('内部 SKU 管理')
expect(wrapper.text()).toContain('新建 SKU')
}, 15000)
it('P2: calls fetchItems on mount', async () => {
await mountPage()
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.any(Object))
})
it('P3: renders table with sku data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('0032')
expect(html).toContain('Test SKU')
})
it('P4: search and reset buttons exist', async () => {
await mountPage()
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)
})
it('P5: barcode field is required in create modal', async () => {
await mountPage()
// Open create modal
const newBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('新建 SKU'))
await newBtn?.trigger('click')
await flushPromises()
await nextTick()
// Modal teleports to document.body
const bodyHtml = document.body.innerHTML
expect(bodyHtml).toContain('条形码 / GTIN 编码')
})
it('P6: new link button exists in action column', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('新建链接')
})
it('P7: terminology uses 链接 not 映射', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('链接')
expect(html).not.toContain('映射')
})
})
+188 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
import { useSkuMappingStore, type SkuMappingForm } from '@/stores/sku-mapping'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
@@ -7,23 +8,117 @@ import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
LinkOutlined,
} from '@ant-design/icons-vue'
import type { Rule } from 'ant-design-vue/es/form'
const store = useSkuOriginStore()
const mappingStore = useSkuMappingStore()
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '公司', key: 'company', width: 140 },
{ title: 'SKU', dataIndex: 'sku', width: 160 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '名称', dataIndex: 'name', ellipsis: true, customCell: () => ({ class: 'min-w-40' }) },
{ title: '条形码', dataIndex: 'barcode', width: 160 },
{ title: '标签', dataIndex: 'label', width: 120 },
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
{ title: '操作', key: 'action', width: 320, fixed: 'right' as const },
]
// Expand row
const expandedRowKeys = ref<number[]>([])
const mappingColumns = [
{ title: '平台', key: 'platform', width: 100 },
{ title: '店铺', key: 'store', width: 140 },
{ title: '平台 SKU', dataIndex: 'platform_outer_sku', width: 160 },
{ title: '平台商品 ID', dataIndex: 'platform_product_id', width: 160, ellipsis: true },
{ title: '状态', key: 'enabled', width: 80 },
]
function onExpand(expanded: boolean, record: { id: number }) {
if (expanded) {
store.fetchMappings(record.id)
}
}
// Create mapping modal
const mappingModalVisible = ref(false)
const mappingSaving = ref(false)
const mappingFormRef = ref()
const mappingOriginRecord = ref<{ id: number; company_id: number; sku: string } | null>(null)
const defaultMappingForm = () => ({
platform_id: undefined as number | undefined,
store_id: undefined as number | undefined,
platform_outer_sku: '',
platform_product_id: '',
})
const mappingForm = reactive(defaultMappingForm())
const mappingRules: Record<string, Rule[]> = {
platform_id: [{ required: true, message: '请选择平台', trigger: 'change' }],
platform_outer_sku: [{ required: true, message: '请输入平台 SKU', trigger: 'blur' }],
}
const filteredMappingStores = computed(() => {
let filtered = store.stores
if (mappingOriginRecord.value?.company_id) {
filtered = filtered.filter((s) => s.company_id === mappingOriginRecord.value!.company_id)
}
if (mappingForm.platform_id) {
filtered = filtered.filter((s) => s.platform_id === mappingForm.platform_id)
}
return filtered.map((s) => ({
value: s.id,
label: s.label && s.label !== 'null' ? s.label : s.name,
}))
})
function openCreateMapping(record: { id: number; company_id: number; sku: string }) {
mappingOriginRecord.value = record
Object.assign(mappingForm, defaultMappingForm())
mappingModalVisible.value = true
}
async function handleMappingSubmit() {
try {
await mappingFormRef.value.validate()
} catch {
return
}
if (!mappingOriginRecord.value) return
mappingSaving.value = true
try {
const payload: SkuMappingForm = {
company_id: mappingOriginRecord.value.company_id,
platform_id: mappingForm.platform_id,
store_id: mappingForm.store_id,
origin_sku: mappingOriginRecord.value.sku,
origin_sku_id: mappingOriginRecord.value.id,
platform_product_id: mappingForm.platform_product_id,
platform_outer_sku: mappingForm.platform_outer_sku,
generation_strategy: 'manual',
warehouse_id: undefined,
enabled: true,
note: '',
}
await mappingStore.createItem(payload)
mappingModalVisible.value = false
// 刷新展开行缓存
store.mappingsCache.delete(mappingOriginRecord.value.id)
store.fetchMappings(mappingOriginRecord.value.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '创建失败'
message.error(msg)
} finally {
mappingSaving.value = false
}
}
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建内部 SKU')
@@ -47,6 +142,7 @@ const rules: Record<string, Rule[]> = {
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
barcode: [{ required: true, message: '请输入条形码', trigger: 'blur' }],
}
onMounted(() => {
@@ -122,7 +218,7 @@ async function handleSubmit() {
function handleDelete(record: { id: number; sku: string }) {
Modal.confirm({
title: '确认删除',
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被链接引用将无法删除。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
@@ -204,7 +300,39 @@ function handleDelete(record: { id: number; sku: string }) {
:pagination="false"
row-key="id"
:scroll="{ x: 1200 }"
:expand-column-width="48"
v-model:expandedRowKeys="expandedRowKeys"
@expand="onExpand"
>
<template #expandedRowRender="{ record }">
<a-spin v-if="store.mappingsLoading.has(record.id)" />
<a-empty v-else-if="!store.mappingsCache.get(record.id)?.length" description="暂无平台链接" />
<a-table
v-else
:columns="mappingColumns"
:data-source="store.mappingsCache.get(record.id) || []"
:pagination="false"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record: mapping }">
<template v-if="column.key === 'platform'">
{{ store.platformMap.get(mapping.platform_id) || mapping.platform_id }}
</template>
<template v-else-if="column.key === 'store'">
<template v-if="mapping.store_id">
{{ store.storeMap.get(mapping.store_id) || mapping.store_id }}
</template>
<span v-else class="text-gray-400">平台默认</span>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="mapping.enabled ? 'green' : 'default'">
{{ mapping.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
</template>
</a-table>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'company'">
{{ store.companyMap.get(record.company_id) || record.company_id }}
@@ -214,6 +342,10 @@ function handleDelete(record: { id: number; sku: string }) {
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openCreateMapping(record)">
<template #icon><LinkOutlined /></template>
新建链接
</a-button>
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
@@ -276,7 +408,7 @@ function handleDelete(record: { id: number; sku: string }) {
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
</a-form-item>
<a-form-item label="条形码" name="barcode">
<a-input v-model:value="form.barcode" placeholder="可选" />
<a-input v-model:value="form.barcode" placeholder="条形码 / GTIN 编码" />
</a-form-item>
<a-form-item label="标签" name="label">
<a-input v-model:value="form.label" placeholder="可选别名" />
@@ -286,6 +418,58 @@ function handleDelete(record: { id: number; sku: string }) {
</a-form-item>
</a-form>
</a-modal>
<!-- Create Mapping Modal -->
<a-modal
v-model:open="mappingModalVisible"
title="新建平台 SKU 链接"
:confirm-loading="mappingSaving"
@ok="handleMappingSubmit"
@cancel="mappingModalVisible = false"
:width="520"
>
<a-form
ref="mappingFormRef"
:model="mappingForm"
:rules="mappingRules"
layout="vertical"
class="mt-4"
>
<a-form-item label="内部 SKU">
<a-input :value="mappingOriginRecord?.sku" disabled />
</a-form-item>
<a-form-item label="平台" name="platform_id">
<a-select
v-model:value="mappingForm.platform_id"
placeholder="请选择平台"
:options="store.platforms.map(p => ({
value: p.id,
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="店铺" name="store_id">
<a-select
v-model:value="mappingForm.store_id"
placeholder="留空表示平台默认"
:options="filteredMappingStores"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="平台 SKU" name="platform_outer_sku">
<a-input v-model:value="mappingForm.platform_outer_sku" placeholder="平台侧 SKU 编码" />
</a-form-item>
<a-form-item label="平台商品 ID" name="platform_product_id">
<a-input v-model:value="mappingForm.platform_product_id" placeholder="可选" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/utils/request', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
import { api } from '@/utils/request'
import { useSkuOriginStore } from '../sku-origin'
const mockItems = [
{ id: 1, company_id: 3, sku: '0032', barcode: '6901234567890', name: 'Test SKU', label: null, hs: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
{ id: 2, company_id: 3, sku: '0033', barcode: '6901234567891', name: 'Test SKU 2', label: 'alias', hs: '6403', created_at: '2026-04-02T00:00:00', updated_at: '2026-04-02T00:00:00' },
]
const mockPaginated = {
items: mockItems,
total: 2,
page: 1,
per_page: 15,
}
const mockCompanies = [
{ id: 3, name: 'Acme Corp', label: 'Acme' },
]
const mockPlatforms = [
{ id: 1, name: 'Amazon', label: null },
]
const mockStores = [
{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null },
]
const mockMappings = {
items: [
{ id: 10, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
],
total: 1,
page: 1,
per_page: 50,
}
describe('useSkuOriginStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with empty items and default pagination', () => {
const store = useSkuOriginStore()
expect(store.items).toEqual([])
expect(store.pagination.page).toBe(1)
expect(store.pagination.per_page).toBe(15)
expect(store.pagination.total).toBe(0)
expect(store.loading).toBe(false)
})
})
describe('fetchItems', () => {
it('loads data and updates state', async () => {
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
const store = useSkuOriginStore()
await store.fetchItems()
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
page: 1,
per_page: 15,
}))
expect(store.items).toEqual(mockItems)
expect(store.pagination.total).toBe(2)
})
it('passes filter params', async () => {
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
const store = useSkuOriginStore()
store.filters.sku = '0032'
store.filters.barcode = '690'
store.cascadeValue.company_id = 3
await store.fetchItems()
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
sku: '0032',
barcode: '690',
company_id: 3,
}))
})
it('clears data on error', async () => {
vi.mocked(api.get).mockRejectedValueOnce(new Error('网络错误'))
const store = useSkuOriginStore()
await store.fetchItems()
expect(store.items).toEqual([])
expect(store.pagination.total).toBe(0)
})
})
describe('resetFilters', () => {
it('clears all filters and pagination', () => {
const store = useSkuOriginStore()
store.filters.sku = 'test'
store.filters.barcode = '123'
store.cascadeValue.company_id = 5
store.pagination.page = 3
store.resetFilters()
expect(store.filters.sku).toBe('')
expect(store.filters.barcode).toBe('')
expect(store.cascadeValue.company_id).toBeUndefined()
expect(store.pagination.page).toBe(1)
})
})
describe('createItem', () => {
it('calls API and refreshes list', async () => {
vi.mocked(api.post).mockResolvedValueOnce({})
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
const store = useSkuOriginStore()
await store.createItem({
company_id: 3,
sku: 'NEW-SKU',
barcode: '1234567890123',
name: 'New SKU',
label: '',
hs: '',
ledger: '',
})
expect(api.post).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
sku: 'NEW-SKU',
}))
expect(api.get).toHaveBeenCalled()
})
})
describe('updateItem', () => {
it('calls API and refreshes list', async () => {
vi.mocked(api.put).mockResolvedValueOnce({})
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
const store = useSkuOriginStore()
await store.updateItem(1, { name: 'Updated' })
expect(api.put).toHaveBeenCalledWith('/api/v1/sku-origins/1', { name: 'Updated' })
expect(api.get).toHaveBeenCalled()
})
})
describe('deleteItem', () => {
it('calls API and refreshes list', async () => {
vi.mocked(api.delete).mockResolvedValueOnce({})
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
const store = useSkuOriginStore()
await store.deleteItem(1)
expect(api.delete).toHaveBeenCalledWith('/api/v1/sku-origins/1')
expect(api.get).toHaveBeenCalled()
})
})
describe('fetchMappings', () => {
it('loads and caches mapping data', async () => {
vi.mocked(api.get).mockResolvedValueOnce(mockMappings)
const store = useSkuOriginStore()
await store.fetchMappings(1)
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-mappings', {
origin_sku_id: 1,
per_page: 50,
})
expect(store.mappingsCache.get(1)).toEqual(mockMappings.items)
})
it('sets loading state correctly', async () => {
let resolvePromise: (value: unknown) => void
const pending = new Promise((resolve) => { resolvePromise = resolve })
vi.mocked(api.get).mockReturnValueOnce(pending as never)
const store = useSkuOriginStore()
const fetchPromise = store.fetchMappings(1)
expect(store.mappingsLoading.has(1)).toBe(true)
resolvePromise!(mockMappings)
await fetchPromise
expect(store.mappingsLoading.has(1)).toBe(false)
})
it('caches empty array on error', async () => {
vi.mocked(api.get).mockRejectedValueOnce(new Error('失败'))
const store = useSkuOriginStore()
await store.fetchMappings(99)
expect(store.mappingsCache.get(99)).toEqual([])
})
})
describe('lookup maps', () => {
it('builds companyMap correctly', async () => {
vi.mocked(api.get)
.mockResolvedValueOnce(mockCompanies)
.mockResolvedValueOnce(mockPlatforms)
.mockResolvedValueOnce(mockStores)
const store = useSkuOriginStore()
await store.loadLookups()
expect(store.companyMap.get(3)).toBe('Acme')
})
it('builds platformMap correctly', async () => {
vi.mocked(api.get)
.mockResolvedValueOnce(mockCompanies)
.mockResolvedValueOnce(mockPlatforms)
.mockResolvedValueOnce(mockStores)
const store = useSkuOriginStore()
await store.loadLookups()
expect(store.platformMap.get(1)).toBe('Amazon')
})
it('builds storeMap correctly', async () => {
vi.mocked(api.get)
.mockResolvedValueOnce(mockCompanies)
.mockResolvedValueOnce(mockPlatforms)
.mockResolvedValueOnce(mockStores)
const store = useSkuOriginStore()
await store.loadLookups()
expect(store.storeMap.get(101)).toBe('Store A')
})
})
})
+10
View File
@@ -157,6 +157,15 @@ export const useSkuMappingStore = defineStore('skuMapping', () => {
}
}
async function searchSkuOrigins(company_id: number, sku: string) {
const data = await api.get<PaginatedData<{ id: number; company_id: number; sku: string; name: string }>>('/api/v1/sku-origins', {
company_id,
sku,
per_page: 20,
})
return data.items
}
async function fetchItems() {
loading.value = true
try {
@@ -240,6 +249,7 @@ export const useSkuMappingStore = defineStore('skuMapping', () => {
skuOrigins,
loadLookups,
loadSkuOrigins,
searchSkuOrigins,
fetchItems,
resetFilters,
createItem,
+60 -3
View File
@@ -1,5 +1,6 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { SkuMappingRecord } from '@/stores/sku-mapping'
// ─── Types ───
@@ -59,6 +60,11 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
// Lookup maps
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
const platforms = ref<{ id: number; name: string; label: string | null }[]>([])
const stores = ref<
{ id: number; company_id: number; platform_id: number; name: string; label: string | null }[]
>([])
const companyMap = computed(
() =>
new Map(
@@ -68,17 +74,61 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
]),
),
)
const platformMap = computed(
() =>
new Map(
platforms.value.map((p) => [
p.id,
p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
]),
),
)
const storeMap = computed(
() =>
new Map(
stores.value.map((s) => [
s.id,
s.label && s.label !== 'null' ? s.label : s.name,
]),
),
)
// Mappings expand cache
const mappingsCache = ref<Map<number, SkuMappingRecord[]>>(new Map())
const mappingsLoading = ref<Set<number>>(new Set())
async function loadLookups() {
try {
companies.value = await api.get<{ id: number; name: string; label: string | null }[]>(
'/api/v1/companies',
)
const [c, p, s] = await Promise.all([
api.get<typeof companies.value>('/api/v1/companies'),
api.get<typeof platforms.value>('/api/v1/platforms'),
api.get<typeof stores.value>('/api/v1/stores'),
])
companies.value = c
platforms.value = p
stores.value = s
} catch (err) {
console.warn('加载查找表数据失败', err)
}
}
async function fetchMappings(originSkuId: number) {
if (mappingsLoading.value.has(originSkuId)) return
mappingsLoading.value.add(originSkuId)
try {
const data = await api.get<PaginatedData<SkuMappingRecord>>('/api/v1/sku-mappings', {
origin_sku_id: originSkuId,
per_page: 50,
})
mappingsCache.value.set(originSkuId, data.items)
} catch (err) {
console.warn('加载关联链接数据失败', err)
mappingsCache.value.set(originSkuId, [])
} finally {
mappingsLoading.value.delete(originSkuId)
}
}
async function fetchItems() {
loading.value = true
try {
@@ -138,9 +188,16 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
cascadeValue,
filters,
companies,
platforms,
stores,
companyMap,
platformMap,
storeMap,
mappingsCache,
mappingsLoading,
loadLookups,
fetchItems,
fetchMappings,
resetFilters,
createItem,
updateItem,