订单查看
This commit is contained in:
parent
4fb29d9cb7
commit
1f03387619
|
|
@ -22,6 +22,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
|
|||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField'
|
||||
import { ProductOrdersField as ProductOrdersField_d2dd8a14d02b2830d96686a022683d02 } from '../../../components/fields/ProductOrdersField'
|
||||
import { TaobaoProductSync as TaobaoProductSync_c920a85a41a3caf5464668c331ea204a } from '../../../components/sync/taobao/TaobaoProductSync'
|
||||
import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton'
|
||||
import { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview'
|
||||
|
|
@ -29,7 +30,6 @@ import { UnifiedSyncButton as UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523
|
|||
import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
|
||||
import { PreorderProgressCell as PreorderProgressCell_67df47753573233f0c83480de687f13b } from '../../../components/cells/PreorderProgressCell'
|
||||
import { RefreshOrderCountField as RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996 } from '../../../components/fields/RefreshOrderCountField'
|
||||
import { PreorderOrdersField as PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f } from '../../../components/fields/PreorderOrdersField'
|
||||
import { PreorderProductGridStyler as PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5 } from '../../../components/list/PreorderProductGridStyler'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
|
|
@ -70,6 +70,7 @@ export const importMap = {
|
|||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
|
||||
"/components/fields/ProductOrdersField#ProductOrdersField": ProductOrdersField_d2dd8a14d02b2830d96686a022683d02,
|
||||
"/components/sync/taobao/TaobaoProductSync#TaobaoProductSync": TaobaoProductSync_c920a85a41a3caf5464668c331ea204a,
|
||||
"/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7,
|
||||
"/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959,
|
||||
|
|
@ -77,7 +78,6 @@ export const importMap = {
|
|||
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
|
||||
"/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b,
|
||||
"/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996,
|
||||
"/components/fields/PreorderOrdersField#PreorderOrdersField": PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f,
|
||||
"/components/list/PreorderProductGridStyler#PreorderProductGridStyler": PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
const MEDUSA_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
|
||||
const MEDUSA_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
|
||||
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
|
||||
|
||||
/**
|
||||
* 获取预购产品的订单列表(从 Medusa 获取)
|
||||
* 获取预购产品的订单列表(调用 Medusa /hooks/preorder-orders)
|
||||
* GET /api/preorders/:id/orders
|
||||
*/
|
||||
export async function GET(
|
||||
|
|
@ -16,108 +17,68 @@ export async function GET(
|
|||
const payload = await getPayload({ config })
|
||||
const { id } = await params
|
||||
|
||||
// 获取预购产品
|
||||
// 获取预购产品(先尝试 Payload ID,再尝试 medusaId / seedId)
|
||||
let product: any = null
|
||||
|
||||
|
||||
try {
|
||||
product = await payload.findByID({
|
||||
collection: 'preorder-products',
|
||||
id,
|
||||
depth: 2,
|
||||
depth: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
} catch {
|
||||
const result = await payload.find({
|
||||
collection: 'preorder-products',
|
||||
where: {
|
||||
or: [
|
||||
{ medusaId: { equals: id } },
|
||||
{ seedId: { equals: id } },
|
||||
{ seedId: { equals: id } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 2,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (result.docs.length > 0) {
|
||||
product = result.docs[0]
|
||||
}
|
||||
if (result.docs.length > 0) product = result.docs[0]
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Preorder product not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Preorder product not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!product.medusaId) {
|
||||
// 构建查询参数:优先 medusaId,其次 seedId
|
||||
const queryParam = product.medusaId
|
||||
? `product_id=${encodeURIComponent(product.medusaId)}`
|
||||
: product.seedId
|
||||
? `seed_id=${encodeURIComponent(product.seedId)}`
|
||||
: null
|
||||
|
||||
if (!queryParam) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product has no Medusa ID, cannot fetch orders' },
|
||||
{ error: 'Product has no Medusa ID or seed ID, cannot fetch orders' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 从 Medusa 获取订单数据
|
||||
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
|
||||
const medusaResponse = await fetch(`${medusaUrl}/admin/orders?limit=500`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
if (!medusaResponse.ok) {
|
||||
throw new Error('Failed to fetch orders from Medusa')
|
||||
}
|
||||
|
||||
const { orders } = await medusaResponse.json()
|
||||
|
||||
// 筛选包含此产品的订单
|
||||
const productOrders = (orders || [])
|
||||
.filter((order: any) => {
|
||||
return order?.items?.some((item: any) => item.product_id === product.medusaId)
|
||||
})
|
||||
.map((order: any) => {
|
||||
// 提取此产品的订单项
|
||||
const items = order.items.filter((item: any) => item.product_id === product.medusaId)
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
display_id: order.display_id,
|
||||
status: order.status,
|
||||
payment_status: order.payment_status,
|
||||
fulfillment_status: order.fulfillment_status,
|
||||
customer_id: order.customer_id,
|
||||
email: order.email,
|
||||
total: order.total,
|
||||
currency_code: order.currency_code,
|
||||
created_at: order.created_at,
|
||||
updated_at: order.updated_at,
|
||||
items: items.map((item: any) => ({
|
||||
id: item.id,
|
||||
variant_id: item.variant_id,
|
||||
title: item.title,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
total: item.total,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// 按创建时间倒序排序
|
||||
productOrders.sort((a: any, b: any) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
// 调用 Medusa 内部 hook(使用 x-payload-api-key,无需 admin JWT)
|
||||
const medusaResponse = await fetch(
|
||||
`${MEDUSA_URL}/hooks/preorder-orders?${queryParam}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-payload-api-key': PAYLOAD_API_KEY,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
orders: productOrders,
|
||||
count: productOrders.length,
|
||||
product: {
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
medusa_id: product.medusaId,
|
||||
},
|
||||
})
|
||||
if (!medusaResponse.ok) {
|
||||
const errBody = await medusaResponse.json().catch(() => ({}))
|
||||
console.error('[Payload Preorder Orders API] Medusa error:', errBody)
|
||||
throw new Error((errBody as any).message || `Medusa responded ${medusaResponse.status}`)
|
||||
}
|
||||
|
||||
// 直接透传 Medusa 的响应
|
||||
const data = await medusaResponse.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
console.error('[Payload Preorder Orders API] Error:', error?.message || error)
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
const MEDUSA_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
|
||||
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
|
||||
|
||||
/**
|
||||
* 获取产品的订单列表(通用,适用于 products 和 preorder-products)
|
||||
* GET /api/products/:id/orders
|
||||
*
|
||||
* :id 可以是:
|
||||
* - Payload 文档 ID
|
||||
* - Medusa 产品 ID (medusaId)
|
||||
* - Seed ID (seedId)
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { id } = await params
|
||||
|
||||
// 依次在两个集合中查找产品
|
||||
let product: any = null
|
||||
const collections = ['products', 'preorder-products'] as const
|
||||
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
product = await payload.findByID({ collection, id, depth: 0 })
|
||||
if (product) break
|
||||
} catch {
|
||||
// 不是 Payload ID,通过 medusaId / seedId 再试
|
||||
const result = await payload.find({
|
||||
collection,
|
||||
where: { or: [{ medusaId: { equals: id } }, { seedId: { equals: id } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
if (result.docs.length > 0) {
|
||||
product = result.docs[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const queryParam = product.medusaId
|
||||
? `product_id=${encodeURIComponent(product.medusaId)}`
|
||||
: product.seedId
|
||||
? `seed_id=${encodeURIComponent(product.seedId)}`
|
||||
: null
|
||||
|
||||
if (!queryParam) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product has no Medusa ID or seed ID, cannot fetch orders' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const medusaResponse = await fetch(
|
||||
`${MEDUSA_URL}/hooks/preorder-orders?${queryParam}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-payload-api-key': PAYLOAD_API_KEY,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!medusaResponse.ok) {
|
||||
const errBody = await medusaResponse.json().catch(() => ({}))
|
||||
console.error('[Products Orders API] Medusa error:', errBody)
|
||||
throw new Error((errBody as any).message || `Medusa responded ${medusaResponse.status}`)
|
||||
}
|
||||
|
||||
const data = await medusaResponse.json()
|
||||
|
||||
// 在服务端注入 Medusa 后台订单链接(避免客户端暴露内部 URL)
|
||||
const medusaAdminBase = MEDUSA_URL
|
||||
if (Array.isArray(data.orders)) {
|
||||
data.orders = data.orders.map((order: any) => ({
|
||||
...order,
|
||||
medusa_url: `${medusaAdminBase}/app/orders/${order.id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
console.error('[Products Orders API] Error:', error?.message || error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch orders', message: error?.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -295,6 +295,9 @@ export const RelatedProductsField: Field = {
|
|||
// TaobaoLinksField 已移至独立文件,包含自动解析功能
|
||||
export { TaobaoLinksField } from './TaobaoLinksField'
|
||||
|
||||
// OrdersTab 定义于 collections/project/OrdersTab.ts,此处统一导出供各集合使用
|
||||
export { OrdersTab } from '../project/OrdersTab'
|
||||
|
||||
/**
|
||||
* 项目状态 Tab
|
||||
* 内嵌数组,可在产品表单中直接添加和编辑,无需跳转新窗口
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
|
||||
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
|
||||
import { ProductBaseFields, RelatedProductsField, TaobaoLinksField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab } from '../base/ProductBase'
|
||||
import { ProductBaseFields, RelatedProductsField, TaobaoLinksField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab, OrdersTab } from '../base/ProductBase'
|
||||
import {
|
||||
AlignFeature,
|
||||
BlocksFeature,
|
||||
|
|
@ -225,20 +225,7 @@ export const PreorderProducts: CollectionConfig = {
|
|||
label: '🔗 相关商品',
|
||||
fields: [RelatedProductsField],
|
||||
},
|
||||
{
|
||||
label: '📦 订单信息',
|
||||
fields: [
|
||||
{
|
||||
name: 'ordersDisplay',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '/components/fields/PreorderOrdersField#PreorderOrdersField',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
OrdersTab,
|
||||
MedusaAttributesTab,
|
||||
ProjectStatusesTab,
|
||||
PrecautionsTab,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
|
||||
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
|
||||
import { ProductBaseFields, RelatedProductsField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab } from '../base/ProductBase'
|
||||
import { ProductBaseFields, RelatedProductsField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab, OrdersTab } from '../base/ProductBase'
|
||||
import { TaobaoLinksField } from '../base/TaobaoLinksField'
|
||||
import {
|
||||
AlignFeature,
|
||||
|
|
@ -117,6 +117,7 @@ export const Products: CollectionConfig = {
|
|||
label: '🔗 关联信息',
|
||||
fields: [RelatedProductsField],
|
||||
},
|
||||
OrdersTab,
|
||||
MedusaAttributesTab,
|
||||
ProjectStatusesTab,
|
||||
PrecautionsTab,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { Tab } from 'payload'
|
||||
|
||||
/**
|
||||
* 订单信息 Tab(通用)
|
||||
* 通过 /api/products/:id/orders 从 Medusa 拉取该产品的订单数据
|
||||
* Products 和 PreorderProducts 均可使用
|
||||
*/
|
||||
export const OrdersTab: Tab = {
|
||||
label: '📦 订单信息',
|
||||
fields: [
|
||||
{
|
||||
name: 'ordersDisplay',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '/components/fields/ProductOrdersField#ProductOrdersField',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -3,20 +3,37 @@
|
|||
import { useField, useFormFields } from '@payloadcms/ui'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
interface PreorderItem {
|
||||
id: string
|
||||
title: string
|
||||
variant_id: string
|
||||
variant_sku?: string
|
||||
variant_title?: string
|
||||
quantity: number
|
||||
unit_price: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
display_id: number
|
||||
status: string
|
||||
payment_status: string
|
||||
fulfillment_status: string
|
||||
email: string
|
||||
total: number
|
||||
preorder_amount: number
|
||||
preorder_quantity: number
|
||||
currency_code: string
|
||||
created_at: string
|
||||
items: Array<{
|
||||
title: string
|
||||
quantity: number
|
||||
unit_price: number
|
||||
}>
|
||||
preorder_items: PreorderItem[]
|
||||
}
|
||||
|
||||
interface Statistics {
|
||||
total_orders: number
|
||||
total_quantity: number
|
||||
total_amount: number
|
||||
status_breakdown: Record<string, number>
|
||||
}
|
||||
|
||||
export const PreorderOrdersField: React.FC = () => {
|
||||
|
|
@ -26,7 +43,7 @@ export const PreorderOrdersField: React.FC = () => {
|
|||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState<{ total: number; totalAmount: number } | null>(null)
|
||||
const [stats, setStats] = useState<Statistics | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (medusaId || seedId) {
|
||||
|
|
@ -50,13 +67,7 @@ export const PreorderOrdersField: React.FC = () => {
|
|||
|
||||
const data = await response.json()
|
||||
setOrders(data.orders || [])
|
||||
|
||||
// 计算统计数据
|
||||
const totalAmount = data.orders?.reduce((sum: number, order: Order) => sum + order.total, 0) || 0
|
||||
setStats({
|
||||
total: data.count || 0,
|
||||
totalAmount,
|
||||
})
|
||||
setStats(data.statistics ?? null)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch orders:', err)
|
||||
setError(err.message || 'Failed to load orders')
|
||||
|
|
@ -152,17 +163,21 @@ export const PreorderOrdersField: React.FC = () => {
|
|||
borderRadius: '4px',
|
||||
marginBottom: '1rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}>订单总数</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>{stats.total}</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>{stats.total_orders}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}>订单总额</div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}>预购数量</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>{stats.total_quantity}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}>预购金额</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>
|
||||
{formatCurrency(stats.totalAmount, orders[0]?.currency_code || 'CNY')}
|
||||
{formatCurrency(stats.total_amount, orders[0]?.currency_code || 'CNY')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
|
|
@ -218,9 +233,9 @@ export const PreorderOrdersField: React.FC = () => {
|
|||
{order.email}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
|
||||
{order.items.map((item, i) => (
|
||||
<div key={i} style={{ marginBottom: i < order.items.length - 1 ? '0.25rem' : 0 }}>
|
||||
{item.title} × {item.quantity}
|
||||
{(order.preorder_items || []).map((item, i) => (
|
||||
<div key={i} style={{ marginBottom: i < order.preorder_items.length - 1 ? '0.25rem' : 0 }}>
|
||||
{item.variant_title ? `${item.title} · ${item.variant_title}` : item.title} × {item.quantity}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,360 @@
|
|||
'use client'
|
||||
|
||||
import { Button, useField } from '@payloadcms/ui'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
interface OrderItem {
|
||||
id: string
|
||||
title: string
|
||||
variant_id: string
|
||||
variant_sku?: string
|
||||
variant_title?: string
|
||||
quantity: number
|
||||
unit_price: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
display_id: number
|
||||
status: string
|
||||
payment_status: string
|
||||
fulfillment_status: string
|
||||
email: string
|
||||
total: number
|
||||
preorder_amount: number
|
||||
preorder_quantity: number
|
||||
currency_code: string
|
||||
created_at: string
|
||||
preorder_items: OrderItem[]
|
||||
medusa_url: string
|
||||
}
|
||||
|
||||
interface Statistics {
|
||||
total_orders: number
|
||||
total_quantity: number
|
||||
total_amount: number
|
||||
status_breakdown: Record<string, number>
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
||||
completed: { bg: 'var(--theme-success-50)', text: 'var(--theme-success-900)' },
|
||||
pending: { bg: 'var(--theme-warning-50)', text: 'var(--theme-warning-900)' },
|
||||
cancelled: { bg: 'var(--theme-error-50)', text: 'var(--theme-error-900)' },
|
||||
processing: { bg: 'var(--theme-elevation-100)', text: 'var(--theme-text)' },
|
||||
}
|
||||
|
||||
const statusColor = (status: string) =>
|
||||
STATUS_COLORS[status] ?? STATUS_COLORS.processing
|
||||
|
||||
// ─── shared styles ──────────────────────────────────────────────────────────
|
||||
const card: React.CSSProperties = {
|
||||
padding: '1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
borderRadius: 'var(--style-radius-m)',
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
marginBottom: '1rem',
|
||||
}
|
||||
|
||||
const TH: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
color: 'var(--theme-elevation-600)',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const TD: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
verticalAlign: 'middle',
|
||||
}
|
||||
|
||||
// ─── sub-component ──────────────────────────────────────────────────────────
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
borderRadius: 'var(--style-radius-m)',
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--theme-elevation-500)',
|
||||
marginBottom: '0.25rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--theme-text)' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── main component ─────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 通用产品订单展示组件
|
||||
* 适用于 Products 和 PreorderProducts
|
||||
* 通过 /api/products/:id/orders 获取数据,点击「查看」跳转 Medusa 后台订单页
|
||||
*/
|
||||
export const ProductOrdersField: React.FC = () => {
|
||||
const { value: medusaId } = useField<string>({ path: 'medusaId' })
|
||||
const { value: seedId } = useField<string>({ path: 'seedId' })
|
||||
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState<Statistics | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (medusaId || seedId) fetchOrders()
|
||||
}, [medusaId, seedId])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
const productId = seedId || medusaId
|
||||
if (!productId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/products/${productId}/orders`)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error((body as any).message || 'Failed to fetch orders')
|
||||
}
|
||||
const data = await res.json()
|
||||
setOrders(data.orders || [])
|
||||
setStats(data.statistics ?? null)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load orders')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (amount: number, currency: string) =>
|
||||
new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100)
|
||||
|
||||
const fmtDate = (d: string) =>
|
||||
new Date(d).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
|
||||
// ─── state views ─────────────────────────────────────────────────────────
|
||||
if (!medusaId && !seedId) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ margin: 0, color: 'var(--theme-elevation-500)', fontSize: '0.875rem' }}>
|
||||
产品尚未同步到 Medusa,无法查看订单
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--theme-elevation-500)' }}>
|
||||
加载订单中…
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ ...card, backgroundColor: 'var(--theme-error-50)', borderColor: 'var(--theme-error-300)' }}>
|
||||
<p style={{ margin: '0 0 0.75rem', color: 'var(--theme-error-900)', fontSize: '0.875rem' }}>
|
||||
加载失败:{error}
|
||||
</p>
|
||||
<Button buttonStyle="secondary" size="small" onClick={fetchOrders}>重试</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ margin: '0 0 0.75rem', color: 'var(--theme-elevation-500)', fontSize: '0.875rem' }}>
|
||||
暂无订单
|
||||
</p>
|
||||
<Button buttonStyle="secondary" size="small" onClick={fetchOrders}>刷新</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── main view ──────────────────────────────────────────────────────────
|
||||
const baseCurrency = orders[0]?.currency_code || 'CNY'
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
|
||||
{/* 统计栏 */}
|
||||
{stats && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<StatCard label="订单总数" value={String(stats.total_orders)} />
|
||||
<StatCard label="下单数量" value={String(stats.total_quantity)} />
|
||||
<StatCard label="订单金额" value={fmt(stats.total_amount, baseCurrency)} />
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
borderRadius: 'var(--style-radius-m)',
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--theme-elevation-500)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
操作
|
||||
</div>
|
||||
<Button buttonStyle="primary" onClick={fetchOrders} disabled={loading}>
|
||||
🔄 刷新订单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 订单表格 */}
|
||||
<div style={{
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
borderRadius: 'var(--style-radius-m)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
|
||||
<thead>
|
||||
<tr style={{
|
||||
background: 'var(--theme-elevation-50)',
|
||||
borderBottom: '1px solid var(--theme-elevation-150)',
|
||||
}}>
|
||||
{['订单号', '客户', '商品', '金额', '状态', '时间', ''].map(h => (
|
||||
<th key={h} style={TH}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order, idx) => {
|
||||
const sc = statusColor(order.status)
|
||||
return (
|
||||
<tr
|
||||
key={order.id}
|
||||
style={{
|
||||
borderBottom: idx < orders.length - 1
|
||||
? '1px solid var(--theme-elevation-100)'
|
||||
: 'none',
|
||||
background: idx % 2 === 0
|
||||
? 'var(--theme-bg)'
|
||||
: 'var(--theme-elevation-50)',
|
||||
}}
|
||||
>
|
||||
{/* 订单号 */}
|
||||
<td style={TD}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--theme-text)' }}>
|
||||
#{order.display_id}
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--theme-elevation-400)' }}>
|
||||
{order.id.slice(0, 8)}…
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* 客户 */}
|
||||
<td style={TD}>
|
||||
<span style={{ color: 'var(--theme-elevation-700)' }}>{order.email}</span>
|
||||
</td>
|
||||
|
||||
{/* 商品 */}
|
||||
<td style={TD}>
|
||||
{(order.preorder_items || []).map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ marginBottom: i < order.preorder_items.length - 1 ? '0.2rem' : 0 }}
|
||||
>
|
||||
<span style={{ color: 'var(--theme-text)' }}>
|
||||
{item.variant_title
|
||||
? `${item.title} · ${item.variant_title}`
|
||||
: item.title}
|
||||
</span>
|
||||
<span style={{ color: 'var(--theme-elevation-400)', marginLeft: '0.25rem' }}>
|
||||
× {item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
|
||||
{/* 金额 */}
|
||||
<td style={{ ...TD, textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{fmt(order.total, order.currency_code)}
|
||||
</td>
|
||||
|
||||
{/* 状态 */}
|
||||
<td style={{ ...TD, textAlign: 'center' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: sc.bg,
|
||||
color: sc.text,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{order.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* 时间 */}
|
||||
<td style={{ ...TD, whiteSpace: 'nowrap', color: 'var(--theme-elevation-500)' }}>
|
||||
{fmtDate(order.created_at)}
|
||||
</td>
|
||||
|
||||
{/* 跳转 Medusa */}
|
||||
<td style={{ ...TD, textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(order.medusa_url, '_blank', 'noopener,noreferrer')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.5rem 0.875rem',
|
||||
borderRadius: 'var(--style-radius-s)',
|
||||
border: '1px solid var(--theme-elevation-300)',
|
||||
backgroundColor: 'var(--theme-elevation-0, var(--theme-bg))',
|
||||
color: 'var(--theme-text)',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--theme-elevation-100)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'var(--theme-elevation-0, var(--theme-bg))')}
|
||||
>
|
||||
查看
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0, opacity: 0.6 }}>
|
||||
<path d="M1 11L11 1M11 1H4M11 1V8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Fix precautions ID type mismatch.
|
||||
*
|
||||
* The `precautions` table was created by a manual migration with `serial`
|
||||
* (integer) PKs. Payload v3 / @payloadcms/db-postgres defaults to `text`
|
||||
* (varchar) IDs. On dev-mode startup, Payload's schema-push tries to ALTER
|
||||
* the `precautions_id` relationship columns to varchar, but the FK constraint
|
||||
* pointing to an integer PK blocks it.
|
||||
*
|
||||
* Fix: drop the precautions table and all `precautions_id` rels columns so
|
||||
* Payload's dev-mode push can recreate everything with the correct types.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
-- Drop FK constraints first (payload_locked_documents_rels)
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_precautions_fk";
|
||||
|
||||
-- Drop the precautions_id column from payload_locked_documents_rels
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
DROP COLUMN IF EXISTS "precautions_id";
|
||||
|
||||
-- Drop from products_rels (sharedPrecautions relationship)
|
||||
ALTER TABLE "products_rels"
|
||||
DROP CONSTRAINT IF EXISTS "products_rels_precautions_fk";
|
||||
ALTER TABLE "products_rels"
|
||||
DROP COLUMN IF EXISTS "precautions_id";
|
||||
|
||||
-- Drop from preorder_products_rels (sharedPrecautions relationship)
|
||||
ALTER TABLE "preorder_products_rels"
|
||||
DROP CONSTRAINT IF EXISTS "preorder_products_rels_precautions_fk";
|
||||
ALTER TABLE "preorder_products_rels"
|
||||
DROP COLUMN IF EXISTS "precautions_id";
|
||||
|
||||
-- Drop the precautions table itself (Payload will recreate with varchar PK)
|
||||
DROP TABLE IF EXISTS "precautions" CASCADE;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Recreating the integer-based schema is not worth reverting to since
|
||||
// the whole point is to migrate to the correct varchar-based schema.
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import * as migration_baseline from './baseline'
|
||||
import * as migration_fix_precautions_id_type from './fix_precautions_id_type'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -6,5 +7,10 @@ export const migrations = [
|
|||
down: migration_baseline.down,
|
||||
name: 'baseline',
|
||||
},
|
||||
{
|
||||
up: migration_fix_precautions_id_type.up,
|
||||
down: migration_fix_precautions_id_type.down,
|
||||
name: 'fix_precautions_id_type',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue