数据储存

This commit is contained in:
龟男日记\www 2026-02-09 02:43:10 +08:00
parent a7da4da87c
commit 93f8261622
15 changed files with 2415 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

196
src/lib/medusa.ts Normal file
View File

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

View File

@ -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";`)
}

9
src/migrations/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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}} 个商品)"
}