diff --git a/package.json b/package.json index ff35980..d9843eb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8ef892..7442b25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index ea1a336..ad824b6 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/src/app/api/sync-medusa/route.ts b/src/app/api/sync-medusa/route.ts new file mode 100644 index 0000000..3d15f51 --- /dev/null +++ b/src/app/api/sync-medusa/route.ts @@ -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 }, + ) + } +} diff --git a/src/collections/Products.ts b/src/collections/Products.ts new file mode 100644 index 0000000..8823f05 --- /dev/null +++ b/src/collections/Products.ts @@ -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, +} diff --git a/src/components/products/ForceSyncButton.tsx b/src/components/products/ForceSyncButton.tsx new file mode 100644 index 0000000..4514668 --- /dev/null +++ b/src/components/products/ForceSyncButton.tsx @@ -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 ( +
+ + {message && ( +
+ {message} +
+ )} +
+ ) +} diff --git a/src/components/products/SyncMedusaButton.tsx b/src/components/products/SyncMedusaButton.tsx new file mode 100644 index 0000000..51f41ca --- /dev/null +++ b/src/components/products/SyncMedusaButton.tsx @@ -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 ( +
+

Medusa 商品同步

+ + {showConfirmInput ? ( +
+
+

+ ⚠️ 危险操作 +

+

+ 这将强制更新所有已存在的商品,覆盖所有本地修改。 +

+

+ 请输入{' '} + + FORCE_UPDATE_ALL + {' '} + 确认: +

+
+ 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} + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} + + {message && ( +
+ {message} +
+ )} + + {!showConfirmInput && ( +
+

+ • 同步新商品: 从 Medusa 导入尚未同步的商品,不会更新已存在的商品。 +

+

+ • 强制更新全部: 更新所有商品,覆盖本地修改(需要确认)。 +

+
+ )} +
+ ) +} diff --git a/src/lib/medusa.ts b/src/lib/medusa.ts new file mode 100644 index 0000000..3867399 --- /dev/null +++ b/src/lib/medusa.ts @@ -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 + 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 { + products?: T[] + product?: T + count?: number + offset?: number + limit?: number +} + +/** + * 获取所有 Medusa 商品 + */ +export async function getAllMedusaProducts(): Promise { + 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 = 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 { + 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 = 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 = 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 { + 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(), + } +} diff --git a/src/migrations/20260208_171142.json b/src/migrations/20260208_171142.json new file mode 100644 index 0000000..e9e6a93 --- /dev/null +++ b/src/migrations/20260208_171142.json @@ -0,0 +1,1254 @@ +{ + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users_sessions": { + "name": "users_sessions", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_sessions_order_idx": { + "name": "users_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_sessions_parent_id_idx": { + "name": "users_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_sessions_parent_id_fk": { + "name": "users_sessions_parent_id_fk", + "tableFrom": "users_sessions", + "tableTo": "users", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_updated_at_idx": { + "name": "users_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "medusa_id": { + "name": "medusa_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_products_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_medusa_id_idx": { + "name": "products_medusa_id_idx", + "columns": [ + { + "expression": "medusa_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_updated_at_idx": { + "name": "products_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_created_at_idx": { + "name": "products_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products_rels": { + "name": "products_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "products_id": { + "name": "products_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "products_rels_order_idx": { + "name": "products_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_rels_parent_idx": { + "name": "products_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_rels_path_idx": { + "name": "products_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_rels_products_id_idx": { + "name": "products_rels_products_id_idx", + "columns": [ + { + "expression": "products_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_rels_parent_fk": { + "name": "products_rels_parent_fk", + "tableFrom": "products_rels", + "tableTo": "products", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "products_rels_products_fk": { + "name": "products_rels_products_fk", + "tableFrom": "products_rels", + "tableTo": "products", + "columnsFrom": [ + "products_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_kv": { + "name": "payload_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "products_id": { + "name": "products_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_products_id_idx": { + "name": "payload_locked_documents_rels_products_id_idx", + "columns": [ + { + "expression": "products_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_products_fk": { + "name": "payload_locked_documents_rels_products_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "products", + "columnsFrom": [ + "products_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences": { + "name": "payload_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_migrations": { + "name": "payload_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.enum_products_status": { + "name": "enum_products_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "id": "14812669-da54-4cd0-abf6-680b50e27c2f", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/src/migrations/20260208_171142.ts b/src/migrations/20260208_171142.ts new file mode 100644 index 0000000..45f5654 --- /dev/null +++ b/src/migrations/20260208_171142.ts @@ -0,0 +1,169 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + 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 { + 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";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts new file mode 100644 index 0000000..935e859 --- /dev/null +++ b/src/migrations/index.ts @@ -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', + }, +] diff --git a/src/payload-types.ts b/src/payload-types.ts index cee4a2a..b0daac3 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | UsersSelect; media: MediaSelect | MediaSelect; + products: ProductsSelect | ProductsSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -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 { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "products_select". + */ +export interface ProductsSelect { + 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". diff --git a/src/payload.config.ts b/src/payload.config.ts index f0e8aad..a7e4c3b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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: [ diff --git a/src/translations/en/products.json b/src/translations/en/products.json new file mode 100644 index 0000000..7482d0f --- /dev/null +++ b/src/translations/en/products.json @@ -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)" +} diff --git a/src/translations/zh/products.json b/src/translations/zh/products.json new file mode 100644 index 0000000..2fe7c32 --- /dev/null +++ b/src/translations/zh/products.json @@ -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}} 个商品)" +}