订单查看

This commit is contained in:
龟男日记\www 2026-02-25 21:57:32 +08:00
parent 4fb29d9cb7
commit 1f03387619
11 changed files with 614 additions and 115 deletions

View File

@ -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,

View File

@ -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,16 +17,16 @@ 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: {
@ -35,89 +36,49 @@ export async function GET(
],
},
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`, {
// 调用 Medusa 内部 hook使用 x-payload-api-key无需 admin JWT
const medusaResponse = await fetch(
`${MEDUSA_URL}/hooks/preorder-orders?${queryParam}`,
{
headers: {
'Content-Type': 'application/json',
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
'x-payload-api-key': PAYLOAD_API_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()
)
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(

View File

@ -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 }
)
}
}

View File

@ -295,6 +295,9 @@ export const RelatedProductsField: Field = {
// TaobaoLinksField 已移至独立文件,包含自动解析功能
export { TaobaoLinksField } from './TaobaoLinksField'
// OrdersTab 定义于 collections/project/OrdersTab.ts此处统一导出供各集合使用
export { OrdersTab } from '../project/OrdersTab'
/**
* Tab
*

View File

@ -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,

View File

@ -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,

View File

@ -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',
},
},
},
],
}

View File

@ -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>

View File

@ -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}>
🔄&nbsp;
</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>
)
}

View File

@ -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.
}

View File

@ -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',
},
]