From 1f78d88d10411431be44040359e09cefb3dcb43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=9F=E7=94=B7=E6=97=A5=E8=AE=B0=5Cwww?= Date: Wed, 18 Feb 2026 03:17:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(payload)/admin/importMap.js | 2 + src/app/api/batch-sync-medusa/route.ts | 119 +++++++ src/app/api/diagnose-products/route.ts | 42 +++ src/app/api/preorders/[id]/orders/route.ts | 125 +++++++ .../api/preorders/[id]/recalculate/route.ts | 143 ++++++++ src/app/api/preorders/[id]/route.ts | 307 +++++++++++++++++ src/app/api/preorders/route.ts | 143 ++++++++ src/app/api/sync-medusa/route.ts | 314 +++++++++++++----- src/collections/PreorderProducts.ts | 11 + src/collections/Products.ts | 11 + src/components/sync/BatchSyncButton.tsx | 128 +++++++ src/components/sync/ForceSyncButton.tsx | 11 +- src/lib/medusa.ts | 10 +- src/payload-types.ts | 10 + 14 files changed, 1280 insertions(+), 96 deletions(-) create mode 100644 src/app/api/batch-sync-medusa/route.ts create mode 100644 src/app/api/diagnose-products/route.ts create mode 100644 src/app/api/preorders/[id]/orders/route.ts create mode 100644 src/app/api/preorders/[id]/recalculate/route.ts create mode 100644 src/app/api/preorders/[id]/route.ts create mode 100644 src/app/api/preorders/route.ts create mode 100644 src/components/sync/BatchSyncButton.tsx diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index e0e8d15..d156704 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -26,6 +26,7 @@ import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField' import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton' +import { BatchSyncButton as BatchSyncButton_c62499057175f17acbe529b96de3aeb8 } from '../../../components/sync/BatchSyncButton' import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler' import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' @@ -63,6 +64,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, "/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08, + "/components/sync/BatchSyncButton#BatchSyncButton": BatchSyncButton_c62499057175f17acbe529b96de3aeb8, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, "/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c, "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, diff --git a/src/app/api/batch-sync-medusa/route.ts b/src/app/api/batch-sync-medusa/route.ts new file mode 100644 index 0000000..46fb31c --- /dev/null +++ b/src/app/api/batch-sync-medusa/route.ts @@ -0,0 +1,119 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextResponse } from 'next/server' + +/** + * 批量同步选中的产品到 Medusa + * POST /api/batch-sync-medusa + * Body: { ids: string[], collection: 'products' | 'preorder-products', forceUpdate: boolean } + */ +export async function POST(request: Request) { + try { + const body = await request.json() + const { ids, collection, forceUpdate = false } = body + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No product IDs provided', + }, + { status: 400 }, + ) + } + + if (!collection || !['products', 'preorder-products'].includes(collection)) { + return NextResponse.json( + { + success: false, + error: 'Invalid collection', + }, + { status: 400 }, + ) + } + + const payload = await getPayload({ config }) + + const results = { + total: ids.length, + success: 0, + failed: 0, + details: [] as any[], + } + + // 逐个同步选中的产品 + for (const id of ids) { + try { + // 获取产品信息 + const product = await payload.findByID({ + collection: collection as 'products' | 'preorder-products', + id, + }) + + if (!product || !product.medusaId) { + results.failed++ + results.details.push({ + id, + title: product?.title || 'Unknown', + status: 'failed', + error: 'No Medusa ID', + }) + continue + } + + // 调用单个产品同步 API + const syncResponse = await fetch( + `${request.url.replace('/api/batch-sync-medusa', '/api/sync-medusa')}?medusaId=${product.medusaId}&forceUpdate=${forceUpdate}`, + { + method: 'GET', + headers: request.headers, + }, + ) + + const syncResult = await syncResponse.json() + + if (syncResult.success) { + results.success++ + results.details.push({ + id, + medusaId: product.medusaId, + title: product.title, + status: 'success', + action: syncResult.action, + }) + } else { + results.failed++ + results.details.push({ + id, + medusaId: product.medusaId, + title: product.title, + status: 'failed', + error: syncResult.error || syncResult.message, + }) + } + } catch (error) { + results.failed++ + results.details.push({ + id, + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + + return NextResponse.json({ + success: true, + message: `批量同步完成: ${results.success} 成功, ${results.failed} 失败`, + results, + }) + } catch (error) { + console.error('Batch sync error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/diagnose-products/route.ts b/src/app/api/diagnose-products/route.ts new file mode 100644 index 0000000..ad18b1a --- /dev/null +++ b/src/app/api/diagnose-products/route.ts @@ -0,0 +1,42 @@ +import { getAllMedusaProducts } from '@/lib/medusa' +import { NextResponse } from 'next/server' + +/** + * 诊断 API - 检查 Medusa 产品的 metadata + * GET /api/diagnose-products + */ +export async function GET() { + try { + const products = await getAllMedusaProducts() + + const diagnosis = products.map((product) => ({ + id: product.id, + title: product.title, + handle: product.handle, + metadata: product.metadata, + is_preorder_value: product.metadata?.is_preorder, + is_preorder_type: typeof product.metadata?.is_preorder, + should_be_in_collection: + product.metadata?.is_preorder === true || product.metadata?.is_preorder === 'true' + ? 'preorder-products' + : 'products', + })) + + return NextResponse.json({ + success: true, + total: products.length, + preorder_count: diagnosis.filter((d) => d.should_be_in_collection === 'preorder-products') + .length, + regular_count: diagnosis.filter((d) => d.should_be_in_collection === 'products').length, + products: diagnosis, + }) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/preorders/[id]/orders/route.ts b/src/app/api/preorders/[id]/orders/route.ts new file mode 100644 index 0000000..28f77e7 --- /dev/null +++ b/src/app/api/preorders/[id]/orders/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * 获取预购产品的订单列表(从 Medusa 获取) + * GET /api/preorders/:id/orders + */ +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const payload = await getPayload({ config }) + const { id } = params + + // 获取预购产品 + let product: any = null + + try { + product = await payload.findByID({ + collection: 'preorder-products', + id, + depth: 2, + }) + } catch (err) { + const result = await payload.find({ + collection: 'preorder-products', + where: { + or: [ + { medusaId: { equals: id } }, + { seedId: { equals: id } }, + ], + }, + limit: 1, + depth: 2, + }) + + if (result.docs.length > 0) { + product = result.docs[0] + } + } + + if (!product) { + return NextResponse.json( + { error: 'Preorder product not found' }, + { status: 404 } + ) + } + + if (!product.medusaId) { + return NextResponse.json( + { error: 'Product has no Medusa ID, cannot fetch orders' }, + { status: 400 } + ) + } + + // 从 Medusa 获取订单数据 + const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000' + const medusaResponse = await fetch(`${medusaUrl}/admin/orders`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + 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, + }, + }) + } catch (error: any) { + console.error('[Payload Preorder Orders API] Error:', error?.message || error) + return NextResponse.json( + { error: 'Failed to fetch preorder orders', message: error?.message }, + { status: 500 } + ) + } +} diff --git a/src/app/api/preorders/[id]/recalculate/route.ts b/src/app/api/preorders/[id]/recalculate/route.ts new file mode 100644 index 0000000..fd50f12 --- /dev/null +++ b/src/app/api/preorders/[id]/recalculate/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * 重新计算预购产品的订单统计(从 Medusa 获取实际订单数据) + * POST /api/preorders/:id/recalculate + */ +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const payload = await getPayload({ config }) + const { id } = params + + // 获取预购产品 + let product: any = null + + try { + product = await payload.findByID({ + collection: 'preorder-products', + id, + depth: 2, + }) + } catch (err) { + const result = await payload.find({ + collection: 'preorder-products', + where: { + or: [ + { medusaId: { equals: id } }, + { seedId: { equals: id } }, + ], + }, + limit: 1, + depth: 2, + }) + + if (result.docs.length > 0) { + product = result.docs[0] + } + } + + if (!product) { + return NextResponse.json( + { error: 'Preorder product not found' }, + { status: 404 } + ) + } + + if (!product.medusaId) { + return NextResponse.json( + { error: 'Product has no Medusa ID, cannot recalculate' }, + { status: 400 } + ) + } + + // 从 Medusa 获取订单数据 + const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000' + const medusaResponse = await fetch(`${medusaUrl}/admin/orders`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!medusaResponse.ok) { + throw new Error('Failed to fetch orders from Medusa') + } + + const { orders } = await medusaResponse.json() + + // 统计每个变体的订单数量 + const variantCounts: Record = {} + let totalCount = 0 + + for (const order of orders || []) { + if (!order?.items) continue + + for (const item of order.items) { + if (!item || item.product_id !== product.medusaId) continue + + const variantId = item.variant_id + if (variantId) { + variantCounts[variantId] = (variantCounts[variantId] || 0) + (item.quantity || 0) + totalCount += item.quantity || 0 + } + } + } + + // 从 Medusa 获取产品变体列表 + const productResponse = await fetch(`${medusaUrl}/admin/products/${product.medusaId}`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!productResponse.ok) { + throw new Error('Failed to fetch product from Medusa') + } + + const { product: medusaProduct } = await productResponse.json() + const variants = medusaProduct.variants || [] + + // 更新每个变体的 metadata(在 Medusa 中) + const updatePromises = variants.map(async (variant: any) => { + const count = variantCounts[variant.id] || 0 + + await fetch(`${medusaUrl}/admin/product-variants/${variant.id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + metadata: { + ...(variant.metadata || {}), + current_orders: String(count), + }, + }), + }) + + return { + variant_id: variant.id, + count, + } + }) + + const updatedVariants = await Promise.all(updatePromises) + + return NextResponse.json({ + success: true, + product_id: product.id, + total_orders: totalCount, + variants: updatedVariants, + message: `Recalculated orders for ${variants.length} variant(s)`, + }) + } catch (error: any) { + console.error('[Payload Preorder Recalculate API] Error:', error?.message || error) + return NextResponse.json( + { error: 'Failed to recalculate orders', message: error?.message }, + { status: 500 } + ) + } +} diff --git a/src/app/api/preorders/[id]/route.ts b/src/app/api/preorders/[id]/route.ts new file mode 100644 index 0000000..a1501b0 --- /dev/null +++ b/src/app/api/preorders/[id]/route.ts @@ -0,0 +1,307 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * 获取单个预购产品详情 + * GET /api/preorders/:id + */ +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const payload = await getPayload({ config }) + const { id } = params + + // 尝试通过 Payload ID 查找 + let product: any = null + + try { + product = await payload.findByID({ + collection: 'preorder-products', + id, + depth: 2, + }) + } catch (err) { + // 如果不是 Payload ID,尝试通过 medusaId 或 seedId 查找 + const result = await payload.find({ + collection: 'preorder-products', + where: { + or: [ + { medusaId: { equals: id } }, + { seedId: { equals: id } }, + ], + }, + limit: 1, + depth: 2, + }) + + if (result.docs.length > 0) { + product = result.docs[0] + } + } + + if (!product) { + return NextResponse.json( + { error: 'Preorder product not found' }, + { status: 404 } + ) + } + + // 格式化变体数据 + const variants = (product.variants || []).map((variant: any) => { + const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0 + const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0 + const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0 + const soldOut = maxOrders > 0 && currentOrders >= maxOrders + const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0 + + return { + id: variant.id, + title: variant.title, + sku: variant.sku, + current_orders: currentOrders, + max_orders: maxOrders, + available_slots: availableSlots, + sold_out: soldOut, + utilization_percentage: utilization, + prices: variant.prices || [], + options: variant.options || {}, + metadata: variant.metadata || {}, + } + }) + + // 计算统计数据 + const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0) + const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0) + const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0) + + const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders + const completionPercentage = fundingGoal > 0 + ? Math.round((totalOrders / fundingGoal) * 100) + : 0 + + const allSoldOut = variants.every((v: any) => v.sold_out) + const someSoldOut = variants.some((v: any) => v.sold_out) + + return NextResponse.json({ + preorder: { + id: product.id, + title: product.title, + handle: product.handle, + description: product.description, + status: product._status, + thumbnail: product.thumbnail, + images: product.images || [], + + // IDs + seed_id: product.seedId || product.medusaId, + medusa_id: product.medusaId, + + // 预购元数据 + is_preorder: true, + preorder_type: product.preorderType || 'standard', + estimated_ship_date: product.estimatedShipDate || null, + funding_goal: fundingGoal, + + // 统计数据 + current_orders: totalOrders, + total_max_orders: totalMaxOrders, + total_available_slots: totalAvailable, + completion_percentage: completionPercentage, + + // 可用性状态 + all_variants_sold_out: allSoldOut, + some_variants_sold_out: someSoldOut, + is_available: !allSoldOut && totalAvailable > 0, + + // 详细信息 + variants, + variants_count: variants.length, + categories: product.categories || [], + collection: product.collection || null, + metadata: product.metadata || {}, + + // 时间戳 + created_at: product.createdAt, + updated_at: product.updatedAt, + }, + }) + } catch (error: any) { + console.error('[Payload Preorder Detail API] Error:', error?.message || error) + return NextResponse.json( + { error: 'Failed to fetch preorder product', message: error?.message }, + { status: 500 } + ) + } +} + +/** + * 更新预购产品 + * PATCH /api/preorders/:id + * + * Body: + * - variant_id?: string - 如果提供,更新变体计数 + * - current_orders?: number - 直接设置订单数 + * - max_orders?: number - 更新最大订单数 + * - increment?: number - 增加订单数 + * - decrement?: number - 减少订单数 + * + * - estimated_ship_date?: string - 更新预估发货日期 + * - funding_goal?: number - 更新目标金额 + * - preorder_type?: string - 更新预购类型 + */ +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const payload = await getPayload({ config }) + const { id } = params + const body = await req.json() + + const { + variant_id, + current_orders, + max_orders, + increment, + decrement, + estimated_ship_date, + funding_goal, + preorder_type, + } = body + + // 获取产品 + let product: any = null + + try { + product = await payload.findByID({ + collection: 'preorder-products', + id, + depth: 2, + }) + } catch (err) { + const result = await payload.find({ + collection: 'preorder-products', + where: { + or: [ + { medusaId: { equals: id } }, + { seedId: { equals: id } }, + ], + }, + limit: 1, + depth: 2, + }) + + if (result.docs.length > 0) { + product = result.docs[0] + } + } + + if (!product) { + return NextResponse.json( + { error: 'Preorder product not found' }, + { status: 404 } + ) + } + + // 模式1: 更新变体预购计数 + if (variant_id) { + // 预购变体数据存储在 Medusa 中,直接更新 Medusa + const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000' + + // 获取当前变体数据 + const variantResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!variantResponse.ok) { + return NextResponse.json( + { error: 'Variant not found in Medusa' }, + { status: 404 } + ) + } + + const { variant } = await variantResponse.json() + const currentMeta = variant.metadata || {} + let newCurrentOrders = parseInt(currentMeta.current_orders || '0', 10) || 0 + let newMaxOrders = parseInt(currentMeta.max_orders || '0', 10) || 0 + + // 处理更新逻辑 + if (typeof current_orders === 'number') { + newCurrentOrders = Math.max(0, current_orders) + } else if (typeof increment === 'number') { + newCurrentOrders = Math.max(0, newCurrentOrders + increment) + } else if (typeof decrement === 'number') { + newCurrentOrders = Math.max(0, newCurrentOrders - decrement) + } + + if (typeof max_orders === 'number') { + newMaxOrders = Math.max(0, max_orders) + } + + // 更新 Medusa 变体 metadata + const updateResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + metadata: { + ...currentMeta, + current_orders: String(newCurrentOrders), + max_orders: String(newMaxOrders), + }, + }), + }) + + if (!updateResponse.ok) { + throw new Error('Failed to update variant in Medusa') + } + + return NextResponse.json({ + success: true, + variant_id, + current_orders: newCurrentOrders, + max_orders: newMaxOrders, + available_slots: newMaxOrders - newCurrentOrders, + sold_out: newMaxOrders > 0 && newCurrentOrders >= newMaxOrders, + }) + } + + // 模式2: 更新产品级别元数据 + const updateData: any = {} + + if (estimated_ship_date !== undefined) { + updateData.estimatedShipDate = estimated_ship_date + } + if (funding_goal !== undefined) { + updateData.fundingGoal = String(funding_goal) + } + if (preorder_type !== undefined) { + updateData.preorderType = preorder_type + } + + if (Object.keys(updateData).length > 0) { + await payload.update({ + collection: 'preorder-products', + id: product.id, + data: updateData, + }) + } + + return NextResponse.json({ + success: true, + message: 'Preorder product updated successfully', + }) + } catch (error: any) { + console.error('[Payload Preorder Update API] Error:', error?.message || error) + return NextResponse.json( + { error: 'Failed to update preorder product', message: error?.message }, + { status: 500 } + ) + } +} diff --git a/src/app/api/preorders/route.ts b/src/app/api/preorders/route.ts new file mode 100644 index 0000000..a0c8f82 --- /dev/null +++ b/src/app/api/preorders/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * 获取预购产品列表 + * GET /api/preorders + * + * Query params: + * - seed_id: 按 seed_id 筛选 + * - status: 按状态筛选 (draft|published) + */ +export async function GET(req: NextRequest) { + try { + const payload = await getPayload({ config }) + const { searchParams } = new URL(req.url) + const seed_id = searchParams.get('seed_id') + const status = searchParams.get('status') + + // 构建查询条件 + const where: any = {} + + if (seed_id) { + where.seedId = { equals: seed_id } + } + + if (status) { + where._status = { equals: status } + } + + // 查询预购产品集合 + const result = await payload.find({ + collection: 'preorder-products', + where, + depth: 2, + limit: 100, + sort: '-createdAt', + }) + + // 格式化数据 - 以预购为主,展示完整变体信息 + const formattedProducts = result.docs.map((product: any) => { + // 计算变体预购统计 + const variants = (product.variants || []).map((variant: any) => { + const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0 + const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0 + const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0 + const soldOut = maxOrders > 0 && currentOrders >= maxOrders + const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0 + + return { + id: variant.id, + title: variant.title, + sku: variant.sku, + current_orders: currentOrders, + max_orders: maxOrders, + available_slots: availableSlots, + sold_out: soldOut, + utilization_percentage: utilization, + prices: variant.prices || [], + } + }) + + // 产品级别统计 + const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0) + const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0) + const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0) + + const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders + const completionPercentage = fundingGoal > 0 + ? Math.round((totalOrders / fundingGoal) * 100) + : 0 + + const allSoldOut = variants.every((v: any) => v.sold_out) + const someSoldOut = variants.some((v: any) => v.sold_out) + + return { + id: product.id, + title: product.title, + handle: product.handle, + status: product._status, + thumbnail: product.thumbnail, + description: product.description, + + // Seed ID + seed_id: product.seedId || product.medusaId, + medusa_id: product.medusaId, + + // 预购元数据 + preorder_type: product.preorderType || 'standard', + estimated_ship_date: product.estimatedShipDate || null, + funding_goal: fundingGoal, + + // 统计数据 + current_orders: totalOrders, + total_max_orders: totalMaxOrders, + total_available_slots: totalAvailable, + completion_percentage: completionPercentage, + + // 可用性状态 + all_variants_sold_out: allSoldOut, + some_variants_sold_out: someSoldOut, + is_available: !allSoldOut && totalAvailable > 0, + + // 变体详情 + variants, + variants_count: variants.length, + + // 时间戳 + created_at: product.createdAt, + updated_at: product.updatedAt, + } + }) + + // 排序:有库存优先 → 完成度高优先 + formattedProducts.sort((a: any, b: any) => { + if (a.is_available !== b.is_available) { + return a.is_available ? -1 : 1 + } + if (a.completion_percentage !== b.completion_percentage) { + return b.completion_percentage - a.completion_percentage + } + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + }) + + return NextResponse.json({ + preorders: formattedProducts, + count: formattedProducts.length, + summary: { + total: formattedProducts.length, + available: formattedProducts.filter((p: any) => p.is_available).length, + sold_out: formattedProducts.filter((p: any) => p.all_variants_sold_out).length, + total_orders: formattedProducts.reduce((sum: number, p: any) => sum + p.current_orders, 0), + total_slots: formattedProducts.reduce((sum: number, p: any) => sum + p.total_max_orders, 0), + }, + }) + } catch (error: any) { + console.error('[Payload Preorders API] Error:', error?.message || error) + return NextResponse.json( + { error: 'Failed to fetch preorder products', message: error?.message }, + { status: 500 } + ) + } +} diff --git a/src/app/api/sync-medusa/route.ts b/src/app/api/sync-medusa/route.ts index 8ebeb66..5e3969f 100644 --- a/src/app/api/sync-medusa/route.ts +++ b/src/app/api/sync-medusa/route.ts @@ -8,6 +8,95 @@ import { getProductCollection, } from '@/lib/medusa' +/** + * 通过 seedId 或 medusaId 查找产品(优先使用 seedId) + * 会在两个 collection 中查找 + * @returns { product, collection } 或 null + */ +async function findProductBySeedIdOrMedusaId( + payload: any, + seedId: string | null, + medusaId: string, +): Promise<{ product: any; collection: 'products' | 'preorder-products' } | null> { + const collections: Array<'products' | 'preorder-products'> = ['products', 'preorder-products'] + + // 优先通过 seedId 查找 + if (seedId) { + for (const collection of collections) { + const result = await payload.find({ + collection, + where: { + seedId: { equals: seedId }, + }, + limit: 1, + }) + + if (result.docs[0]) { + return { product: result.docs[0], collection } + } + } + } + + // 如果通过 seedId 没找到,使用 medusaId 查找 + for (const collection of collections) { + const result = await payload.find({ + collection, + where: { + medusaId: { equals: medusaId }, + }, + limit: 1, + }) + + if (result.docs[0]) { + return { product: result.docs[0], collection } + } + } + + return null +} + +/** + * 合并产品数据 - 只更新 Payload 中为空的字段 + * @param existingProduct Payload 中现有的产品数据 + * @param newData 从 Medusa 转换来的新数据 + * @param forceUpdate 是否强制更新所有字段 + * @returns 合并后的数据 + */ +function mergeProductData(existingProduct: any, newData: any, forceUpdate: boolean): any { + if (forceUpdate) { + // 强制更新模式:使用所有新数据 + return { ...newData } + } + + // 只填充空值模式:只更新空字段 + const mergedData: any = {} + + // 总是更新这些字段 + mergedData.lastSyncedAt = newData.lastSyncedAt + mergedData.medusaId = newData.medusaId + + // 如果 seedId 为空,更新它 + if (!existingProduct.seedId && newData.seedId) { + mergedData.seedId = newData.seedId + } + + // 只在字段为空时更新 + if (!existingProduct.title) { + mergedData.title = newData.title + } + if (!existingProduct.handle) { + mergedData.handle = newData.handle + } + if (!existingProduct.thumbnail) { + mergedData.thumbnail = newData.thumbnail + } + if (!existingProduct.status) { + mergedData.status = newData.status + } + + return mergedData +} + /** * 同步 Medusa 商品到 Payload CMS * GET /api/sync-medusa @@ -16,6 +105,24 @@ import { */ export async function GET(request: Request) { try { + // 可选的 API Key 验证 + const authHeader = request.headers.get('authorization') + const storeApiKey = process.env.STORE_API_KEY + + // 如果配置了 STORE_API_KEY,则验证请求 + if (storeApiKey && authHeader) { + const token = authHeader.replace('Bearer ', '') + if (token !== storeApiKey) { + return NextResponse.json( + { + success: false, + error: 'Invalid API key', + }, + { status: 401 }, + ) + } + } + const { searchParams } = new URL(request.url) const medusaId = searchParams.get('medusaId') const payloadId = searchParams.get('payloadId') @@ -78,31 +185,18 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force const otherCollection = targetCollection === 'preorder-products' ? 'products' : 'preorder-products' - // 在目标 collection 中检查是否已存在 - const existingInTarget = await payload.find({ - collection: targetCollection, - where: { - medusaId: { equals: medusaId }, - }, - limit: 1, - }) - - // 在另一个 collection 中检查是否存在(产品类型可能改变) - const existingInOther = await payload.find({ - collection: otherCollection, - where: { - medusaId: { equals: medusaId }, - }, - limit: 1, - }) - + // 转换数据 const productData = transformMedusaProductToPayload(medusaProduct) + const seedId = productData.seedId - // 如果在另一个 collection 中存在,需要删除并在正确的 collection 中创建 - if (existingInOther.docs[0]) { + // 使用新的查找函数(优先 seedId) + const found = await findProductBySeedIdOrMedusaId(payload, seedId, medusaId) + + // 如果在另一个 collection 中找到,需要移动 + if (found && found.collection !== targetCollection) { await payload.delete({ - collection: otherCollection, - id: existingInOther.docs[0].id, + collection: found.collection, + id: found.product.id, }) const created = await payload.create({ @@ -113,27 +207,50 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force return { success: true, action: 'moved', - message: `商品 ${medusaId} 已从 ${otherCollection} 移动到 ${targetCollection}`, + message: `商品 ${medusaId} 已从 ${found.collection} 移动到 ${targetCollection}`, productId: created.id, collection: targetCollection, } } - const existingProduct = existingInTarget.docs[0] + // 如果在目标 collection 中找到 + if (found) { + const existingProduct = found.product - // 如果存在且不强制更新,跳过 - if (existingProduct && !forceUpdate) { - return { - success: true, - action: 'skipped', - message: `商品 ${medusaId} 已存在于 ${targetCollection}`, - productId: existingProduct.id, - collection: targetCollection, + // 如果存在且不强制更新,只更新空字段 + if (!forceUpdate) { + // 合并数据(只更新空字段) + const mergedData = mergeProductData(existingProduct, productData, false) + + // 如果没有需要更新的字段,跳过 + if (Object.keys(mergedData).length <= 2) { + // 只有 lastSyncedAt 和 medusaId + return { + success: true, + action: 'skipped', + message: `商品 ${medusaId} 已存在于 ${targetCollection},且所有字段都有值`, + productId: existingProduct.id, + collection: targetCollection, + } + } + + // 更新(只更新空字段) + const updated = await payload.update({ + collection: targetCollection, + id: existingProduct.id, + data: mergedData, + }) + + return { + success: true, + action: 'updated_partial', + message: `商品 ${medusaId} 已部分更新(仅空字段)于 ${targetCollection}`, + productId: updated.id, + collection: targetCollection, + } } - } - if (existingProduct) { - // 更新现有商品 + // 强制更新所有字段 const updated = await payload.update({ collection: targetCollection, id: existingProduct.id, @@ -147,20 +264,20 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force productId: updated.id, collection: targetCollection, } - } else { - // 创建新商品 - const created = await payload.create({ - collection: targetCollection, - data: productData, - }) + } - return { - success: true, - action: 'created', - message: `商品 ${medusaId} 已创建于 ${targetCollection}`, - productId: created.id, - collection: targetCollection, - } + // 不存在,创建新商品 + const created = await payload.create({ + collection: targetCollection, + data: productData, + }) + + return { + success: true, + action: 'created', + message: `商品 ${medusaId} 已创建于 ${targetCollection}`, + productId: created.id, + collection: targetCollection, } } catch (error) { console.error(`Error syncing product ${medusaId}:`, error) @@ -252,6 +369,8 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { total: 0, created: 0, updated: 0, + updated_partial: 0, + moved: 0, skipped: 0, errors: 0, details: [] as any[], @@ -273,34 +392,23 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { try { // 确定应该同步到哪个 collection const targetCollection = getProductCollection(medusaProduct) - const otherCollection = - targetCollection === 'preorder-products' ? 'products' : 'preorder-products' - - // 在目标 collection 中检查是否已存在 - const existingInTarget = await payload.find({ - collection: targetCollection, - where: { - medusaId: { equals: medusaProduct.id }, - }, - limit: 1, - }) - - // 在另一个 collection 中检查是否存在(产品类型可能改变) - const existingInOther = await payload.find({ - collection: otherCollection, - where: { - medusaId: { equals: medusaProduct.id }, - }, - limit: 1, - }) + // 转换数据 const productData = transformMedusaProductToPayload(medusaProduct) + const seedId = productData.seedId + + // 使用新的查找函数(优先 seedId) + const found = await findProductBySeedIdOrMedusaId( + payload, + seedId, + medusaProduct.id, + ) // 如果在错误的 collection 中,移动它 - if (existingInOther.docs[0]) { + if (found && found.collection !== targetCollection) { await payload.delete({ - collection: otherCollection, - id: existingInOther.docs[0].id, + collection: found.collection, + id: found.product.id, }) await payload.create({ @@ -308,33 +416,59 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { data: productData, }) - results.updated++ + results.moved++ results.details.push({ medusaId: medusaProduct.id, + seedId: seedId, title: medusaProduct.title, action: 'moved', - from: otherCollection, + from: found.collection, to: targetCollection, }) continue } - const existingProduct = existingInTarget.docs[0] + // 如果在目标 collection 中找到 + if (found) { + const existingProduct = found.product - // 如果存在且不强制更新,跳过 - if (existingProduct && !forceUpdate) { - results.skipped++ - results.details.push({ - medusaId: medusaProduct.id, - title: medusaProduct.title, - action: 'skipped', - collection: targetCollection, - }) - continue - } + // 如果不强制更新,只更新空字段 + if (!forceUpdate) { + const mergedData = mergeProductData(existingProduct, productData, false) - if (existingProduct) { - // 更新 + // 如果没有需要更新的字段,跳过 + if (Object.keys(mergedData).length <= 2) { + // 只有 lastSyncedAt 和 medusaId + results.skipped++ + results.details.push({ + medusaId: medusaProduct.id, + seedId: seedId, + title: medusaProduct.title, + action: 'skipped', + collection: targetCollection, + }) + continue + } + + // 更新(只更新空字段) + await payload.update({ + collection: targetCollection, + id: existingProduct.id, + data: mergedData, + }) + + results.updated_partial++ + results.details.push({ + medusaId: medusaProduct.id, + seedId: seedId, + title: medusaProduct.title, + action: 'updated_partial', + collection: targetCollection, + }) + continue + } + + // 强制更新 await payload.update({ collection: targetCollection, id: existingProduct.id, @@ -343,12 +477,13 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { results.updated++ results.details.push({ medusaId: medusaProduct.id, + seedId: seedId, title: medusaProduct.title, action: 'updated', collection: targetCollection, }) } else { - // 创建 + // 创建新商品 await payload.create({ collection: targetCollection, data: productData, @@ -356,6 +491,7 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { results.created++ results.details.push({ medusaId: medusaProduct.id, + seedId: seedId, title: medusaProduct.title, action: 'created', collection: targetCollection, @@ -382,7 +518,7 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) { return { success: true, - message: `同步完成: ${results.created} 个创建, ${results.updated} 个更新, ${results.skipped} 个跳过, ${results.errors} 个错误`, + message: `同步完成: ${results.created} 个创建, ${results.updated} 个更新, ${results.updated_partial} 个部分更新, ${results.moved} 个移动, ${results.skipped} 个跳过, ${results.errors} 个错误`, results, } } catch (error) { diff --git a/src/collections/PreorderProducts.ts b/src/collections/PreorderProducts.ts index 98f0392..41e69ba 100644 --- a/src/collections/PreorderProducts.ts +++ b/src/collections/PreorderProducts.ts @@ -39,6 +39,7 @@ export const PreorderProducts: CollectionConfig = { }, beforeListTable: [ '/components/sync/SyncMedusaButton#SyncMedusaButton', + '/components/sync/BatchSyncButton#BatchSyncButton', '/components/list/ProductGridStyler', ], }, @@ -84,6 +85,16 @@ export const PreorderProducts: CollectionConfig = { }, ], }, + { + name: 'seedId', + type: 'text', + unique: true, + index: true, + admin: { + description: 'Seed ID (从 Medusa 同步,用于数据绑定)', + readOnly: true, + }, + }, { name: 'title', type: 'text', diff --git a/src/collections/Products.ts b/src/collections/Products.ts index 6c7c62e..6da188b 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -39,6 +39,7 @@ export const Products: CollectionConfig = { }, beforeListTable: [ '/components/sync/SyncMedusaButton#SyncMedusaButton', + '/components/sync/BatchSyncButton#BatchSyncButton', '/components/list/ProductGridStyler', ], }, @@ -90,6 +91,16 @@ export const Products: CollectionConfig = { }, ], }, + { + name: 'seedId', + type: 'text', + unique: true, + index: true, + admin: { + description: 'Seed ID (恥从 Medusa 同步,用于数据绑定)', + readOnly: true, + }, + }, { name: 'title', type: 'text', diff --git a/src/components/sync/BatchSyncButton.tsx b/src/components/sync/BatchSyncButton.tsx new file mode 100644 index 0000000..005fa44 --- /dev/null +++ b/src/components/sync/BatchSyncButton.tsx @@ -0,0 +1,128 @@ +'use client' +import { useState } from 'react' +import { Button, useSelection } from '@payloadcms/ui' +import { useRouter } from 'next/navigation' + +/** + * 批量同步按钮组件 + * 用于同步选中的产品到 Medusa + */ +export function BatchSyncButton() { + const { getQueryParams, selectAll, toggleAll } = useSelection() + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const router = useRouter() + + // 获取当前页面的 collection slug + const pathname = typeof window !== 'undefined' ? window.location.pathname : '' + const collectionSlug = pathname.includes('preorder-products') + ? 'preorder-products' + : 'products' + + const handleBatchSync = async (forceUpdate: boolean = false) => { + try { + const queryParams = getQueryParams() + + // 尝试从不同的位置获取选中的 IDs + let selectedIds: string[] = [] + + if (queryParams && typeof queryParams === 'object') { + // 尝试从 where 条件中获取 + const whereCondition = (queryParams as any).where + if (whereCondition?.id?.in) { + selectedIds = whereCondition.id.in + } + } + + if (!selectedIds || selectedIds.length === 0) { + setMessage('请先勾选要同步的商品(使用列表左侧的复选框)') + return + } + + if ( + forceUpdate && + !confirm(`确定要强制更新选中的 ${selectedIds.length} 个商品吗?这将覆盖本地修改。`) + ) { + return + } + + setLoading(true) + setMessage('') + + const response = await fetch('/api/batch-sync-medusa', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ids: selectedIds, + collection: collectionSlug, + forceUpdate, + }), + }) + + const data = await response.json() + + if (data.success) { + setMessage(data.message || '批量同步成功!') + // 清除选择 + if (toggleAll) { + toggleAll() + } + // 刷新页面 + setTimeout(() => { + router.refresh() + }, 1500) + } else { + setMessage(`批量同步失败: ${data.error || '未知错误'}`) + } + } catch (error) { + setMessage(`批量同步出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setLoading(false) + } + } + + return ( +
+

批量操作

+ +
+ + +
+ + {message && ( +
+ {message} +
+ )} + +
+

+ • 同步选中商品: 只更新选中商品的空字段 +

+

+ • 强制更新选中商品: 覆盖选中商品的所有字段 +

+
+
+ ) +} diff --git a/src/components/sync/ForceSyncButton.tsx b/src/components/sync/ForceSyncButton.tsx index 4514668..49e8eaf 100644 --- a/src/components/sync/ForceSyncButton.tsx +++ b/src/components/sync/ForceSyncButton.tsx @@ -8,7 +8,7 @@ import { useDocumentInfo } from '@payloadcms/ui' * 用于在商品编辑页面强制更新该商品的信息 */ export function ForceSyncButton() { - const { id } = useDocumentInfo() + const { id, collectionSlug } = useDocumentInfo() const [loading, setLoading] = useState(false) const [message, setMessage] = useState('') @@ -26,9 +26,12 @@ export function ForceSyncButton() { setMessage('') try { - const response = await fetch(`/api/sync-medusa?payloadId=${id}&forceUpdate=true`, { - method: 'GET', - }) + const response = await fetch( + `/api/sync-medusa?payloadId=${id}&collection=${collectionSlug}&forceUpdate=true`, + { + method: 'GET', + }, + ) const data = await response.json() diff --git a/src/lib/medusa.ts b/src/lib/medusa.ts index 2b20862..f422efd 100644 --- a/src/lib/medusa.ts +++ b/src/lib/medusa.ts @@ -15,7 +15,8 @@ interface MedusaProduct { created_at: string updated_at: string metadata?: Record & { - is_preorder?: boolean + is_preorder?: boolean | string + seed_id?: string } images?: Array<{ id: string @@ -177,10 +178,12 @@ export async function uploadImageFromUrl(imageUrl: string, payload: any): Promis /** * 判断产品是否为预售商品 - * 检查 metadata.is_preorder + * 检查 metadata.is_preorder (支持布尔值和字符串) */ export function isPreorderProduct(product: MedusaProduct): boolean { - return product.metadata?.is_preorder === true + const isPreorder = product.metadata?.is_preorder + // 支持布尔值 true 或字符串 "true" + return isPreorder === true || isPreorder === 'true' } /** @@ -204,6 +207,7 @@ export function transformMedusaProductToPayload(product: MedusaProduct) { return { medusaId: product.id, + seedId: product.metadata?.seed_id || null, title: product.title, handle: product.handle, thumbnail: thumbnailUrl || null, diff --git a/src/payload-types.ts b/src/payload-types.ts index d7ac0ef..937aae2 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -205,6 +205,10 @@ export interface Product { * 商品详情状态 */ status: 'draft' | 'published'; + /** + * Seed ID (恥从 Medusa 同步,用于数据绑定) + */ + seedId?: string | null; /** * 商品标题(从 Medusa 同步) */ @@ -270,6 +274,10 @@ export interface PreorderProduct { * 商品详情状态 */ status: 'draft' | 'published'; + /** + * Seed ID (从 Medusa 同步,用于数据绑定) + */ + seedId?: string | null; /** * 商品标题(从 Medusa 同步) */ @@ -762,6 +770,7 @@ export interface MediaSelect { export interface ProductsSelect { medusaId?: T; status?: T; + seedId?: T; title?: T; handle?: T; thumbnail?: T; @@ -778,6 +787,7 @@ export interface ProductsSelect { export interface PreorderProductsSelect { medusaId?: T; status?: T; + seedId?: T; title?: T; handle?: T; thumbnail?: T;