数据储存
This commit is contained in:
parent
a7da4da87c
commit
93f8261622
|
|
@ -24,6 +24,7 @@
|
||||||
"@payloadcms/plugin-cloud-storage": "^3.75.0",
|
"@payloadcms/plugin-cloud-storage": "^3.75.0",
|
||||||
"@payloadcms/richtext-lexical": "3.75.0",
|
"@payloadcms/richtext-lexical": "3.75.0",
|
||||||
"@payloadcms/storage-s3": "^3.75.0",
|
"@payloadcms/storage-s3": "^3.75.0",
|
||||||
|
"@payloadcms/translations": "3.75.0",
|
||||||
"@payloadcms/ui": "3.75.0",
|
"@payloadcms/ui": "3.75.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "16.4.7",
|
"dotenv": "16.4.7",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ importers:
|
||||||
'@payloadcms/storage-s3':
|
'@payloadcms/storage-s3':
|
||||||
specifier: ^3.75.0
|
specifier: ^3.75.0
|
||||||
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
|
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
|
||||||
|
'@payloadcms/translations':
|
||||||
|
specifier: 3.75.0
|
||||||
|
version: 3.75.0
|
||||||
'@payloadcms/ui':
|
'@payloadcms/ui':
|
||||||
specifier: 3.75.0
|
specifier: 3.75.0
|
||||||
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
|
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
|
||||||
|
|
@ -8139,8 +8142,8 @@ snapshots:
|
||||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
||||||
eslint: 9.39.2
|
eslint: 9.39.2
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2)
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.2)
|
eslint-plugin-react: 7.37.5(eslint@9.39.2)
|
||||||
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2)
|
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2)
|
||||||
|
|
@ -8159,7 +8162,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -8170,22 +8173,22 @@ snapshots:
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
||||||
eslint: 9.39.2
|
eslint: 9.39.2
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|
@ -8196,7 +8199,7 @@ snapshots:
|
||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.39.2
|
eslint: 9.39.2
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2)
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { ForceSyncButton as ForceSyncButton_86f9d5df4f20495427521354d06db618 } from '../../../components/products/ForceSyncButton'
|
||||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
|
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
|
"/components/products/ForceSyncButton#ForceSyncButton": ForceSyncButton_86f9d5df4f20495427521354d06db618,
|
||||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import {
|
||||||
|
getAllMedusaProducts,
|
||||||
|
transformMedusaProductToPayload,
|
||||||
|
getMedusaProductsPaginated,
|
||||||
|
} from '@/lib/medusa'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步 Medusa 商品到 Payload CMS
|
||||||
|
* GET /api/sync-medusa
|
||||||
|
* GET /api/sync-medusa?medusaId=prod_xxx (通过 Medusa ID 同步单个商品)
|
||||||
|
* GET /api/sync-medusa?payloadId=123 (通过 Payload ID 同步单个商品)
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const medusaId = searchParams.get('medusaId')
|
||||||
|
const payloadId = searchParams.get('payloadId')
|
||||||
|
const forceUpdate = searchParams.get('forceUpdate') === 'true'
|
||||||
|
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// 同步单个商品(通过 Medusa ID)
|
||||||
|
if (medusaId) {
|
||||||
|
const result = await syncSingleProductByMedusaId(payload, medusaId, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步单个商品(通过 Payload ID)
|
||||||
|
if (payloadId) {
|
||||||
|
const result = await syncSingleProductByPayloadId(payload, payloadId, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步所有商品
|
||||||
|
const result = await syncAllProducts(payload, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Medusa ID 同步单个商品
|
||||||
|
*/
|
||||||
|
async function syncSingleProductByMedusaId(payload: any, medusaId: string, forceUpdate: boolean) {
|
||||||
|
try {
|
||||||
|
// 检查商品是否已存在
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
where: {
|
||||||
|
medusaId: { equals: medusaId },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingProduct = existing.docs[0]
|
||||||
|
|
||||||
|
// 如果存在且不强制更新,跳过
|
||||||
|
if (existingProduct && !forceUpdate) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'skipped',
|
||||||
|
message: `商品 ${medusaId} 已存在`,
|
||||||
|
productId: existingProduct.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Medusa 获取商品数据
|
||||||
|
const medusaProducts = await getAllMedusaProducts()
|
||||||
|
const medusaProduct = medusaProducts.find((p) => p.id === medusaId)
|
||||||
|
|
||||||
|
if (!medusaProduct) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'not_found',
|
||||||
|
message: `Medusa 中未找到商品 ${medusaId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productData = transformMedusaProductToPayload(medusaProduct)
|
||||||
|
|
||||||
|
if (existingProduct) {
|
||||||
|
// 更新现有商品
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: 'products',
|
||||||
|
id: existingProduct.id,
|
||||||
|
data: productData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'updated',
|
||||||
|
message: `商品 ${medusaId} 已更新`,
|
||||||
|
productId: updated.id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建新商品
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: 'products',
|
||||||
|
data: productData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'created',
|
||||||
|
message: `商品 ${medusaId} 已创建`,
|
||||||
|
productId: created.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing product ${medusaId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Payload ID 同步单个商品
|
||||||
|
*/
|
||||||
|
async function syncSingleProductByPayloadId(payload: any, payloadId: string, forceUpdate: boolean) {
|
||||||
|
try {
|
||||||
|
// 获取 Payload 商品
|
||||||
|
const product = await payload.findByID({
|
||||||
|
collection: 'products',
|
||||||
|
id: payloadId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'not_found',
|
||||||
|
message: `Payload 中未找到商品 ID: ${payloadId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 medusaId,无法同步
|
||||||
|
if (!product.medusaId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'no_medusa_id',
|
||||||
|
message: `商品 ${product.title} 没有关联的 Medusa ID,无法同步`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 medusaId 同步
|
||||||
|
return await syncSingleProductByMedusaId(payload, product.medusaId, forceUpdate)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing product by Payload ID ${payloadId}:`, error)
|
||||||
|
throw 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,
|
||||||
|
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 {
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
where: {
|
||||||
|
medusaId: { equals: medusaProduct.id },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingProduct = existing.docs[0]
|
||||||
|
|
||||||
|
// 如果存在且不强制更新,跳过
|
||||||
|
if (existingProduct && !forceUpdate) {
|
||||||
|
results.skipped++
|
||||||
|
results.details.push({
|
||||||
|
medusaId: medusaProduct.id,
|
||||||
|
title: medusaProduct.title,
|
||||||
|
action: 'skipped',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const productData = transformMedusaProductToPayload(medusaProduct)
|
||||||
|
|
||||||
|
if (existingProduct) {
|
||||||
|
// 更新
|
||||||
|
await payload.update({
|
||||||
|
collection: 'products',
|
||||||
|
id: existingProduct.id,
|
||||||
|
data: productData,
|
||||||
|
})
|
||||||
|
results.updated++
|
||||||
|
results.details.push({
|
||||||
|
medusaId: medusaProduct.id,
|
||||||
|
title: medusaProduct.title,
|
||||||
|
action: 'updated',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
await payload.create({
|
||||||
|
collection: 'products',
|
||||||
|
data: productData,
|
||||||
|
})
|
||||||
|
results.created++
|
||||||
|
results.details.push({
|
||||||
|
medusaId: medusaProduct.id,
|
||||||
|
title: medusaProduct.title,
|
||||||
|
action: 'created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} 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.skipped} 个跳过, ${results.errors} 个错误`,
|
||||||
|
results,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing all products:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync-medusa
|
||||||
|
* 触发手动同步(需要认证)
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// 可以在这里添加认证检查
|
||||||
|
// const { user } = await payload.auth({ headers: request.headers })
|
||||||
|
// if (!user) {
|
||||||
|
// return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
// }
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { medusaId, payloadId, forceUpdate = true } = body
|
||||||
|
|
||||||
|
if (medusaId) {
|
||||||
|
const result = await syncSingleProductByMedusaId(payload, medusaId, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payloadId) {
|
||||||
|
const result = await syncSingleProductByPayloadId(payload, payloadId, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await syncAllProducts(payload, forceUpdate)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const Products: CollectionConfig = {
|
||||||
|
slug: 'products',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
defaultColumns: ['thumbnail', 'title', 'medusaId', 'status', 'updatedAt'],
|
||||||
|
description: '管理 Medusa 商品的详细内容和描述',
|
||||||
|
listSearchableFields: ['title', 'medusaId', 'handle'],
|
||||||
|
pagination: {
|
||||||
|
defaultLimit: 20,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
edit: {
|
||||||
|
PreviewButton: '/components/products/ForceSyncButton#ForceSyncButton',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: () => true, // 公开可读
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'medusaId',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: 'Medusa 商品 ID',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: '商品标题(从 Medusa 同步)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'handle',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '商品 URL slug(从 Medusa 同步)',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thumbnail',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '商品缩略图 URL(从 Medusa 同步)',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'draft',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '草稿',
|
||||||
|
value: 'draft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '已发布',
|
||||||
|
value: 'published',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: '商品详情状态',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'relatedProducts',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'products',
|
||||||
|
hasMany: true,
|
||||||
|
admin: {
|
||||||
|
description: '相关商品',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastSyncedAt',
|
||||||
|
type: 'date',
|
||||||
|
admin: {
|
||||||
|
description: '最后同步时间',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@payloadcms/ui'
|
||||||
|
import { useDocumentInfo } from '@payloadcms/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个商品强制同步按钮
|
||||||
|
* 用于在商品编辑页面强制更新该商品的信息
|
||||||
|
*/
|
||||||
|
export function ForceSyncButton() {
|
||||||
|
const { id } = useDocumentInfo()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
const handleForceSync = async () => {
|
||||||
|
if (!id) {
|
||||||
|
setMessage('无法获取商品 ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('确定要从 Medusa 强制更新此商品吗?这将覆盖当前的商品信息。')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sync-medusa?payloadId=${id}&forceUpdate=true`, {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setMessage(data.message || '强制同步成功!')
|
||||||
|
// 刷新页面显示更新后的数据
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} else {
|
||||||
|
setMessage(`同步失败: ${data.error || data.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`同步出错: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<Button onClick={handleForceSync} disabled={loading} buttonStyle="secondary">
|
||||||
|
{loading ? '同步中...' : '从 Medusa 强制更新'}
|
||||||
|
</Button>
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor:
|
||||||
|
message.includes('失败') || message.includes('出错')
|
||||||
|
? 'var(--theme-error-50)'
|
||||||
|
: 'var(--theme-success-50)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@payloadcms/ui'
|
||||||
|
|
||||||
|
export function SyncMedusaButton() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [showConfirmInput, setShowConfirmInput] = useState(false)
|
||||||
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync-medusa?forceUpdate=false', {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setMessage(data.message || '同步成功!')
|
||||||
|
// 刷新页面显示新商品
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} else {
|
||||||
|
setMessage(`同步失败: ${data.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`同步出错: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForceUpdateAll = () => {
|
||||||
|
setShowConfirmInput(true)
|
||||||
|
setMessage('')
|
||||||
|
setConfirmText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmForceUpdate = async () => {
|
||||||
|
if (confirmText !== 'FORCE_UPDATE_ALL') {
|
||||||
|
setMessage('确认字符不正确,请输入: FORCE_UPDATE_ALL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
setShowConfirmInput(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync-medusa?forceUpdate=true', {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setMessage(data.message || '强制更新成功!')
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} else {
|
||||||
|
setMessage(`同步失败: ${data.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`同步出错: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setConfirmText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelForceUpdate = () => {
|
||||||
|
setShowConfirmInput(false)
|
||||||
|
setConfirmText('')
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem', borderTop: '1px solid var(--theme-elevation-100)' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Medusa 商品同步</h3>
|
||||||
|
|
||||||
|
{showConfirmInput ? (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: 'var(--theme-warning-50)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 0.5rem 0',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'var(--theme-warning-900)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ 危险操作
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}>
|
||||||
|
这将强制更新所有已存在的商品,覆盖所有本地修改。
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
请输入{' '}
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
padding: '0.125rem 0.25rem',
|
||||||
|
backgroundColor: 'var(--theme-elevation-100)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
FORCE_UPDATE_ALL
|
||||||
|
</code>{' '}
|
||||||
|
确认:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="输入 FORCE_UPDATE_ALL"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
border: '1px solid var(--theme-elevation-400)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmForceUpdate}
|
||||||
|
disabled={loading || confirmText !== 'FORCE_UPDATE_ALL'}
|
||||||
|
>
|
||||||
|
{loading ? '更新中...' : '确认强制更新'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancelForceUpdate} disabled={loading} buttonStyle="secondary">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
<Button onClick={handleSync} disabled={loading}>
|
||||||
|
{loading ? '同步中...' : '同步新商品'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleForceUpdateAll} disabled={loading} buttonStyle="secondary">
|
||||||
|
强制更新全部
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor:
|
||||||
|
message.includes('失败') || message.includes('出错') || message.includes('不正确')
|
||||||
|
? 'var(--theme-error-50)'
|
||||||
|
: 'var(--theme-success-50)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showConfirmInput && (
|
||||||
|
<div
|
||||||
|
style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--theme-elevation-400)' }}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}>
|
||||||
|
• <strong>同步新商品</strong>: 从 Medusa 导入尚未同步的商品,不会更新已存在的商品。
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
• <strong>强制更新全部</strong>: 更新所有商品,覆盖本地修改(需要确认)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* Medusa API 客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
|
||||||
|
const MEDUSA_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
|
||||||
|
|
||||||
|
interface MedusaProduct {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
handle: string
|
||||||
|
description: string
|
||||||
|
thumbnail: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
images?: Array<{
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
}>
|
||||||
|
variants?: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
prices: Array<{
|
||||||
|
amount: number
|
||||||
|
currency_code: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
tags?: Array<{
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
}>
|
||||||
|
collection_id?: string
|
||||||
|
type_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MedusaResponse<T> {
|
||||||
|
products?: T[]
|
||||||
|
product?: T
|
||||||
|
count?: number
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 Medusa 商品
|
||||||
|
*/
|
||||||
|
export async function getAllMedusaProducts(): Promise<MedusaProduct[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${MEDUSA_BACKEND_URL}/store/products`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch products: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: MedusaResponse<MedusaProduct> = await response.json()
|
||||||
|
return data.products || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Medusa products:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 Medusa 商品
|
||||||
|
*/
|
||||||
|
export async function getMedusaProduct(productId: string): Promise<MedusaProduct | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${MEDUSA_BACKEND_URL}/store/products/${productId}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch product: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: MedusaResponse<MedusaProduct> = await response.json()
|
||||||
|
return data.product || null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching Medusa product ${productId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取 Medusa 商品
|
||||||
|
*/
|
||||||
|
export async function getMedusaProductsPaginated(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<{ products: MedusaProduct[]; count: number }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MEDUSA_BACKEND_URL}/store/products?offset=${offset}&limit=${limit}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch products: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: MedusaResponse<MedusaProduct> = await response.json()
|
||||||
|
return {
|
||||||
|
products: data.products || [],
|
||||||
|
count: data.count || 0,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Medusa products (paginated):', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 Medusa 商品为 Payload 格式
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 从 URL 下载并上传图片到 Payload Media 集合
|
||||||
|
*/
|
||||||
|
export async function uploadImageFromUrl(imageUrl: string, payload: any): Promise<string | null> {
|
||||||
|
if (!imageUrl) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载图片
|
||||||
|
const response = await fetch(imageUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to download image: ${imageUrl}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const arrayBuffer = await blob.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
// 从 URL 提取文件名
|
||||||
|
const urlParts = imageUrl.split('/')
|
||||||
|
const filename = urlParts[urlParts.length - 1] || 'product-image.jpg'
|
||||||
|
|
||||||
|
// 上传到 Media 集合
|
||||||
|
const media = await payload.create({
|
||||||
|
collection: 'media',
|
||||||
|
data: {
|
||||||
|
alt: filename,
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
data: buffer,
|
||||||
|
mimetype: blob.type || 'image/jpeg',
|
||||||
|
name: filename,
|
||||||
|
size: buffer.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return media.id
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error uploading image from URL ${imageUrl}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 Medusa 商品数据到 Payload 格式
|
||||||
|
* 直接保存 Medusa 的图片 URL,不上传到 Media 集合
|
||||||
|
*/
|
||||||
|
export function transformMedusaProductToPayload(product: MedusaProduct) {
|
||||||
|
// 优先使用 thumbnail,如果没有则使用第一张图片的 URL
|
||||||
|
let thumbnailUrl = product.thumbnail
|
||||||
|
|
||||||
|
if (!thumbnailUrl && product.images && product.images.length > 0) {
|
||||||
|
thumbnailUrl = product.images[0].url
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medusaId: product.id,
|
||||||
|
title: product.title,
|
||||||
|
handle: product.handle,
|
||||||
|
thumbnail: thumbnailUrl || null,
|
||||||
|
status: 'draft',
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TABLE "users_sessions" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone,
|
||||||
|
"expires_at" timestamp(3) with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"reset_password_token" varchar,
|
||||||
|
"reset_password_expiration" timestamp(3) with time zone,
|
||||||
|
"salt" varchar,
|
||||||
|
"hash" varchar,
|
||||||
|
"login_attempts" numeric DEFAULT 0,
|
||||||
|
"lock_until" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"alt" varchar NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"url" varchar,
|
||||||
|
"thumbnail_u_r_l" varchar,
|
||||||
|
"filename" varchar,
|
||||||
|
"mime_type" varchar,
|
||||||
|
"filesize" numeric,
|
||||||
|
"width" numeric,
|
||||||
|
"height" numeric,
|
||||||
|
"focal_x" numeric,
|
||||||
|
"focal_y" numeric
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "products" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"medusa_id" varchar NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"handle" varchar,
|
||||||
|
"thumbnail" varchar,
|
||||||
|
"status" "enum_products_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"last_synced_at" timestamp(3) with time zone,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "products_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"products_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_kv" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar NOT NULL,
|
||||||
|
"data" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"global_slug" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer,
|
||||||
|
"media_id" integer,
|
||||||
|
"products_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar,
|
||||||
|
"value" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_migrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar,
|
||||||
|
"batch" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_products_fk" FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_products_fk" FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||||
|
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||||
|
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||||
|
CREATE UNIQUE INDEX "products_medusa_id_idx" ON "products" USING btree ("medusa_id");
|
||||||
|
CREATE INDEX "products_updated_at_idx" ON "products" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "products_created_at_idx" ON "products" USING btree ("created_at");
|
||||||
|
CREATE INDEX "products_rels_order_idx" ON "products_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "products_rels_parent_idx" ON "products_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "products_rels_path_idx" ON "products_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "products_rels_products_id_idx" ON "products_rels" USING btree ("products_id");
|
||||||
|
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||||
|
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_products_id_idx" ON "payload_locked_documents_rels" USING btree ("products_id");
|
||||||
|
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP TABLE "users_sessions" CASCADE;
|
||||||
|
DROP TABLE "users" CASCADE;
|
||||||
|
DROP TABLE "media" CASCADE;
|
||||||
|
DROP TABLE "products" CASCADE;
|
||||||
|
DROP TABLE "products_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_kv" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_migrations" CASCADE;
|
||||||
|
DROP TYPE "public"."enum_products_status";`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as migration_20260208_171142 from './20260208_171142'
|
||||||
|
|
||||||
|
export const migrations = [
|
||||||
|
{
|
||||||
|
up: migration_20260208_171142.up,
|
||||||
|
down: migration_20260208_171142.down,
|
||||||
|
name: '20260208_171142',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -69,6 +69,7 @@ export interface Config {
|
||||||
collections: {
|
collections: {
|
||||||
users: User;
|
users: User;
|
||||||
media: Media;
|
media: Media;
|
||||||
|
products: Product;
|
||||||
'payload-kv': PayloadKv;
|
'payload-kv': PayloadKv;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
|
|
@ -78,6 +79,7 @@ export interface Config {
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
|
|
@ -158,6 +160,45 @@ export interface Media {
|
||||||
focalX?: number | null;
|
focalX?: number | null;
|
||||||
focalY?: number | null;
|
focalY?: number | null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 管理 Medusa 商品的详细内容和描述
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "products".
|
||||||
|
*/
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Medusa 商品 ID
|
||||||
|
*/
|
||||||
|
medusaId: string;
|
||||||
|
/**
|
||||||
|
* 商品标题(从 Medusa 同步)
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* 商品 URL slug(从 Medusa 同步)
|
||||||
|
*/
|
||||||
|
handle?: string | null;
|
||||||
|
/**
|
||||||
|
* 商品缩略图 URL(从 Medusa 同步)
|
||||||
|
*/
|
||||||
|
thumbnail?: string | null;
|
||||||
|
/**
|
||||||
|
* 商品详情状态
|
||||||
|
*/
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
/**
|
||||||
|
* 相关商品
|
||||||
|
*/
|
||||||
|
relatedProducts?: (number | Product)[] | null;
|
||||||
|
/**
|
||||||
|
* 最后同步时间
|
||||||
|
*/
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-kv".
|
* via the `definition` "payload-kv".
|
||||||
|
|
@ -189,6 +230,10 @@ export interface PayloadLockedDocument {
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'media';
|
relationTo: 'media';
|
||||||
value: number | Media;
|
value: number | Media;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'products';
|
||||||
|
value: number | Product;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -272,6 +317,21 @@ export interface MediaSelect<T extends boolean = true> {
|
||||||
focalX?: T;
|
focalX?: T;
|
||||||
focalY?: T;
|
focalY?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "products_select".
|
||||||
|
*/
|
||||||
|
export interface ProductsSelect<T extends boolean = true> {
|
||||||
|
medusaId?: T;
|
||||||
|
title?: T;
|
||||||
|
handle?: T;
|
||||||
|
thumbnail?: T;
|
||||||
|
status?: T;
|
||||||
|
relatedProducts?: T;
|
||||||
|
lastSyncedAt?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-kv_select".
|
* via the `definition` "payload-kv_select".
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ import sharp from 'sharp'
|
||||||
|
|
||||||
import { Users } from './collections/Users'
|
import { Users } from './collections/Users'
|
||||||
import { Media } from './collections/Media'
|
import { Media } from './collections/Media'
|
||||||
|
import { Products } from './collections/Products'
|
||||||
import { s3Storage } from '@payloadcms/storage-s3'
|
import { s3Storage } from '@payloadcms/storage-s3'
|
||||||
|
import { en } from '@payloadcms/translations/languages/en'
|
||||||
|
import { zh } from '@payloadcms/translations/languages/zh'
|
||||||
|
|
||||||
|
// 导入自定义翻译
|
||||||
|
import enProducts from './translations/en/products.json'
|
||||||
|
import zhProducts from './translations/zh/products.json'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
@ -19,7 +26,20 @@ export default buildConfig({
|
||||||
baseDir: path.resolve(dirname),
|
baseDir: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: [Users, Media],
|
i18n: {
|
||||||
|
supportedLanguages: {
|
||||||
|
en: {
|
||||||
|
...en,
|
||||||
|
...enProducts,
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
...zh,
|
||||||
|
...zhProducts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fallbackLanguage: 'zh',
|
||||||
|
},
|
||||||
|
collections: [Users, Media, Products],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
secret: process.env.PAYLOAD_SECRET || '',
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|
@ -29,6 +49,7 @@ export default buildConfig({
|
||||||
pool: {
|
pool: {
|
||||||
connectionString: process.env.DATABASE_URL || '',
|
connectionString: process.env.DATABASE_URL || '',
|
||||||
},
|
},
|
||||||
|
migrationDir: path.resolve(dirname, 'migrations'),
|
||||||
}),
|
}),
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"products:gridView:loading": "Loading...",
|
||||||
|
"products:gridView:empty": "No Products",
|
||||||
|
"products:gridView:emptyDescription": "Click the \"Sync New Products\" button above to import products from Medusa",
|
||||||
|
"products:gridView:viewMode:grid": "Grid View",
|
||||||
|
"products:gridView:viewMode:table": "Table View",
|
||||||
|
"products:gridView:status:published": "Published",
|
||||||
|
"products:gridView:status:draft": "Draft",
|
||||||
|
"products:gridView:pagination:previous": "Previous",
|
||||||
|
"products:gridView:pagination:next": "Next",
|
||||||
|
"products:gridView:pagination:info": "Page {{page}} / {{totalPages}} ({{totalDocs}} total)"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"products:gridView:loading": "加载中...",
|
||||||
|
"products:gridView:empty": "暂无商品",
|
||||||
|
"products:gridView:emptyDescription": "点击上方的「同步新商品」按钮从 Medusa 导入商品",
|
||||||
|
"products:gridView:viewMode:grid": "网格视图",
|
||||||
|
"products:gridView:viewMode:table": "表格视图",
|
||||||
|
"products:gridView:status:published": "已发布",
|
||||||
|
"products:gridView:status:draft": "草稿",
|
||||||
|
"products:gridView:pagination:previous": "上一页",
|
||||||
|
"products:gridView:pagination:next": "下一页",
|
||||||
|
"products:gridView:pagination:info": "第 {{page}} / {{totalPages}} 页(共 {{totalDocs}} 个商品)"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue