同步优化

This commit is contained in:
龟男日记\www 2026-02-21 22:52:41 +08:00
parent 4def0e2c0b
commit 14a2aaced0
19 changed files with 1489 additions and 722 deletions

View File

@ -3,36 +3,37 @@ import { ThumbnailField as ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6 } fro
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
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 { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview'
import { UnifiedSyncButton as UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523 } from '../../../components/sync/UnifiedSyncButton'
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 { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { PreorderOrdersField as PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f } from '../../../components/fields/PreorderOrdersField'
import { PreorderHealthCheckButton as PreorderHealthCheckButton_5c0756e0fa67593931ce171329b92892 } from '../../../components/views/PreorderHealthCheckButton'
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'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel'
import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView'
import { RestoreRecommendationsSeedButton as RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da } from '../../../components/seed/RestoreRecommendationsSeedButton'
@ -45,36 +46,37 @@ export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
"/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959,
"/components/sync/UnifiedSyncButton#UnifiedSyncButton": UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523,
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
"/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b,
"/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/PreorderOrdersField#PreorderOrdersField": PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f,
"/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton": PreorderHealthCheckButton_5c0756e0fa67593931ce171329b92892,
"/components/list/PreorderProductGridStyler#PreorderProductGridStyler": PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4,
"/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f,
"/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da,

View File

@ -0,0 +1,76 @@
# 数据重置功能说明
## 概述
通过 Admin Settings 中的"数据重置"按钮,一键完成完整的数据重置流程。
## 功能
**一键重置所有数据**,包括:
1. 清理 Payload CMS 数据(保留用户)
2. 清理 Medusa 数据
3. 重新导入 Medusa seed 数据
## 使用方法
1. 登录 Payload CMS: `http://localhost:1145/admin`
2. 进入 **系统 → Admin Settings**
3. 在"数据管理"区域找到"🔄 数据重置Payload + Medusa"
4. 点击"🗑️ 重置所有数据"按钮
5. 确认操作后等待完成
## 技术实现
### Payload CMS 端
**API 端点:**
- `POST /api/admin/reset-data` - 数据重置主控端点
**UI 组件:**
- `ResetDataButton` - 重置按钮组件
- `AdminPanel` - 管理面板(包含重置按钮)
### Medusa 端
**API 端点:**
- `POST /admin/custom/clean` - 清理 Medusa 数据
- `POST /admin/custom/seed-pro` - 导入 seed 数据
## 执行流程
```
用户点击按钮
调用 /api/admin/reset-data
步骤 1: 清理 Payload 数据Products, PreorderProducts, Media, Announcements, Articles, Logs
步骤 2: 调用 Medusa /admin/custom/clean
步骤 3: 调用 Medusa /admin/custom/seed-pro
返回结果和详细信息
```
## 后续操作
数据重置完成后需要:
1. 同步 Medusa 商品到 Payload CMS
2. 设置 ProductRecommendations 商品推荐
3. 配置 PreorderProducts 的预购设置fundingGoal, preorderEndDate 等)
## 注意事项
⚠️ **危险操作** - 此操作不可撤销!
- 会删除所有商品、媒体、公告、文章和日志数据
- 保留用户账户和系统配置
- 整个过程可能需要 2-3 分钟
## 已移除的文件
精简脚本后移除了:
- `reset-data.bat` - 批处理脚本
- `gb-payload/src/scripts/clean-payload.ts` - 清理脚本
- `gb-payload/package.json` 中的 `clean` 命令
现在所有操作通过 Web UI 完成,无需命令行。

View File

@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* API Route: Reset All Data
* POST /api/admin/reset-data
*
*
* 1. Payload CMS
* 2. Medusa
* 3. Medusa seed
*/
export async function POST(request: NextRequest) {
try {
const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const results: any = {
steps: [],
success: true,
}
// ==================== 步骤 1: 清理 Payload 数据 ====================
console.log('🧹 [1/3] 开始清理 Payload CMS 数据...')
const payloadResult = await cleanPayloadData()
results.steps.push({
step: 1,
name: 'Clean Payload',
success: payloadResult.success,
deleted: payloadResult.totalDeleted,
details: payloadResult.details,
})
if (!payloadResult.success) {
results.success = false
return NextResponse.json(results, { status: 500 })
}
// ==================== 步骤 2: 清理 Medusa 数据 ====================
console.log('🧹 [2/3] 开始清理 Medusa 数据...')
try {
const cleanResponse = await fetch(`${MEDUSA_BACKEND_URL}/admin/custom/clean`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (!cleanResponse.ok) {
throw new Error(`Medusa clean failed: ${cleanResponse.statusText}`)
}
const cleanData = await cleanResponse.json()
results.steps.push({
step: 2,
name: 'Clean Medusa',
success: true,
details: cleanData,
})
console.log('✅ Medusa 数据清理完成')
} catch (error) {
console.error('❌ Medusa 清理失败:', error)
results.steps.push({
step: 2,
name: 'Clean Medusa',
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})
results.success = false
return NextResponse.json(results, { status: 500 })
}
// ==================== 步骤 3: Seed Medusa 数据 ====================
console.log('🌱 [3/3] 开始导入 Medusa 数据...')
try {
const seedResponse = await fetch(`${MEDUSA_BACKEND_URL}/admin/custom/seed-pro`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (!seedResponse.ok) {
throw new Error(`Medusa seed failed: ${seedResponse.statusText}`)
}
const seedData = await seedResponse.json()
results.steps.push({
step: 3,
name: 'Seed Medusa',
success: true,
details: seedData,
})
console.log('✅ Medusa 数据导入完成')
} catch (error) {
console.error('❌ Medusa seed 失败:', error)
results.steps.push({
step: 3,
name: 'Seed Medusa',
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})
results.success = false
return NextResponse.json(results, { status: 500 })
}
// ==================== 完成 ====================
console.log('✨ 数据重置完成!')
results.message = '数据重置完成!现在可以同步 Medusa 商品到 Payload CMS。'
return NextResponse.json(results, { status: 200 })
} catch (error) {
console.error('❌ 数据重置失败:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}
/**
* Payload CMS
*/
async function cleanPayloadData() {
const payload = await getPayload({ config })
const collections = [
'media',
'products',
'preorder-products',
'announcements',
'articles',
'logs'
]
const details: any = {}
let totalDeleted = 0
for (const collection of collections) {
try {
const result = await payload.find({
collection: collection as any,
limit: 1000,
})
if (result.totalDocs > 0) {
for (const doc of result.docs) {
await payload.delete({
collection: collection as any,
id: doc.id,
})
}
details[collection] = result.totalDocs
totalDeleted += result.totalDocs
console.log(`${collection}: 已删除 ${result.totalDocs} 条记录`)
} else {
details[collection] = 0
console.log(` ${collection}: 集合为空`)
}
} catch (error) {
console.error(`${collection}: 清理失败`, error)
details[collection] = { error: error instanceof Error ? error.message : 'Unknown error' }
}
}
console.log(`✅ Payload 数据清理完成,共删除 ${totalDeleted} 条记录`)
return {
success: true,
totalDeleted,
details,
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/debug/preorder-products
* Debug endpoint to check PreorderProducts data
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 直接查询 PreorderProducts 集合
const preorderProducts = await payload.find({
collection: 'preorder-products',
limit: 5,
depth: 0, // 不查询关联数据
})
console.log('=== PreorderProducts Debug ===')
console.log('Total docs:', preorderProducts.totalDocs)
console.log('First doc:', JSON.stringify(preorderProducts.docs[0], null, 2))
console.log('===============================')
return NextResponse.json({
total: preorderProducts.totalDocs,
products: preorderProducts.docs.map(doc => ({
id: doc.id,
title: doc.title,
medusaId: doc.medusaId,
thumbnail: doc.thumbnail,
description: doc.description,
preorderType: doc.preorderType,
fundingGoal: doc.fundingGoal,
orderCount: doc.orderCount,
preorderStartDate: doc.preorderStartDate,
preorderEndDate: doc.preorderEndDate,
allFields: Object.keys(doc),
})),
}, { status: 200 })
} catch (error: any) {
console.error('Error fetching preorder products:', error)
return NextResponse.json(
{
error: 'Failed to fetch preorder products',
message: error.message,
},
{ status: 500 }
)
}
}

View File

@ -4,7 +4,7 @@ import config from '@payload-config'
/**
* GET /api/home
* + Hero Slider +
* + Hero Slider +
*/
export async function GET(req: NextRequest) {
try {
@ -36,11 +36,14 @@ export async function GET(req: NextRequest) {
slug: 'hero-slider',
})
// 获取产品推荐
// 获取产品推荐(包含深度查询的产品信息)
const productRecommendations = await payload.findGlobal({
slug: 'product-recommendations',
depth: 3, // 增加深度以确保完全获取嵌套数据
})
console.log('Raw productRecommendations:', JSON.stringify(productRecommendations, null, 2))
// 构建响应数据
const response = {
announcements: announcements.docs.map((announcement) => ({
@ -56,7 +59,74 @@ export async function GET(req: NextRequest) {
},
productRecommendations: {
enabled: productRecommendations.enabled || false,
lists: productRecommendations.lists || [],
lists: (productRecommendations.lists || []).map((list: any) => ({
title: list.title,
subtitle: list.subtitle,
preorder: list.preorder || false,
products: (list.products || []).map((productRef: any) => {
const product = productRef.value
// 调试日志:查看实际接收到的产品数据
console.log('=== Product Debug Info ===')
console.log('relationTo:', productRef.relationTo)
console.log('product object:', product)
console.log('Available fields:', Object.keys(product || {}))
console.log('=========================')
// 处理 description 字段
// Products 使用 textarea (字符串)PreorderProducts 使用 richText (对象)
let description = ''
if (typeof product.description === 'string') {
description = product.description
} else if (product.description && typeof product.description === 'object') {
// richText 字段,提取纯文本(简单处理)
description = JSON.stringify(product.description)
}
// 基础产品信息
const baseInfo = {
id: product.id,
medusaId: product.medusaId,
seedId: product.seedId,
title: product.title,
thumbnail: product.thumbnail,
status: product.status,
description,
minPrice: product.minPrice,
}
// 如果是预购产品,添加预购特有字段
if (productRef.relationTo === 'preorder-products') {
return {
...baseInfo,
relationTo: 'preorder-products',
preorder: {
type: product.preorderType || 'standard',
fundingGoal: product.fundingGoal || 0,
orderCount: product.orderCount || 0,
startDate: product.preorderStartDate,
endDate: product.preorderEndDate,
// 计算进度百分比
progress: product.fundingGoal > 0
? Math.min(Math.round((product.orderCount / product.fundingGoal) * 100), 100)
: 0,
// 计算剩余天数
daysLeft: product.preorderEndDate
? Math.max(0, Math.ceil((new Date(product.preorderEndDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null,
// 支持者数量(使用 orderCount
backers: product.orderCount || 0,
},
}
}
// 普通产品
return {
...baseInfo,
relationTo: 'products',
}
}),
})),
},
}

View File

@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/preorders/health-check
*
*
*
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 获取所有预购产品
const { docs: products } = await payload.find({
collection: 'preorder-products',
limit: 1000,
depth: 2,
})
if (!products || products.length === 0) {
return NextResponse.json({
success: true,
summary: {
total: 0,
healthy: 0,
warnings: 0,
errors: 0,
},
products: [],
issues: [],
})
}
// 检查每个产品
const productChecks: any[] = []
const allIssues: string[] = []
let healthyCount = 0
let warningCount = 0
let errorCount = 0
for (const product of products) {
const issues: string[] = []
let severity: 'healthy' | 'warning' | 'error' = 'healthy'
// 检查必要字段
if (!product.medusaId) {
issues.push('缺少 Medusa ID')
severity = 'error'
}
if (!product.title) {
issues.push('缺少产品标题')
severity = 'error'
}
// 检查预购设置
if (product.fundingGoal === undefined || product.fundingGoal === null) {
issues.push('未设置众筹目标')
severity = severity === 'error' ? 'error' : 'warning'
} else if (product.fundingGoal === 0) {
issues.push('众筹目标为 0将使用变体总和')
}
// 检查日期
if (!product.preorderStartDate) {
issues.push('未设置预购开始日期')
severity = severity === 'error' ? 'error' : 'warning'
}
if (!product.preorderEndDate) {
issues.push('未设置预购结束日期')
severity = severity === 'error' ? 'error' : 'warning'
}
// 检查日期逻辑
if (product.preorderStartDate && product.preorderEndDate) {
const startDate = new Date(product.preorderStartDate)
const endDate = new Date(product.preorderEndDate)
if (startDate >= endDate) {
issues.push('预购开始日期晚于或等于结束日期')
severity = 'error'
}
const now = new Date()
if (endDate < now) {
issues.push('预购已结束')
} else if (startDate > now) {
issues.push('预购尚未开始')
}
}
// 检查订单计数
const orderCount = parseInt(String(product.orderCount || 0), 10)
const fakeOrderCount = parseInt(String(product.fakeOrderCount || 0), 10)
const totalDisplayCount = orderCount + fakeOrderCount
const fundingGoal = parseInt(String(product.fundingGoal || 0), 10)
if (fundingGoal > 0) {
const completionPercentage = Math.round((totalDisplayCount / fundingGoal) * 100)
if (completionPercentage >= 100) {
issues.push(`已达成目标 (${completionPercentage}%)`)
} else if (completionPercentage < 10) {
issues.push(`完成度较低 (${completionPercentage}%)`)
severity = severity === 'error' ? 'error' : 'warning'
}
}
// 检查状态
if (product.status !== 'published') {
issues.push(`产品状态为: ${product.status}`)
severity = severity === 'error' ? 'error' : 'warning'
}
// 更新统计
if (severity === 'error') {
errorCount++
} else if (severity === 'warning' || issues.length > 0) {
warningCount++
} else {
healthyCount++
}
// 记录产品检查结果
productChecks.push({
id: product.id,
title: product.title,
medusaId: product.medusaId,
seedId: product.seedId,
status: product.status,
severity,
issues,
stats: {
orderCount,
fakeOrderCount,
totalDisplayCount,
fundingGoal,
completionPercentage: fundingGoal > 0
? Math.round((totalDisplayCount / fundingGoal) * 100)
: 0,
},
dates: {
preorderStartDate: product.preorderStartDate,
preorderEndDate: product.preorderEndDate,
},
})
// 添加到全局问题列表
if (issues.length > 0) {
allIssues.push(`${product.title}: ${issues.join(', ')}`)
}
}
// 按严重程度排序
productChecks.sort((a, b) => {
const severityOrder: { [key: string]: number } = { error: 0, warning: 1, healthy: 2 }
return severityOrder[a.severity] - severityOrder[b.severity]
})
return NextResponse.json({
success: true,
timestamp: new Date().toISOString(),
summary: {
total: products.length,
healthy: healthyCount,
warnings: warningCount,
errors: errorCount,
},
products: productChecks,
issues: allIssues,
})
} catch (error: any) {
console.error('[Health Check API] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Failed to check preorder products health',
message: error.message
},
{ status: 500 }
)
}
}

View File

@ -1,123 +0,0 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getAllMedusaProducts } from '@/lib/medusa'
/**
* Batch Sync Selected Products
* POST /api/sync/batch-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 })
// Get all Medusa products once
const medusaProducts = await getAllMedusaProducts()
const medusaProductMap = new Map(medusaProducts.map(p => [p.id, p]))
const results = {
total: ids.length,
success: 0,
failed: 0,
skipped: 0,
details: [] as any[],
}
// Sync each selected product
for (const id of ids) {
try {
const product = await payload.findByID({
collection: collection as 'products' | 'preorder-products',
id,
})
if (!product || !product.medusaId) {
results.skipped++
results.details.push({
id,
title: product?.title || 'Unknown',
status: 'skipped',
reason: 'No Medusa ID',
})
continue
}
const medusaProduct = medusaProductMap.get(product.medusaId)
if (!medusaProduct) {
results.failed++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'failed',
error: 'Product not found in Medusa',
})
continue
}
// Update basic fields from Medusa
const updateData: any = {
lastSyncedAt: new Date().toISOString(),
}
if (forceUpdate || !product.title) updateData.title = medusaProduct.title
if (forceUpdate || !product.thumbnail) updateData.thumbnail = medusaProduct.thumbnail
await payload.update({
collection: collection as 'products' | 'preorder-products',
id,
data: updateData,
})
results.success++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'success',
})
} catch (error) {
results.failed++
results.details.push({
id,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return NextResponse.json({
success: true,
message: `Batch sync completed: ${results.success} success, ${results.failed} failed, ${results.skipped} skipped`,
results,
})
} catch (error) {
console.error('[batch-sync-medusa] Error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
}
}

View File

@ -1,526 +0,0 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import {
getAllMedusaProducts,
transformMedusaProductToPayload,
getMedusaProductsPaginated,
getProductCollection,
} from '@/lib/medusa'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
/**
* CORS
*/
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin')
return handleCorsOptions(origin)
}
/**
* 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
}
// Medusa 属性字段:总是更新(以 Medusa 为准)
mergedData.tags = newData.tags
mergedData.type = newData.type
mergedData.collection = newData.collection
mergedData.category = newData.category
// 物理属性:总是更新
mergedData.height = newData.height
mergedData.width = newData.width
mergedData.length = newData.length
mergedData.weight = newData.weight
// 海关与物流:总是更新
mergedData.midCode = newData.midCode
mergedData.hsCode = newData.hsCode
mergedData.countryOfOrigin = newData.countryOfOrigin
return mergedData
}
/**
* Medusa Payload CMS
* GET /api/sync/medusa -
* GET /api/sync/medusa?medusaId=prod_xxx -
* GET /api/sync/medusa?medusaId=prod_xxx&collection=preorder-products - collection
* GET /api/sync/medusa?forceUpdate=true -
*/
export async function GET(request: Request) {
const origin = request.headers.get('origin')
try {
// 可选的 API Key 验证
const authHeader = request.headers.get('authorization')
const payloadApiKey = process.env.PAYLOAD_API_KEY
// 如果配置了 PAYLOAD_API_KEY则验证请求
if (payloadApiKey && authHeader) {
const token = authHeader.replace('Bearer ', '')
if (token !== payloadApiKey) {
const response = NextResponse.json(
{
success: false,
error: 'Invalid API key',
},
{ status: 401 },
)
return addCorsHeaders(response, origin)
}
}
const { searchParams } = new URL(request.url)
const medusaId = searchParams.get('medusaId')
const collection = searchParams.get('collection') as 'products' | 'preorder-products' | null
const forceUpdate = searchParams.get('forceUpdate') === 'true'
const payload = await getPayload({ config })
// 同步单个商品
if (medusaId) {
const result = await syncSingleProductByMedusaId(payload, medusaId, forceUpdate, collection || undefined)
const response = NextResponse.json(result)
return addCorsHeaders(response, origin)
}
// 同步所有商品
const result = await syncAllProducts(payload, forceUpdate)
const response = NextResponse.json(result)
return addCorsHeaders(response, origin)
} catch (error) {
console.error('[Sync API] ❌ 请求处理失败:', error)
const response = NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
return addCorsHeaders(response, origin)
}
}
/**
* Medusa ID
*/
async function syncSingleProductByMedusaId(
payload: any,
medusaId: string,
forceUpdate: boolean,
preferredCollection?: 'products' | 'preorder-products'
) {
console.log(`[Sync API] 🔄 开始同步产品: ${medusaId}`)
console.log(`[Sync API] ⚙️ forceUpdate: ${forceUpdate}, preferredCollection: ${preferredCollection || 'auto'}`)
try {
// 从 Medusa 获取商品数据
const medusaProducts = await getAllMedusaProducts()
const medusaProduct = medusaProducts.find((p) => p.id === medusaId)
if (!medusaProduct) {
console.error(`[Sync API] ❌ Medusa 中未找到商品: ${medusaId}`)
return {
success: false,
action: 'not_found',
message: `Medusa 中未找到商品 ${medusaId}`,
}
}
console.log(`[Sync API] ✅ 找到 Medusa 产品: ${medusaProduct.title}`)
// 确定应该同步到哪个 collection优先使用传入的 collection否则自动判断
const targetCollection = preferredCollection || getProductCollection(medusaProduct)
console.log(`[Sync API] 🎯 目标 collection: ${targetCollection}${preferredCollection ? ' (指定)' : ' (自动判断)'}`)
const otherCollection =
targetCollection === 'preorder-products' ? 'products' : 'preorder-products'
// 转换数据
const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId
console.log(`[Sync API] 📦 产品数据: title=${productData.title}, seedId=${seedId}`)
// 使用新的查找函数(优先 seedId
const found = await findProductBySeedIdOrMedusaId(payload, seedId, medusaId)
// 如果在另一个 collection 中找到,需要移动
if (found && found.collection !== targetCollection) {
console.log(`[Sync API] 🚚 需要移动: ${found.collection} -> ${targetCollection}`)
await payload.delete({
collection: found.collection,
id: found.product.id,
})
const created = await payload.create({
collection: targetCollection,
data: productData,
})
console.log(`[Sync API] ✅ 移动成功, 新 ID: ${created.id}`)
return {
success: true,
action: 'moved',
message: `商品 ${medusaId} 已从 ${found.collection} 移动到 ${targetCollection}`,
productId: created.id,
collection: targetCollection,
}
}
// 如果在目标 collection 中找到
if (found) {
console.log(`[Sync API] 🔎 在 ${found.collection} 中找到现有产品, ID: ${found.product.id}`)
const existingProduct = found.product
// 如果存在且不强制更新,只更新空字段
if (!forceUpdate) {
console.log(`[Sync API] 🔄 模式: 只填充空字段,但 Medusa 属性总是更新`)
// 合并数据(只更新空字段,但 Medusa 属性总是更新)
const mergedData = mergeProductData(existingProduct, productData, false)
console.log(`[Sync API] 📝 更新字段(含 Medusa 属性): ${Object.keys(mergedData).join(', ')}`)
// 更新(只更新空字段 + Medusa 属性)
const updated = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: mergedData,
})
console.log(`[Sync API] ✅ 部分更新成功`)
return {
success: true,
action: 'updated_partial',
message: `商品 ${medusaId} 已部分更新(仅空字段)于 ${targetCollection}`,
productId: updated.id,
collection: targetCollection,
}
}
console.log(`[Sync API] ⚡ 模式: 强制更新所有字段`)
// 强制更新所有字段
const updated = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: productData,
})
console.log(`[Sync API] ✅ 强制更新成功`)
return {
success: true,
action: 'updated',
message: `商品 ${medusaId} 已更新于 ${targetCollection}`,
productId: updated.id,
collection: targetCollection,
}
}
console.log(`[Sync API] ✨ 创建新产品`)
// 不存在,创建新商品
const created = await payload.create({
collection: targetCollection,
data: productData,
})
console.log(`[Sync API] ✅ 创建成功, ID: ${created.id}`)
return {
success: true,
action: 'created',
message: `商品 ${medusaId} 已创建于 ${targetCollection}`,
productId: created.id,
collection: targetCollection,
}
} catch (error) {
console.error(`[Sync API] ❌ 同步失败:`, error)
return {
success: false,
action: 'error',
message: `同步商品 ${medusaId} 失败`,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
*
*/
async function syncAllProducts(payload: any, forceUpdate: boolean) {
try {
let offset = 0
const limit = 100
let hasMore = true
const results = {
total: 0,
created: 0,
updated: 0,
updated_partial: 0,
moved: 0,
skipped: 0,
errors: 0,
details: [] as any[],
}
while (hasMore) {
// 分页获取 Medusa 商品
const { products: medusaProducts, count } = await getMedusaProductsPaginated(offset, limit)
if (medusaProducts.length === 0) {
hasMore = false
break
}
results.total += medusaProducts.length
// 处理每个商品
for (const medusaProduct of medusaProducts) {
try {
// 确定应该同步到哪个 collection
const targetCollection = getProductCollection(medusaProduct)
// 转换数据
const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId
// 使用新的查找函数(优先 seedId
const found = await findProductBySeedIdOrMedusaId(
payload,
seedId,
medusaProduct.id,
)
// 如果在错误的 collection 中,移动它
if (found && found.collection !== targetCollection) {
await payload.delete({
collection: found.collection,
id: found.product.id,
})
await payload.create({
collection: targetCollection,
data: productData,
})
results.moved++
results.details.push({
medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title,
action: 'moved',
from: found.collection,
to: targetCollection,
})
continue
}
// 如果在目标 collection 中找到
if (found) {
const existingProduct = found.product
// 如果不强制更新,只更新空字段,但 Medusa 属性总是更新
if (!forceUpdate) {
const mergedData = mergeProductData(existingProduct, productData, false)
// 更新(只更新空字段 + Medusa 属性)
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,
data: productData,
})
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,
})
results.created++
results.details.push({
medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title,
action: 'created',
collection: targetCollection,
})
}
} catch (error) {
console.error(`Error processing product ${medusaProduct.id}:`, error)
results.errors++
results.details.push({
medusaId: medusaProduct.id,
title: medusaProduct.title,
action: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
// 更新偏移量
offset += limit
if (offset >= count) {
hasMore = false
}
}
return {
success: true,
message: `同步完成: ${results.created} 个创建, ${results.updated} 个更新, ${results.updated_partial} 个部分更新, ${results.moved} 个移动, ${results.skipped} 个跳过, ${results.errors} 个错误`,
results,
}
} catch (error) {
console.error('Error syncing all products:', error)
throw error
}
}
/**
* POST /api/sync/medusa
* GET
* Body: { medusaId?, collection?, forceUpdate? }
*/
export async function POST(request: Request) {
const origin = request.headers.get('origin')
try {
const payload = await getPayload({ config })
// 可以在这里添加认证检查
// const { user } = await payload.auth({ headers: request.headers })
// if (!user) {
// const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// return addCorsHeaders(response, origin)
// }
const body = await request.json()
const { medusaId, collection, forceUpdate = true } = body
// 同步单个商品
if (medusaId) {
const result = await syncSingleProductByMedusaId(payload, medusaId, forceUpdate, collection)
const response = NextResponse.json(result)
return addCorsHeaders(response, origin)
}
// 同步所有商品
const result = await syncAllProducts(payload, forceUpdate)
const response = NextResponse.json(result)
return addCorsHeaders(response, origin)
} catch (error) {
console.error('[Sync API] ❌ POST 请求处理失败:', error)
const response = NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
return addCorsHeaders(response, origin)
}
}

View File

@ -5,6 +5,7 @@ import {
getAllMedusaProducts,
transformMedusaProductToPayload,
getProductCollection,
convertTextToLexical,
} from '@/lib/medusa'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
@ -30,7 +31,15 @@ export async function POST(request: Request) {
try {
const body = await request.json()
const { medusaId, collection: preferredCollection, forceUpdate = false } = body
const {
medusaId,
collection: preferredCollection,
forceUpdate = false,
// 预购相关字段(从 Medusa subscriber 传递)
fundingGoal,
preorderStartDate,
preorderEndDate,
} = body
if (!medusaId) {
const response = NextResponse.json(
@ -43,7 +52,12 @@ export async function POST(request: Request) {
return addCorsHeaders(response, origin)
}
console.log('[Sync Product API] 🎯 参数:', { medusaId, preferredCollection, forceUpdate })
console.log('[Sync Product API] 🎯 参数:', {
medusaId,
preferredCollection,
forceUpdate,
preorderData: { fundingGoal, preorderStartDate, preorderEndDate }
})
const payload = await getPayload({ config })
@ -123,9 +137,15 @@ export async function POST(request: Request) {
id: existingProduct.id,
})
// 准备移动数据,包括描述转换
const moveData: any = { ...productData }
if (medusaProduct.description) {
moveData.description = convertTextToLexical(medusaProduct.description)
}
finalProduct = await payload.create({
collection: targetCollection,
data: productData,
data: moveData,
})
action = 'moved'
@ -148,6 +168,19 @@ export async function POST(request: Request) {
if (!existingProduct.handle) mergedData.handle = productData.handle
if (!existingProduct.thumbnail) mergedData.thumbnail = productData.thumbnail
if (!existingProduct.status) mergedData.status = productData.status
// 描述为空时也从 Medusa 导入(转换为富文本格式)
if (!existingProduct.description && medusaProduct.description) {
(mergedData as any).description = convertTextToLexical(medusaProduct.description)
}
// 最低价格和 seedId总是更新
mergedData.seedId = productData.seedId
mergedData.startPrice = productData.startPrice
// 如果是预购产品fundingGoal 也总是更新
if (targetCollection === 'preorder-products' && fundingGoal !== undefined) {
(mergedData as any).fundingGoal = fundingGoal
}
// Medusa 属性字段:总是更新(以 Medusa 为准)
mergedData.tags = productData.tags
@ -166,6 +199,18 @@ export async function POST(request: Request) {
mergedData.hsCode = productData.hsCode
mergedData.countryOfOrigin = productData.countryOfOrigin
// 如果是预购产品,添加预购日期字段(只在为空时更新)
if (targetCollection === 'preorder-products') {
// Preorder Start Date - 只在为空时更新
if (!existingProduct.preorderStartDate && preorderStartDate) {
(mergedData as any).preorderStartDate = preorderStartDate
}
// Preorder End Date - 只在为空时更新
if (!existingProduct.preorderEndDate && preorderEndDate) {
(mergedData as any).preorderEndDate = preorderEndDate
}
}
console.log(`[Sync Product API] 📝 更新字段(含 Medusa 属性): ${Object.keys(mergedData).join(', ')}`)
finalProduct = await payload.update({
collection: targetCollection,
@ -174,12 +219,16 @@ export async function POST(request: Request) {
})
action = 'updated_partial'
} else {
// 强制更新所有字段
// 强制更新所有字段(包括描述转换为富文本)
console.log(`[Sync Product API] ⚡ 强制更新所有字段`)
const forceUpdateData: any = { ...productData }
if (medusaProduct.description) {
forceUpdateData.description = convertTextToLexical(medusaProduct.description)
}
finalProduct = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: productData,
data: forceUpdateData,
})
action = 'updated'
}
@ -187,9 +236,35 @@ export async function POST(request: Request) {
// 不存在,创建新产品
else {
console.log(`[Sync Product API] ✨ 创建新产品`)
// 如果是预购产品,添加预购相关字段
const createData: any = { ...productData }
// 添加描述(转换为富文本格式)
if (medusaProduct.description) {
createData.description = convertTextToLexical(medusaProduct.description)
}
if (targetCollection === 'preorder-products') {
if (fundingGoal !== undefined) {
createData.fundingGoal = fundingGoal
}
if (preorderStartDate) {
createData.preorderStartDate = preorderStartDate
}
if (preorderEndDate) {
createData.preorderEndDate = preorderEndDate
}
console.log(`[Sync Product API] 📋 添加预购字段:`, {
fundingGoal: createData.fundingGoal,
preorderStartDate: createData.preorderStartDate,
preorderEndDate: createData.preorderEndDate,
})
}
finalProduct = await payload.create({
collection: targetCollection,
data: productData,
data: createData,
})
action = 'created'
}

View File

@ -37,6 +37,7 @@ export const PreorderProducts: CollectionConfig = {
components: {
beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton',
'/components/list/PreorderProductGridStyler#PreorderProductGridStyler',
],
},

View File

@ -3,13 +3,25 @@ import { logAfterChange, logAfterDelete } from '../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
import { ProductBaseFields, RelatedProductsField, TaobaoLinksField, MedusaAttributesTab } from './base/ProductBase'
import {
AlignFeature,
BlocksFeature,
BoldFeature,
ChecklistFeature,
HeadingFeature,
IndentFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
RelationshipFeature,
UnorderedListFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
BlockquoteFeature,
} from '@payloadcms/richtext-lexical'
export const Products: CollectionConfig = {
@ -44,29 +56,37 @@ export const Products: CollectionConfig = {
label: '📄 商品详情',
fields: [
{
name: 'content',
name: 'description',
type: 'richText',
admin: {
description: '商品详细内容(支持图文混排)',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
LinkFeature({
enabledCollections: ['products'],
fields: ({ defaultFields }) => [
...defaultFields,
features: [
ParagraphFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
LinkFeature(),
AlignFeature(),
BlockquoteFeature(),
HorizontalRuleFeature(),
InlineCodeFeature(),
IndentFeature(),
ChecklistFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
BlocksFeature({
blocks: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
slug: 'image',
imageURL: '/api/media',
fields: [
{
name: 'caption',
type: 'text',
label: '图片说明',
},
],
},
],
}),
@ -76,19 +96,19 @@ export const Products: CollectionConfig = {
fields: [
{
name: 'caption',
type: 'richText',
type: 'text',
label: '图片说明',
editor: lexicalEditor(),
},
],
},
},
}),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
RelationshipFeature(),
],
}),
admin: {
description: '商品详细描述(支持富文本编辑)',
},
},
],
},

View File

@ -76,6 +76,14 @@ export const ProductBaseFields: Field[] = [
},
},
},
{
name: 'startPrice',
type: 'number',
admin: {
description: '起始价格(从 Medusa 同步,单位:美分)',
readOnly: true,
},
},
{
name: 'lastSyncedAt',
type: 'date',

View File

@ -6,6 +6,10 @@ export const ThumbnailCell = (props: any) => {
const value = props.value || props.cellData || props.data
const rowData = props.rowData || props.row
// 获取起始价格(已经是美元)
const startPrice = rowData?.startPrice
const formattedPrice = startPrice ? `$${startPrice.toFixed(2)}` : ''
// 优先从 props 中获取 collection 信息Payload Cell API
let collectionSlug = props.collectionConfig?.slug || props.field?.relationTo || props.collection
@ -22,13 +26,30 @@ export const ThumbnailCell = (props: any) => {
return (
<Link
href={editUrl}
style={{ display: 'block', width: '100%', height: '200px', textDecoration: 'none' }}
style={{ display: 'block', width: '100%', textDecoration: 'none', position: 'relative' }}
>
<div style={{ position: 'relative', width: '100%', height: '200px' }}>
{isImage ? (
<img src={value} alt="商品缩略图" className="thumbnail-img" />
) : (
<div className="no-image">{value || '无图片'}</div>
)}
{formattedPrice && (
<div style={{
position: 'absolute',
bottom: '8px',
right: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
}}>
{formattedPrice}
</div>
)}
</div>
</Link>
)
}

View File

@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
interface Props {
className?: string
}
/**
* Reset Data Button
* Payload + Medusa + Seed Medusa
*/
export function ResetDataButton({ className }: Props) {
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [details, setDetails] = useState<any>(null)
const handleResetData = async () => {
if (!confirm(
'⚠️ 危险操作:重置所有数据\n\n' +
'此操作将:\n' +
'1. 清理所有 Payload CMS 数据(保留用户)\n' +
'2. 清理所有 Medusa 数据\n' +
'3. 重新导入 Medusa seed 数据\n\n' +
'⚠️ 此操作不可撤销!\n\n' +
'确认要继续吗?'
)) {
return
}
setLoading(true)
setMessage('🔄 开始数据重置流程...')
setDetails(null)
try {
const response = await fetch('/api/admin/reset-data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Reset failed')
}
setDetails(result)
setMessage(
'✅ 数据重置完成!\n\n' +
'下一步:\n' +
'1. 同步 Medusa 商品到 Payload CMS\n' +
'2. 设置 ProductRecommendations\n' +
'3. 配置 PreorderProducts 的预购设置'
)
} catch (error) {
console.error('数据重置失败:', error)
setMessage('❌ 重置失败: ' + (error instanceof Error ? error.message : 'Unknown error'))
} finally {
setLoading(false)
}
}
return (
<div className={className}>
<div style={{ marginBottom: '1rem' }}>
<Button
onClick={handleResetData}
buttonStyle="error"
disabled={loading}
size="medium"
>
{loading ? '🔄 重置中...' : '🗑️ 重置所有数据'}
</Button>
</div>
{message && (
<div
style={{
padding: '1rem',
backgroundColor: message.includes('✅') ? '#d4edda' : message.includes('❌') ? '#f8d7da' : '#d1ecf1',
border: `1px solid ${message.includes('✅') ? '#c3e6cb' : message.includes('❌') ? '#f5c6cb' : '#bee5eb'}`,
borderRadius: '4px',
marginTop: '1rem',
whiteSpace: 'pre-wrap',
fontSize: '0.9rem',
}}
>
{message}
</div>
)}
{details && details.steps && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px',
fontSize: '0.85rem',
}}
>
<h4 style={{ marginTop: 0, marginBottom: '0.5rem' }}></h4>
{details.steps.map((step: any, index: number) => (
<div key={index} style={{ marginBottom: '0.5rem' }}>
<strong>
[{step.step}/3] {step.name}:{' '}
</strong>
<span style={{ color: step.success ? 'green' : 'red' }}>
{step.success ? '✅ 成功' : '❌ 失败'}
</span>
{step.deleted !== undefined && (
<span style={{ marginLeft: '0.5rem' }}>
( {step.deleted} )
</span>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@ -2,6 +2,7 @@
import React, { useState } from 'react'
import { Button } from '@payloadcms/ui'
import { ResetDataButton } from '../sync/ResetDataButton'
/**
*
@ -144,6 +145,33 @@ export default function AdminPanel() {
📦
</h2>
{/* 数据重置 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
marginBottom: '1rem',
border: '1px solid var(--theme-elevation-150)',
}}
>
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
🔄 Payload + Medusa
</h3>
<p
style={{
marginBottom: '1rem',
fontSize: '0.875rem',
color: 'var(--theme-elevation-600)',
}}
>
Payload CMS Medusa Medusa seed
</p>
<ResetDataButton />
</div>
{/* 清理数据库 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',

View File

@ -0,0 +1,248 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Button } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: {
total: number
healthy: number
warnings: number
errors: number
}
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: {
preorderStartDate: string | null
preorderEndDate: string | null
}
}>
issues: string[]
}
export const PreorderHealthCheck: React.FC = () => {
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runHealthCheck = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/preorders/health-check')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setResult(data)
} catch (err: any) {
setError(err.message || 'Failed to run health check')
console.error('Health check error:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
// 自动运行一次健康检查
runHealthCheck()
}, [])
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return 'text-red-600 bg-red-50'
case 'warning':
return 'text-yellow-600 bg-yellow-50'
case 'healthy':
return 'text-green-600 bg-green-50'
default:
return 'text-gray-600 bg-gray-50'
}
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return '❌'
case 'warning':
return '⚠️'
case 'healthy':
return '✅'
default:
return ''
}
}
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A'
try {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return dateString
}
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-600">
</p>
</div>
<Button
onClick={runHealthCheck}
disabled={loading}
buttonStyle="primary"
>
{loading ? '检查中...' : '刷新检查'}
</Button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">
<strong>:</strong> {error}
</p>
</div>
)}
{result && (
<>
{/* 概览统计 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<p className="text-sm text-blue-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-blue-700">{result.summary.total}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
<p className="text-sm text-green-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-green-700">{result.summary.healthy}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<p className="text-sm text-yellow-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-yellow-700">{result.summary.warnings}</p>
</div>
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<p className="text-sm text-red-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-red-700">{result.summary.errors}</p>
</div>
</div>
{/* 检查时间 */}
<p className="text-sm text-gray-500 mb-6">
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div className="space-y-4">
{result.products.map((product) => (
<div
key={product.id}
className={`border rounded-lg p-4 ${
product.severity === 'error'
? 'border-red-300 bg-red-50'
: product.severity === 'warning'
? 'border-yellow-300 bg-yellow-50'
: 'border-green-300 bg-green-50'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{getSeverityIcon(product.severity)}</span>
<h3 className="text-lg font-semibold">{product.title}</h3>
<span
className={`px-2 py-1 text-xs rounded-full ${
product.status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{product.status}
</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-600">
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
</div>
{/* 日期信息 */}
<div className="flex gap-4 text-sm text-gray-600 mb-3">
<div>
<span className="font-medium">:</span>{' '}
{formatDate(product.dates.preorderStartDate)}
</div>
<div>
<span className="font-medium">:</span>{' '}
{formatDate(product.dates.preorderEndDate)}
</div>
</div>
{/* 问题列表 */}
{product.issues.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-300">
<p className="text-sm font-medium mb-2">:</p>
<ul className="text-sm space-y-1">
{product.issues.map((issue, idx) => (
<li key={idx} className="flex items-start">
<span className="mr-2"></span>
<span>{issue}</span>
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
{result.products.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg"></p>
</div>
)}
</>
)}
{!result && !error && loading && (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,260 @@
'use client'
import React, { useState } from 'react'
import { Button, Modal } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: {
total: number
healthy: number
warnings: number
errors: number
}
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: {
preorderStartDate: string | null
preorderEndDate: string | null
}
}>
issues: string[]
}
export const PreorderHealthCheckButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runHealthCheck = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/preorders/health-check')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setResult(data)
setIsOpen(true)
} catch (err: any) {
setError(err.message || 'Failed to run health check')
console.error('Health check error:', err)
} finally {
setLoading(false)
}
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return '❌'
case 'warning':
return '⚠️'
case 'healthy':
return '✅'
default:
return ''
}
}
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A'
try {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return dateString
}
}
return (
<>
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem' }}>
<Button
onClick={runHealthCheck}
disabled={loading}
buttonStyle="secondary"
icon="health"
>
{loading ? '检查中...' : '🏥 健康检查'}
</Button>
{error && (
<span style={{ color: 'red', marginLeft: '1rem', alignSelf: 'center' }}>
: {error}
</span>
)}
</div>
{isOpen && result && (
<Modal slug="health-check-modal" onClose={() => setIsOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.5rem', fontWeight: 'bold' }}>
</h2>
{/* 概览统计 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<div style={{
padding: '1rem',
backgroundColor: '#EFF6FF',
borderRadius: '0.5rem',
border: '1px solid #BFDBFE'
}}>
<p style={{ fontSize: '0.875rem', color: '#2563EB', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#1E40AF' }}>{result.summary.total}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#F0FDF4',
borderRadius: '0.5rem',
border: '1px solid #BBF7D0'
}}>
<p style={{ fontSize: '0.875rem', color: '#16A34A', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#15803D' }}>{result.summary.healthy}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#FEFCE8',
borderRadius: '0.5rem',
border: '1px solid #FDE047'
}}>
<p style={{ fontSize: '0.875rem', color: '#CA8A04', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#A16207' }}>{result.summary.warnings}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#FEF2F2',
borderRadius: '0.5rem',
border: '1px solid #FECACA'
}}>
<p style={{ fontSize: '0.875rem', color: '#DC2626', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#B91C1C' }}>{result.summary.errors}</p>
</div>
</div>
<p style={{ fontSize: '0.875rem', color: '#6B7280', marginBottom: '1.5rem' }}>
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{result.products.map((product) => (
<div
key={product.id}
style={{
border: `1px solid ${
product.severity === 'error'
? '#FCA5A5'
: product.severity === 'warning'
? '#FCD34D'
: '#86EFAC'
}`,
backgroundColor: product.severity === 'error'
? '#FEF2F2'
: product.severity === 'warning'
? '#FEFCE8'
: '#F0FDF4',
borderRadius: '0.5rem',
padding: '1rem',
marginBottom: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>{getSeverityIcon(product.severity)}</span>
<h3 style={{ fontSize: '1.125rem', fontWeight: '600' }}>{product.title}</h3>
<span
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
borderRadius: '9999px',
backgroundColor: product.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: product.status === 'published' ? '#065F46' : '#374151',
}}
>
{product.status}
</span>
</div>
<div style={{ fontSize: '0.875rem', color: '#4B5563' }}>
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.875rem', color: '#4B5563' }}>
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#4B5563', marginBottom: '0.75rem' }}>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{formatDate(product.dates.preorderStartDate)}
</div>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{formatDate(product.dates.preorderEndDate)}
</div>
</div>
{product.issues.length > 0 && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid #D1D5DB' }}>
<p style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>:</p>
<ul style={{ fontSize: '0.875rem', paddingLeft: '1rem' }}>
{product.issues.map((issue, idx) => (
<li key={idx} style={{ marginBottom: '0.25rem' }}>
{issue}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
{result.products.length === 0 && (
<p style={{ textAlign: 'center', padding: '3rem', color: '#6B7280' }}>
</p>
)}
<div style={{ marginTop: '1.5rem', textAlign: 'right' }}>
<Button onClick={() => setIsOpen(false)} buttonStyle="primary">
</Button>
</div>
</div>
</Modal>
)}
</>
)
}

View File

@ -217,6 +217,47 @@ export function isPreorderProduct(product: MedusaProduct): boolean {
return false
}
/**
* Lexical
*
*/
export function convertTextToLexical(text: string | null | undefined): any {
if (!text) return null
// 按换行符分割文本,创建多个段落
const paragraphs = text.split(/\r?\n/).filter(line => line.trim().length > 0)
if (paragraphs.length === 0) return null
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: paragraphs.map(paragraph => ({
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: paragraph.trim(),
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
})),
direction: 'ltr',
},
}
}
/**
* collection
*/
@ -248,6 +289,20 @@ export function transformMedusaProductToPayload(product: MedusaProduct) {
// 提取 category从 categories 数组或 metadata
const category = product.categories?.[0]?.name || product.metadata?.category || null
// 计算起始价格(从所有 variants 的 prices 中找最低价格,并转换为美元)
let startPrice: number | null = null
if (product.variants && product.variants.length > 0) {
const allPrices = product.variants.flatMap(variant =>
variant.prices?.map(price => price.amount) || []
).filter(price => typeof price === 'number' && price > 0)
if (allPrices.length > 0) {
// 将美分转换为美元(保留两位小数)
const minPriceInCents = Math.min(...allPrices)
startPrice = Math.round(minPriceInCents) / 100
}
}
return {
medusaId: product.id,
seedId: product.metadata?.seed_id || null,
@ -256,6 +311,7 @@ export function transformMedusaProductToPayload(product: MedusaProduct) {
thumbnail: thumbnailUrl || null,
status: 'draft',
lastSyncedAt: new Date().toISOString(),
startPrice: startPrice,
// Medusa 默认属性
tags: tags,

View File

@ -217,14 +217,18 @@ export interface Product {
* URL URL
*/
thumbnail?: string | null;
/**
* Medusa
*/
startPrice?: number | null;
/**
*
*/
lastSyncedAt?: string | null;
/**
*
*
*/
content?: {
description?: {
root: {
type: string;
children: {
@ -344,6 +348,10 @@ export interface PreorderProduct {
* URL URL
*/
thumbnail?: string | null;
/**
* Medusa
*/
startPrice?: number | null;
/**
*
*/
@ -910,8 +918,9 @@ export interface ProductsSelect<T extends boolean = true> {
seedId?: T;
title?: T;
thumbnail?: T;
startPrice?: T;
lastSyncedAt?: T;
content?: T;
description?: T;
relatedProducts?: T;
tags?: T;
type?: T;
@ -946,6 +955,7 @@ export interface PreorderProductsSelect<T extends boolean = true> {
seedId?: T;
title?: T;
thumbnail?: T;
startPrice?: T;
lastSyncedAt?: T;
preorderType?: T;
fundingGoal?: T;