数据储存
This commit is contained in:
parent
a7da4da87c
commit
93f8261622
|
|
@ -24,6 +24,7 @@
|
|||
"@payloadcms/plugin-cloud-storage": "^3.75.0",
|
||||
"@payloadcms/richtext-lexical": "3.75.0",
|
||||
"@payloadcms/storage-s3": "^3.75.0",
|
||||
"@payloadcms/translations": "3.75.0",
|
||||
"@payloadcms/ui": "3.75.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ importers:
|
|||
'@payloadcms/storage-s3':
|
||||
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)
|
||||
'@payloadcms/translations':
|
||||
specifier: 3.75.0
|
||||
version: 3.75.0
|
||||
'@payloadcms/ui':
|
||||
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)
|
||||
|
|
@ -8139,8 +8142,8 @@ snapshots:
|
|||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
||||
eslint: 9.39.2
|
||||
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-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-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@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-hooks: 5.2.0(eslint@9.39.2)
|
||||
|
|
@ -8159,7 +8162,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
|
|
@ -8170,22 +8173,22 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
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:
|
||||
- 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:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
|
||||
eslint: 9.39.2
|
||||
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:
|
||||
- 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:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -8196,7 +8199,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.2
|
||||
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
|
||||
is-core-module: 2.16.1
|
||||
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 { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"/components/products/ForceSyncButton#ForceSyncButton": ForceSyncButton_86f9d5df4f20495427521354d06db618,
|
||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||
"@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: {
|
||||
users: User;
|
||||
media: Media;
|
||||
products: Product;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
|
|
@ -78,6 +79,7 @@ export interface Config {
|
|||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
|
|
@ -158,6 +160,45 @@ export interface Media {
|
|||
focalX?: 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
|
||||
* via the `definition` "payload-kv".
|
||||
|
|
@ -189,6 +230,10 @@ export interface PayloadLockedDocument {
|
|||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'products';
|
||||
value: number | Product;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
|
|
@ -272,6 +317,21 @@ export interface MediaSelect<T extends boolean = true> {
|
|||
focalX?: 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
|
||||
* via the `definition` "payload-kv_select".
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import sharp from 'sharp'
|
|||
|
||||
import { Users } from './collections/Users'
|
||||
import { Media } from './collections/Media'
|
||||
import { Products } from './collections/Products'
|
||||
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 dirname = path.dirname(filename)
|
||||
|
|
@ -19,7 +26,20 @@ export default buildConfig({
|
|||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media],
|
||||
i18n: {
|
||||
supportedLanguages: {
|
||||
en: {
|
||||
...en,
|
||||
...enProducts,
|
||||
},
|
||||
zh: {
|
||||
...zh,
|
||||
...zhProducts,
|
||||
},
|
||||
},
|
||||
fallbackLanguage: 'zh',
|
||||
},
|
||||
collections: [Users, Media, Products],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
|
|
@ -29,6 +49,7 @@ export default buildConfig({
|
|||
pool: {
|
||||
connectionString: process.env.DATABASE_URL || '',
|
||||
},
|
||||
migrationDir: path.resolve(dirname, 'migrations'),
|
||||
}),
|
||||
sharp,
|
||||
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