add order items

This commit is contained in:
2026-03-19 16:29:26 +08:00
parent 9a78398cfe
commit 419778a53b
3 changed files with 990 additions and 0 deletions
@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { useOrderItemStore } from '@/stores/order-item'
import type { OrderItemRecord } from '@/stores/order'
// jsdom 不支持 matchMediaAnt Design Vue 响应式布局需要
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
vi.mock('@/utils/request', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
},
}))
const mockOrderItems: OrderItemRecord[] = [
{
id: 101,
company_id: 1,
platform_id: 1,
store_id: 1,
order_id: 1,
platform_order_id: 'ORD-20260101-001',
sub_order_id: 'SUB-001',
sub_order_type_id: 1,
product_id: 500,
platform_product_id: 'PROD-001',
product_sku: 'SKU-ABC-001',
product_barcode: '6901234567890',
unit_price: '99.99',
quantity: 2,
discount: '10.00',
total: '189.98',
created_date: '2026-01-01T10:00:00Z',
ext: null,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
},
{
id: 102,
company_id: 2,
platform_id: 1,
store_id: 2,
order_id: 2,
platform_order_id: 'ORD-20260201-002',
sub_order_id: null,
sub_order_type_id: 1,
product_id: 501,
platform_product_id: 'PROD-002',
product_sku: null,
product_barcode: null,
unit_price: '59.00',
quantity: 1,
discount: '0',
total: '59.00',
created_date: '2026-02-01T14:00:00Z',
ext: { color: 'red' },
created_at: '2026-02-01T14:00:00Z',
updated_at: '2026-02-01T14:00:00Z',
},
]
const mockPaginatedResponse = {
items: mockOrderItems,
total: 2,
page: 1,
per_page: 15,
}
const mockItemDetail = {
...mockOrderItems[0],
parent_order: {
id: 1,
platform_order_id: 'ORD-20260101-001',
order_status_id: 3,
total_amount: '199.99',
total_paid: '189.99',
created_date: '2026-01-01T10:00:00Z',
paid_date: '2026-01-01T10:05:00Z',
},
}
const mockLookupCompanies = [
{ id: 1, name: 'Company A', label: '公司A' },
{ id: 2, name: 'Company B', label: '公司B' },
]
const mockLookupPlatforms = [{ id: 1, developer_id: 1 }]
const mockLookupStores = [
{ id: 1, company_id: 1, platform_id: 1, name: 'Store 1', label: '店铺1' },
{ id: 2, company_id: 2, platform_id: 1, name: 'Store 2', label: '店铺2' },
]
// ─── Store Tests ───────────────────────────────────────
describe('useOrderItemStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with empty orderItems and default pagination', () => {
const store = useOrderItemStore()
expect(store.orderItems).toEqual([])
expect(store.loading).toBe(false)
expect(store.pagination.page).toBe(1)
expect(store.pagination.per_page).toBe(15)
expect(store.pagination.total).toBe(0)
expect(store.filters.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).toBe('')
expect(store.filters.created_date_range).toBeNull()
})
})
describe('fetchOrderItems', () => {
it('loads data and updates state', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
const store = useOrderItemStore()
await store.fetchOrderItems()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
page: 1,
per_page: 15,
platform_order_id: undefined,
platform_product_id: undefined,
product_sku: undefined,
created_date_from: undefined,
created_date_to: undefined,
}),
)
expect(store.orderItems).toEqual(mockOrderItems)
expect(store.pagination.total).toBe(2)
})
it('passes filter params correctly', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
const store = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
store.filters.created_date_range = ['2026-01-01', '2026-01-31']
store.cascadeValue.company_id = 1
store.cascadeValue.platform_id = 2
store.cascadeValue.store_id = 3
await store.fetchOrderItems()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
platform_order_id: 'ORD-001',
platform_product_id: 'PROD-001',
product_sku: 'SKU-ABC',
created_date_from: '2026-01-01',
created_date_to: '2026-01-31',
company_id: 1,
platform_id: 2,
store_id: 3,
}),
)
})
it('shows error message on failure', async () => {
const { api } = await import('@/utils/request')
const { message } = await import('ant-design-vue')
const errorSpy = vi.spyOn(message, 'error')
vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error'))
const store = useOrderItemStore()
await store.fetchOrderItems()
expect(errorSpy).toHaveBeenCalledWith('Network error')
expect(store.orderItems).toEqual([])
})
it('clears orderItems on fetch failure', async () => {
const { api } = await import('@/utils/request')
const { message } = await import('ant-design-vue')
vi.spyOn(message, 'error')
const store = useOrderItemStore()
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
await store.fetchOrderItems()
expect(store.orderItems.length).toBe(2)
vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error'))
await store.fetchOrderItems()
expect(store.orderItems).toEqual([])
expect(store.pagination.total).toBe(0)
})
})
describe('resetFilters', () => {
it('clears all filters and resets page to 1', () => {
const store = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
store.filters.created_date_range = ['2026-01-01', '2026-01-31']
store.cascadeValue.company_id = 1
store.cascadeValue.platform_id = 2
store.cascadeValue.store_id = 3
store.pagination.page = 5
store.resetFilters()
expect(store.filters.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).toBe('')
expect(store.filters.created_date_range).toBeNull()
expect(store.cascadeValue.company_id).toBeUndefined()
expect(store.cascadeValue.platform_id).toBeUndefined()
expect(store.cascadeValue.store_id).toBeUndefined()
expect(store.pagination.page).toBe(1)
})
})
describe('name maps', () => {
it('builds lookup maps from loaded data', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get)
.mockResolvedValueOnce(mockLookupCompanies)
.mockResolvedValueOnce(mockLookupPlatforms)
.mockResolvedValueOnce(mockLookupStores)
const store = useOrderItemStore()
await store.loadLookups()
expect(store.companyMap.get(1)).toBe('公司A')
expect(store.platformMap.get(1)).toBe('平台 #1')
expect(store.storeMap.get(1)).toBe('店铺1')
})
it('handles loadLookups failure gracefully', async () => {
const { api } = await import('@/utils/request')
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error'))
const store = useOrderItemStore()
await store.loadLookups()
expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error))
expect(store.companyMap.size).toBe(0)
warnSpy.mockRestore()
})
})
})
// ─── Page Component Tests ──────────────────────────────
describe('OrderItemsPage', () => {
let wrapper: ReturnType<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/order-items') return Promise.resolve(mockPaginatedResponse) as never
if (url === '/api/v1/companies') return Promise.resolve(mockLookupCompanies) as never
if (url === '/api/v1/platforms') return Promise.resolve(mockLookupPlatforms) as never
if (url === '/api/v1/stores') return Promise.resolve(mockLookupStores) as never
return Promise.resolve([]) as never
})
const { default: OrderItemsPage } = await import('../index.vue')
wrapper = mount(OrderItemsPage, {
attachTo: document.body,
global: {
stubs: {
SearchOutlined: { template: '<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 fetchOrderItems and loadLookups on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
page: 1,
per_page: 15,
}),
)
expect(api.get).toHaveBeenCalledWith('/api/v1/companies')
expect(api.get).toHaveBeenCalledWith('/api/v1/platforms')
expect(api.get).toHaveBeenCalledWith('/api/v1/stores')
})
it('renders table with order item data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('ORD-20260101-001')
expect(html).toContain('SKU-ABC-001')
expect(html).toContain('99.99')
expect(html).toContain('189.98')
})
it('renders company/platform/store names from lookup maps', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('公司A')
expect(html).toContain('店铺1')
})
it('formats amounts correctly (dash for zero values)', async () => {
await mountPage()
// mockOrderItems[1] has discount '0' which should render as '-'
const html = wrapper.html()
expect(html).toContain('59.00')
})
it('renders CascadeFilter component', async () => {
await mountPage()
expect(wrapper.find('.cascade-filter-stub').exists()).toBe(true)
})
it('search button resets page to 1 and fetches', async () => {
const { api } = await mountPage()
const store = useOrderItemStore()
store.pagination.page = 3
store.filters.platform_order_id = 'ORD-001'
vi.mocked(api.get).mockClear()
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse)
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const searchBtn = buttons.find((b) => b.textContent?.trim().includes('搜索'))
searchBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(store.pagination.page).toBe(1)
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
page: 1,
platform_order_id: 'ORD-001',
}),
)
})
it('resets filters when clicking reset button', async () => {
await mountPage()
const store = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
store.filters.created_date_range = ['2026-01-01', '2026-01-31']
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const resetBtn = buttons.find((b) => b.textContent?.trim().includes('重置'))
expect(resetBtn).toBeDefined()
resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(store.filters.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).toBe('')
expect(store.filters.created_date_range).toBeNull()
})
it('opens detail drawer when clicking view button', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce(mockItemDetail)
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看')
expect(viewBtn).toBeDefined()
viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const drawer = document.body.querySelector('.ant-drawer')
expect(drawer).toBeTruthy()
})
it('displays parent order summary in drawer', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce(mockItemDetail)
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看')
viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || ''
expect(drawerHtml).toContain('父订单摘要')
expect(drawerHtml).toContain('ORD-20260101-001')
expect(drawerHtml).toContain('199.99')
expect(drawerHtml).toContain('189.99')
expect(drawerHtml).toContain('已支付')
})
it('displays ext JSON in drawer when available', async () => {
const { api } = await mountPage()
const detailWithExt = {
...mockItemDetail,
ext: { color: 'red', size: 'XL' },
}
vi.mocked(api.get).mockResolvedValueOnce(detailWithExt)
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看')
viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || ''
expect(drawerHtml).toContain('color')
expect(drawerHtml).toContain('red')
})
it('shows placeholder when ext is null', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce({ ...mockItemDetail, ext: null })
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看')
viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || ''
expect(drawerHtml).toContain('暂无数据')
})
it('shows error message when detail request fails', async () => {
const { api } = await mountPage()
const { message } = await import('ant-design-vue')
const errorSpy = vi.spyOn(message, 'error')
vi.mocked(api.get).mockRejectedValueOnce(new Error('Not found'))
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看')
viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(errorSpy).toHaveBeenCalledWith('获取订单项详情失败')
})
})
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { api } from '@/utils/request'
import {
useOrderItemStore,
type OrderItemDetail,
} from '@/stores/order-item'
import type { OrderItemRecord } from '@/stores/order'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
} from '@ant-design/icons-vue'
const store = useOrderItemStore()
// Detail drawer
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const itemDetail = ref<OrderItemDetail | null>(null)
let detailRequestId = 0
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80, fixed: 'left' as const },
{ title: '公司', key: 'company', width: 100 },
{ title: '平台', key: 'platform', width: 100 },
{ title: '店铺', key: 'store', width: 120 },
{ title: '平台订单ID', key: 'platform_order_id', width: 160, ellipsis: true },
{ title: '子订单ID', dataIndex: 'sub_order_id', width: 140 },
{ title: '平台商品ID', key: 'platform_product_id', width: 140, ellipsis: true },
{ title: 'SKU', dataIndex: 'product_sku', width: 120 },
{ title: '单价', key: 'unit_price', width: 100 },
{ title: '数量', dataIndex: 'quantity', width: 80 },
{ title: '优惠', key: 'discount', width: 100 },
{ title: '小计', key: 'total', width: 100 },
{ title: '创建时间', key: 'created_date', width: 140 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
]
const orderStatusMap: Record<number, { text: string; color: string }> = {
1: { text: '待付款', color: 'orange' },
2: { text: '支付失败', color: 'red' },
3: { text: '已支付', color: 'blue' },
4: { text: '待发货', color: 'cyan' },
5: { text: '已发货', color: 'geekblue' },
6: { text: '取消申请中', color: 'volcano' },
7: { text: '已取消', color: 'default' },
8: { text: '已完成', color: 'green' },
9: { text: '发货前取消', color: 'default' },
}
// RangePicker dayjs 桥接
const createdDateRange = computed({
get: () => {
if (!store.filters.created_date_range) return undefined
return store.filters.created_date_range.map((d) => dayjs(d)) as [
dayjs.Dayjs,
dayjs.Dayjs,
]
},
set: (val) => {
store.filters.created_date_range = val
? [val[0].format('YYYY-MM-DD'), val[1].format('YYYY-MM-DD')]
: null
},
})
onMounted(() => {
store.loadLookups()
store.fetchOrderItems()
})
function handleSearch() {
store.pagination.page = 1
store.fetchOrderItems()
}
function handleReset() {
store.resetFilters()
store.fetchOrderItems()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchOrderItems()
}
function formatAmount(val: string | null | undefined) {
if (!val || val === '0' || val === '0.00') return '-'
return val
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
async function handleViewDetail(record: { id: number }) {
const currentRequestId = ++detailRequestId
drawerVisible.value = true
drawerLoading.value = true
itemDetail.value = null
try {
const detail = await api.get<OrderItemDetail>(`/api/v1/order-items/${record.id}`)
// 竞态保护
if (currentRequestId !== detailRequestId) return
itemDetail.value = detail
} catch {
if (currentRequestId !== detailRequestId) return
message.error('获取订单项详情失败')
} finally {
if (currentRequestId === detailRequestId) {
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="平台订单ID">
<a-input
v-model:value="store.filters.platform_order_id"
placeholder="精确搜索"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="平台商品ID">
<a-input
v-model:value="store.filters.platform_product_id"
placeholder="精确搜索"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="SKU">
<a-input
v-model:value="store.filters.product_sku"
placeholder="精确搜索"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="创建时间">
<a-range-picker
v-model:value="createdDateRange"
format="YYYY-MM-DD"
/>
</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.orderItems"
:loading="store.loading"
:pagination="false"
:scroll="{ x: 1500 }"
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.key === 'platform_order_id'">
{{ record.platform_order_id }}
</template>
<template v-else-if="column.key === 'platform_product_id'">
{{ record.platform_product_id }}
</template>
<template v-else-if="column.key === 'unit_price'">
{{ formatAmount(record.unit_price) }}
</template>
<template v-else-if="column.key === 'discount'">
{{ formatAmount(record.discount) }}
</template>
<template v-else-if="column.key === 'total'">
{{ formatAmount(record.total) }}
</template>
<template v-else-if="column.key === 'created_date'">
{{ formatTime(record.created_date) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button
type="link"
size="small"
@click="handleViewDetail(record as OrderItemRecord)"
>
<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="720"
@close="drawerVisible = false"
>
<a-spin :spinning="drawerLoading">
<template v-if="itemDetail">
<!-- 父订单摘要 -->
<a-descriptions title="父订单摘要" :column="2" bordered class="mb-4">
<a-descriptions-item label="平台订单ID">
{{ itemDetail.parent_order.platform_order_id }}
</a-descriptions-item>
<a-descriptions-item label="订单状态">
<a-tag
:color="orderStatusMap[itemDetail.parent_order.order_status_id]?.color || 'default'"
>
{{
orderStatusMap[itemDetail.parent_order.order_status_id]?.text
|| `状态 ${itemDetail.parent_order.order_status_id}`
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总金额">
{{ formatAmount(itemDetail.parent_order.total_amount) }}
</a-descriptions-item>
<a-descriptions-item label="实付金额">
{{ formatAmount(itemDetail.parent_order.total_paid) }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(itemDetail.parent_order.created_date) }}
</a-descriptions-item>
<a-descriptions-item label="支付时间">
{{ formatTime(itemDetail.parent_order.paid_date) }}
</a-descriptions-item>
</a-descriptions>
<!-- 订单项基本信息 -->
<a-descriptions title="订单项信息" :column="2" bordered class="mb-4">
<a-descriptions-item label="ID">{{ itemDetail.id }}</a-descriptions-item>
<a-descriptions-item label="公司">
{{ store.companyMap.get(itemDetail.company_id) || itemDetail.company_id }}
</a-descriptions-item>
<a-descriptions-item label="平台">
{{ store.platformMap.get(itemDetail.platform_id) || itemDetail.platform_id }}
</a-descriptions-item>
<a-descriptions-item label="店铺">
{{ store.storeMap.get(itemDetail.store_id) || itemDetail.store_id }}
</a-descriptions-item>
<a-descriptions-item label="平台订单ID">
{{ itemDetail.platform_order_id }}
</a-descriptions-item>
<a-descriptions-item label="子订单ID">
{{ itemDetail.sub_order_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="子订单类型">
{{ itemDetail.sub_order_type_id }}
</a-descriptions-item>
<a-descriptions-item label="平台商品ID">
{{ itemDetail.platform_product_id }}
</a-descriptions-item>
<a-descriptions-item label="商品ID">
{{ itemDetail.product_id }}
</a-descriptions-item>
<a-descriptions-item label="SKU">
{{ itemDetail.product_sku || '-' }}
</a-descriptions-item>
<a-descriptions-item label="条码">
{{ itemDetail.product_barcode || '-' }}
</a-descriptions-item>
<a-descriptions-item label="单价">
{{ formatAmount(itemDetail.unit_price) }}
</a-descriptions-item>
<a-descriptions-item label="数量">
{{ itemDetail.quantity }}
</a-descriptions-item>
<a-descriptions-item label="优惠">
{{ formatAmount(itemDetail.discount) }}
</a-descriptions-item>
<a-descriptions-item label="小计">
{{ formatAmount(itemDetail.total) }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(itemDetail.created_date) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatTime(itemDetail.updated_at) }}
</a-descriptions-item>
</a-descriptions>
<!-- ext JSON -->
<a-descriptions title="扩展数据 (ext)" :column="1" bordered>
<a-descriptions-item>
<pre v-if="itemDetail.ext" class="m-0 text-xs max-h-80 overflow-auto">{{
JSON.stringify(itemDetail.ext, null, 2)
}}</pre>
<span v-else class="text-gray-400">暂无数据</span>
</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
</a-drawer>
</div>
</template>
+142
View File
@@ -0,0 +1,142 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { OrderItemRecord } from '@/stores/order'
/** 父订单摘要(详情接口嵌套返回) */
export interface ParentOrderSummary {
id: number
platform_order_id: string
order_status_id: number
total_amount: string
total_paid: string
created_date: string | null
paid_date: string | null
}
/** 订单项详情(含父订单摘要) */
export interface OrderItemDetail extends OrderItemRecord {
parent_order: ParentOrderSummary
}
export interface OrderItemFilters {
platform_order_id: string
platform_product_id: string
product_sku: string
created_date_range: [string, string] | null
}
/** 名称映射用的查找表 */
interface LookupItem {
id: number
name: string
label?: string
}
export const useOrderItemStore = defineStore('order-item', () => {
const orderItems = ref<OrderItemRecord[]>([])
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<OrderItemFilters>({
platform_order_id: '',
platform_product_id: '',
product_sku: '',
created_date_range: null,
})
// 名称映射数据
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])),
)
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 fetchOrderItems() {
loading.value = true
try {
const data = await api.get<PaginatedData<OrderItemRecord>>('/api/v1/order-items', {
page: pagination.page,
per_page: pagination.per_page,
company_id: cascadeValue.company_id,
platform_id: cascadeValue.platform_id,
store_id: cascadeValue.store_id,
platform_order_id: filters.platform_order_id || undefined,
platform_product_id: filters.platform_product_id || undefined,
product_sku: filters.product_sku || undefined,
created_date_from: filters.created_date_range?.[0] || undefined,
created_date_to: filters.created_date_range?.[1] || undefined,
})
orderItems.value = data.items
pagination.total = data.total
pagination.page = data.page
} catch (err: unknown) {
orderItems.value = []
pagination.total = 0
const msg = err instanceof Error ? err.message : '获取订单项列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.platform_order_id = ''
filters.platform_product_id = ''
filters.product_sku = ''
filters.created_date_range = null
cascadeValue.company_id = undefined
cascadeValue.platform_id = undefined
cascadeValue.store_id = undefined
pagination.page = 1
}
return {
orderItems,
loading,
pagination,
cascadeValue,
filters,
companies,
platforms,
stores,
companyMap,
platformMap,
storeMap,
loadLookups,
fetchOrderItems,
resetFilters,
}
})