From c84eef485b3e760bc9b59f744638931643448563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=9F=E7=94=B7=E6=97=A5=E8=AE=B0=5Cwww?= Date: Mon, 23 Feb 2026 06:32:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=98=E5=AE=9D=E6=8C=89=E9=92=AE=E5=A1=AB?= =?UTF-8?q?=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + src/app/(payload)/admin/importMap.js | 6 + src/app/api/admin/taobao/sync-all/route.ts | 93 +++++++++ .../api/admin/taobao/sync-product/route.ts | 55 ++++++ src/app/api/sync/product/route.ts | 9 +- src/app/api/taobao/parse/route.ts | 41 ++++ src/collections/PreorderProducts.ts | 14 +- src/collections/Products.ts | 17 +- src/collections/base/ProductBase.ts | 65 +------ src/collections/base/TaobaoLinksField.ts | 101 ++++++++++ src/components/fields/TaobaoFetchButton.tsx | 85 +++++++++ src/components/sync/TaobaoSyncAllButton.tsx | 146 +++++++++++++++ src/components/sync/TaobaoSyncButtons.tsx | 135 ++++++++++++++ src/lib/medusa.ts | 23 +++ src/lib/taobao.ts | 176 ++++++++++++++++++ src/payload-types.ts | 20 +- 16 files changed, 921 insertions(+), 69 deletions(-) create mode 100644 src/app/api/admin/taobao/sync-all/route.ts create mode 100644 src/app/api/admin/taobao/sync-product/route.ts create mode 100644 src/app/api/taobao/parse/route.ts create mode 100644 src/collections/base/TaobaoLinksField.ts create mode 100644 src/components/fields/TaobaoFetchButton.tsx create mode 100644 src/components/sync/TaobaoSyncAllButton.tsx create mode 100644 src/components/sync/TaobaoSyncButtons.tsx create mode 100644 src/lib/taobao.ts diff --git a/.env.example b/.env.example index 2eb8069..878a3fe 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ DATABASE_URL=postgresql://user:password@localhost:5432/database # Payload PAYLOAD_SECRET=YOUR_SECRET_HERE +# Onebound API(淘宝商品数据)https://open.onebound.cn +ONEBOUND_API_KEY=your-onebound-key +ONEBOUND_API_SECRET=your-onebound-secret + # Redis Configuration REDIS_URL=redis://localhost:6379 diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 495552f..1a05ba9 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -22,8 +22,11 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0 import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField' +import { TaobaoSyncButtons as TaobaoSyncButtons_1287e89ff664e3153e7b1d531ac3c868 } from '../../../components/sync/TaobaoSyncButtons' +import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton' import { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview' import { UnifiedSyncButton as UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523 } from '../../../components/sync/UnifiedSyncButton' +import { TaobaoSyncAllButton as TaobaoSyncAllButton_e831fa632dca24f7a1678e011885f4da } from '../../../components/sync/TaobaoSyncAllButton' import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler' import { PreorderProgressCell as PreorderProgressCell_67df47753573233f0c83480de687f13b } from '../../../components/cells/PreorderProgressCell' import { RefreshOrderCountField as RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996 } from '../../../components/fields/RefreshOrderCountField' @@ -65,8 +68,11 @@ export const importMap = { "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, + "/components/sync/TaobaoSyncButtons#TaobaoSyncButtons": TaobaoSyncButtons_1287e89ff664e3153e7b1d531ac3c868, + "/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7, "/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959, "/components/sync/UnifiedSyncButton#UnifiedSyncButton": UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523, + "/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton": TaobaoSyncAllButton_e831fa632dca24f7a1678e011885f4da, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, "/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b, "/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996, diff --git a/src/app/api/admin/taobao/sync-all/route.ts b/src/app/api/admin/taobao/sync-all/route.ts new file mode 100644 index 0000000..06f72f3 --- /dev/null +++ b/src/app/api/admin/taobao/sync-all/route.ts @@ -0,0 +1,93 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextResponse } from 'next/server' +import { syncProductTaobaoLinks } from '@/lib/taobao' +import { addCorsHeaders, handleCorsOptions } from '@/lib/cors' + +export async function OPTIONS(request: Request) { + return handleCorsOptions(request.headers.get('origin')) +} + +/** + * POST /api/admin/taobao/sync-all + * 遍历所有产品,解析淘宝链接并回填 title / thumbnail / price + * + * Body: { force?: boolean } + * 返回: { success, total, updated, skipped, errors[] } + */ +export async function POST(request: Request) { + const origin = request.headers.get('origin') + + try { + const body = await request.json().catch(() => ({})) + const force: boolean = body.force ?? false + + const payload = await getPayload({ config }) + const collections = ['products', 'preorder-products'] as const + + let total = 0 + let updated = 0 + let skipped = 0 + const errors: string[] = [] + + for (const collection of collections) { + let page = 1 + let hasMore = true + + while (hasMore) { + const result = await payload.find({ + collection, + limit: 20, + page, + pagination: true, + }) + + for (const product of result.docs) { + const links: any[] = (product as any).taobaoLinks || [] + if (links.length === 0) { + skipped++ + continue + } + + total++ + try { + const r = await syncProductTaobaoLinks( + payload, + product.id, + collection, + force, + ) + if (r.updated) updated++ + else skipped++ + } catch (err: any) { + errors.push(`${collection}/${product.id}: ${err?.message}`) + } + } + + hasMore = result.hasNextPage ?? false + page++ + } + } + + const message = `共处理 ${total} 个产品,更新 ${updated} 个,跳过 ${skipped} 个${errors.length ? `,${errors.length} 个错误` : ''}` + console.log(`[taobao/sync-all] ${message}`) + + return addCorsHeaders( + NextResponse.json({ + success: true, + message, + total, + updated, + skipped, + errors: errors.length ? errors : undefined, + }), + origin, + ) + } catch (err: any) { + console.error('[taobao/sync-all]', err) + return addCorsHeaders( + NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }), + origin, + ) + } +} diff --git a/src/app/api/admin/taobao/sync-product/route.ts b/src/app/api/admin/taobao/sync-product/route.ts new file mode 100644 index 0000000..679357c --- /dev/null +++ b/src/app/api/admin/taobao/sync-product/route.ts @@ -0,0 +1,55 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextResponse } from 'next/server' +import { syncProductTaobaoLinks } from '@/lib/taobao' +import { addCorsHeaders, handleCorsOptions } from '@/lib/cors' + +export async function OPTIONS(request: Request) { + return handleCorsOptions(request.headers.get('origin')) +} + +/** + * POST /api/admin/taobao/sync-product + * 为指定产品解析淘宝链接并回填 title / thumbnail / price + * + * Body: { + * productId: string + * collection: 'products' | 'preorder-products' + * force?: boolean // true = 覆盖已有字段;false (默认) = 只填充空字段 + * } + */ +export async function POST(request: Request) { + const origin = request.headers.get('origin') + + try { + const { productId, collection, force = false } = await request.json() + + if (!productId || !collection) { + return addCorsHeaders( + NextResponse.json({ success: false, error: 'productId 和 collection 必填' }, { status: 400 }), + origin, + ) + } + + if (!['products', 'preorder-products'].includes(collection)) { + return addCorsHeaders( + NextResponse.json({ success: false, error: '无效的 collection' }, { status: 400 }), + origin, + ) + } + + const payload = await getPayload({ config }) + const result = await syncProductTaobaoLinks(payload, productId, collection, force) + + return addCorsHeaders( + NextResponse.json({ success: true, ...result }), + origin, + ) + } catch (err: any) { + console.error('[taobao/sync-product]', err) + return addCorsHeaders( + NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }), + origin, + ) + } +} diff --git a/src/app/api/sync/product/route.ts b/src/app/api/sync/product/route.ts index 8cfee61..f838286 100644 --- a/src/app/api/sync/product/route.ts +++ b/src/app/api/sync/product/route.ts @@ -85,6 +85,8 @@ export async function POST(request: Request) { // 转换数据 const productData = transformMedusaProductToPayload(medusaProduct) const seedId = productData.seedId + // 注意:taobaoLinks 在此仅写入原始 URL(不调用 Onebound API) + // 标题/封面/价格 需要在 Payload 后台手动点击“更新淘宝信息”按鈕才会解析 // 查找现有产品(优先通过 seedId) let existingProduct: any = null @@ -161,8 +163,13 @@ export async function POST(request: Request) { mergedData.title = productData.title mergedData.handle = productData.handle mergedData.status = productData.status - // thumbnail 只在为空时同步(Payload 编辑优先) + // thumbnail 只在为空时同步(Payload 编辑优先;可能来自 Medusa/S3 或淘宝链接首图) if (!existingProduct.thumbnail) mergedData.thumbnail = productData.thumbnail + + // taobaoLinks 只在为空时同步(Payload 编辑优先) + if ((!existingProduct.taobaoLinks || existingProduct.taobaoLinks.length === 0) && (productData as any).taobaoLinks) { + mergedData.taobaoLinks = (productData as any).taobaoLinks + } // description 始终从 Medusa 同步(纯文本,只读字段) mergedData.description = medusaProduct.description || null diff --git a/src/app/api/taobao/parse/route.ts b/src/app/api/taobao/parse/route.ts new file mode 100644 index 0000000..ef57993 --- /dev/null +++ b/src/app/api/taobao/parse/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { parseTaobaoMeta } from '@/lib/taobao' +import { addCorsHeaders, handleCorsOptions } from '@/lib/cors' + +export async function OPTIONS(request: Request) { + return handleCorsOptions(request.headers.get('origin')) +} + +/** + * POST /api/taobao/parse + * Body: { url: string } + * Returns: { success: true, title, thumbnail, price } + * + * 用于前端"解析"按钮和同步流程调用 + */ +export async function POST(request: Request) { + const origin = request.headers.get('origin') + + try { + const { url } = await request.json() + + if (!url || typeof url !== 'string') { + return addCorsHeaders( + NextResponse.json({ success: false, error: 'url is required' }, { status: 400 }), + origin, + ) + } + + const meta = await parseTaobaoMeta(url) + + return addCorsHeaders( + NextResponse.json({ success: true, ...meta }), + origin, + ) + } catch (err: any) { + return addCorsHeaders( + NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }), + origin, + ) + } +} diff --git a/src/collections/PreorderProducts.ts b/src/collections/PreorderProducts.ts index 2b2616b..15f0c63 100644 --- a/src/collections/PreorderProducts.ts +++ b/src/collections/PreorderProducts.ts @@ -38,6 +38,7 @@ export const PreorderProducts: CollectionConfig = { beforeListTable: [ '/components/sync/UnifiedSyncButton#UnifiedSyncButton', '/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton', + '/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton', '/components/list/PreorderProductGridStyler#PreorderProductGridStyler', ], }, @@ -243,7 +244,18 @@ export const PreorderProducts: CollectionConfig = { MedusaAttributesTab, { label: '🛒 淘宝链接', - fields: [TaobaoLinksField], + fields: [ + { + type: 'ui', + name: 'taobaoSyncButtons', + admin: { + components: { + Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons', + }, + }, + }, + TaobaoLinksField, + ], }, ], }, diff --git a/src/collections/Products.ts b/src/collections/Products.ts index 0914494..ecd7e42 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -1,7 +1,8 @@ import type { CollectionConfig } from 'payload' import { logAfterChange, logAfterDelete } from '../hooks/logAction' import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation' -import { ProductBaseFields, RelatedProductsField, TaobaoLinksField, MedusaAttributesTab } from './base/ProductBase' +import { ProductBaseFields, RelatedProductsField, MedusaAttributesTab } from './base/ProductBase' +import { TaobaoLinksField } from './base/TaobaoLinksField' import { AlignFeature, BlocksFeature, @@ -37,6 +38,7 @@ export const Products: CollectionConfig = { components: { beforeListTable: [ '/components/sync/UnifiedSyncButton#UnifiedSyncButton', + '/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton', '/components/list/ProductGridStyler', ], }, @@ -119,7 +121,18 @@ export const Products: CollectionConfig = { MedusaAttributesTab, { label: '🛒 淘宝链接', - fields: [TaobaoLinksField], + fields: [ + { + type: 'ui', + name: 'taobaoSyncButtons', + admin: { + components: { + Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons', + }, + }, + }, + TaobaoLinksField, + ], }, ], }, diff --git a/src/collections/base/ProductBase.ts b/src/collections/base/ProductBase.ts index fcfdc23..98ffc29 100644 --- a/src/collections/base/ProductBase.ts +++ b/src/collections/base/ProductBase.ts @@ -284,66 +284,5 @@ export const RelatedProductsField: Field = { }, } -/** - * 淘宝链接字段配置 - * 仅后台管理员可见,不通过 API 暴露 - */ -export const TaobaoLinksField: Field = { - name: 'taobaoLinks', - type: 'array', - label: '淘宝采购链接列表', - admin: { - description: '💡 管理淘宝采购链接(仅后台显示,不通过 API 暴露)', - initCollapsed: false, - }, - access: { - read: ({ req: { user } }) => !!user, - update: ({ req: { user } }) => !!user, - }, - fields: [ - { - name: 'url', - type: 'text', - label: '🔗 淘宝链接', - required: true, - admin: { - placeholder: 'https://item.taobao.com/...', - }, - }, - { - name: 'title', - type: 'text', - label: '📝 标题', - admin: { - placeholder: '链接标题或商品名称', - }, - }, - { - name: 'thumbnail', - type: 'text', - label: '🖼️ 缩略图 URL', - admin: { - placeholder: 'https://...', - description: '淘宝商品图片地址', - }, - }, - { - name: 'note', - type: 'textarea', - label: '📄 备注', - admin: { - placeholder: '其他备注信息...', - rows: 3, - }, - }, - { - type: 'ui', - name: 'linkPreview', - admin: { - components: { - Field: '/components/fields/TaobaoLinkPreview#TaobaoLinkPreview', - }, - }, - }, - ], -} +// TaobaoLinksField 已移至独立文件,包含自动解析功能 +export { TaobaoLinksField } from './TaobaoLinksField' diff --git a/src/collections/base/TaobaoLinksField.ts b/src/collections/base/TaobaoLinksField.ts new file mode 100644 index 0000000..ea0ba15 --- /dev/null +++ b/src/collections/base/TaobaoLinksField.ts @@ -0,0 +1,101 @@ +import type { Field } from 'payload' + +/** + * 淘宝采购链接字段 + * + * 功能: + * - 储存淘宝采购链接列表(仅后台可见,不通过 API 暴露) + * - 每条链接支持自动解析:点击"🔍 自动解析"按钮调用 /api/taobao/parse + * 自动填入标题、封面图(thumbnail,字符串 URL)、人民币价格 + * - 第一条链接的 thumbnail 在同步时可作为产品封面的备用来源 + * + * 数据流向: + * Medusa seed → metadata.taobao_links (JSON string[]) + * Payload sync → taobaoLinks[].url 写入;调用 /api/taobao/parse 解析并填入其余字段 + * Merging rule → 只在字段为空时同步(Payload 编辑优先) + */ +export const TaobaoLinksField: Field = { + name: 'taobaoLinks', + type: 'array', + label: '淘宝采购链接列表', + admin: { + description: '💡 管理淘宝采购链接(仅后台显示,不通过 API 暴露)', + initCollapsed: false, + }, + access: { + read: ({ req: { user } }) => !!user, + update: ({ req: { user } }) => !!user, + }, + fields: [ + { + name: 'url', + type: 'text', + label: '🔗 淘宝链接', + required: true, + admin: { + placeholder: 'https://item.taobao.com/item.htm?id=...', + }, + }, + { + // 自动解析按钮 —— 读取 url,填入 title / thumbnail / price + type: 'ui', + name: 'fetchButton', + admin: { + components: { + Field: '/components/fields/TaobaoFetchButton#TaobaoFetchButton', + }, + }, + }, + { + name: 'title', + type: 'text', + label: '📝 标题', + admin: { + placeholder: '自动解析或手动填写', + description: '淘宝商品标题(解析后自动填入)', + }, + }, + { + name: 'thumbnail', + type: 'text', + label: '🖼️ 封面 URL', + admin: { + placeholder: 'https://...', + description: '淘宝商品图片地址(字符串 URL;解析后自动填入;首条链接的封面可作为产品封面备用来源)', + components: { + Cell: '/components/cells/ThumbnailCell#ThumbnailCell', + Field: '/components/fields/ThumbnailField#ThumbnailField', + }, + }, + }, + { + name: 'price', + type: 'number', + label: '💴 价格(CNY)', + admin: { + placeholder: '0.00', + description: '淘宝商品人民币价格(解析后自动填入)', + step: 0.01, + }, + }, + { + name: 'note', + type: 'textarea', + label: '📄 备注', + admin: { + placeholder: '其他备注信息…', + rows: 2, + }, + }, + { + // 已有的预览组件(展示缩略图 + 跳转按钮) + type: 'ui', + name: 'linkPreview', + admin: { + components: { + Field: '/components/fields/TaobaoLinkPreview#TaobaoLinkPreview', + }, + }, + }, + ], +} diff --git a/src/components/fields/TaobaoFetchButton.tsx b/src/components/fields/TaobaoFetchButton.tsx new file mode 100644 index 0000000..d5535b0 --- /dev/null +++ b/src/components/fields/TaobaoFetchButton.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useField, useFormFields } from '@payloadcms/ui' +import React, { useState } from 'react' + +/** + * 淘宝自动解析按钮 + * 放置在 taobaoLinks 数组项内,读取当前 url 字段, + * 调用 /api/taobao/parse 获取标题 / 封面 / 价格, + * 然后自动填入同一数组项的对应字段。 + * + * 使用方式(TaobaoLinksField.ts 的 UI 字段): + * { type: 'ui', name: 'fetchButton', admin: { components: { Field: '/components/fields/TaobaoFetchButton#TaobaoFetchButton' } } } + */ +export const TaobaoFetchButton: React.FC<{ path?: string }> = ({ path = '' }) => { + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState(null) + + // path 例如 "taobaoLinks.0.fetchButton",取前缀 "taobaoLinks.0." + const prefix = path.replace(/[^.]+$/, '') + + const { value: url } = useField({ path: `${prefix}url` }) + const { setValue: setTitle } = useField({ path: `${prefix}title` }) + const { setValue: setThumbnail } = useField({ path: `${prefix}thumbnail` }) + const { setValue: setPrice } = useField({ path: `${prefix}price` }) + + const handleFetch = async () => { + if (!url) { + setMessage('请先填写淘宝链接') + return + } + setLoading(true) + setMessage(null) + try { + const res = await fetch('/api/taobao/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }) + const data = await res.json() + if (!data.success) throw new Error(data.error || '解析失败') + + if (data.title) setTitle(data.title) + if (data.thumbnail) setThumbnail(data.thumbnail) + if (data.price != null) setPrice(data.price) + + const filled = [data.title && '标题', data.thumbnail && '封面', data.price != null && '价格'] + .filter(Boolean) + .join('、') + setMessage(filled ? `✅ 已填入:${filled}` : '⚠️ 未能解析到内容') + } catch (err: any) { + setMessage(`❌ ${err?.message ?? '请求失败'}`) + } finally { + setLoading(false) + } + } + + return ( +
+ + {message && ( + + {message} + + )} +
+ ) +} diff --git a/src/components/sync/TaobaoSyncAllButton.tsx b/src/components/sync/TaobaoSyncAllButton.tsx new file mode 100644 index 0000000..387b270 --- /dev/null +++ b/src/components/sync/TaobaoSyncAllButton.tsx @@ -0,0 +1,146 @@ +'use client' + +import React, { useState } from 'react' + +/** + * 列表页 — 淘宝全量同步按钮 + * + * 加入 beforeListTable 后显示两个按钮: + * 🔄 更新全部淘宝信息 → 仅填充空字段 (force=false) + * ⚡ 强制更新全部淘宝信息 → 覆盖所有字段 (force=true,二次确认) + * + * 依赖 API:POST /api/admin/taobao/sync-all + */ +export const TaobaoSyncAllButton: React.FC = () => { + const [loadingNormal, setLoadingNormal] = useState(false) + const [loadingForce, setLoadingForce] = useState(false) + const [confirmForce, setConfirmForce] = useState(false) + const [message, setMessage] = useState(null) + + const run = async (force: boolean) => { + const setLoading = force ? setLoadingForce : setLoadingNormal + setLoading(true) + setMessage(null) + setConfirmForce(false) + + try { + const res = await fetch('/api/admin/taobao/sync-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force }), + }) + const data = await res.json() + + if (!data.success) throw new Error(data.error || '请求失败') + setMessage(`✅ ${data.message}`) + } catch (err: any) { + setMessage(`❌ ${err?.message ?? '未知错误'}`) + } finally { + setLoading(false) + } + } + + const busy = loadingNormal || loadingForce + + return ( +
+ {/* 更新(非强制) */} + + + {/* 强制更新(二次确认) */} + {!confirmForce ? ( + + ) : ( + <> + + 确认覆盖所有字段? + + + + + )} + + {message && ( + + {message} + + )} +
+ ) +} diff --git a/src/components/sync/TaobaoSyncButtons.tsx b/src/components/sync/TaobaoSyncButtons.tsx new file mode 100644 index 0000000..d076978 --- /dev/null +++ b/src/components/sync/TaobaoSyncButtons.tsx @@ -0,0 +1,135 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useDocumentInfo } from '@payloadcms/ui' + +/** + * 产品编辑页 — 淘宝信息同步按钮 + * + * 放置在淘宝链接 Tab 顶部(UI 字段),显示两个操作按钮: + * 🔄 更新淘宝信息 → 仅填充空字段 (force=false) + * ⚡ 强制更新淘宝信息 → 覆盖所有字段 (force=true) + * + * 依赖 API:POST /api/admin/taobao/sync-product + */ +export const TaobaoSyncButtons: React.FC = () => { + const { id, collectionSlug } = useDocumentInfo() + + const [loadingNormal, setLoadingNormal] = useState(false) + const [loadingForce, setLoadingForce] = useState(false) + const [message, setMessage] = useState(null) + + if (!id) return null // 新建文档时不显示 + + const isValidCollection = + collectionSlug === 'products' || collectionSlug === 'preorder-products' + if (!isValidCollection) return null + + const run = async (force: boolean) => { + const setLoading = force ? setLoadingForce : setLoadingNormal + setLoading(true) + setMessage(null) + + try { + const res = await fetch('/api/admin/taobao/sync-product', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId: id, collection: collectionSlug, force }), + }) + const data = await res.json() + + if (!data.success) throw new Error(data.error || '请求失败') + setMessage(`✅ ${data.message || '完成'}`) + + // 刷新页面以显示更新后的字段值 + setTimeout(() => window.location.reload(), 1200) + } catch (err: any) { + setMessage(`❌ ${err?.message ?? '未知错误'}`) + } finally { + setLoading(false) + } + } + + const busy = loadingNormal || loadingForce + + return ( +
+
+ 淘宝自动解析 +
+ +
+ {/* 更新(非强制) */} + + + {/* 强制全量更新 */} + +
+ + {/* 说明文字 */} +
+ 🔄 更新:仅填充空白字段(标题、封面、价格)  + ⚡ 强制更新:覆盖已有字段 +
+ + {message && ( +
+ {message} +
+ )} +
+ ) +} diff --git a/src/lib/medusa.ts b/src/lib/medusa.ts index a7473a2..c17c863 100644 --- a/src/lib/medusa.ts +++ b/src/lib/medusa.ts @@ -277,6 +277,26 @@ export function transformMedusaProductToPayload(product: MedusaProduct) { thumbnailUrl = product.images[0].url } + // 解析 metadata.taobao_links(JSON string[] → { url }[]) + // 标题/封面/价格 在同步到 Payload 时由 route 自动调用 parseTaobaoMeta 填充 + let taobaoLinks: Array<{ url: string }> = [] + if (product.metadata?.taobao_links) { + try { + const parsed = typeof product.metadata.taobao_links === 'string' + ? JSON.parse(product.metadata.taobao_links) + : product.metadata.taobao_links + if (Array.isArray(parsed)) { + taobaoLinks = (parsed as any[]).map(item => + typeof item === 'string' ? { url: item } : { url: item.url } + ).filter(item => !!item.url) + } + } catch { + console.warn('[transformMedusaProductToPayload] 解析 taobao_links 失败') + } + } + + // 如果 Medusa/S3 没有封面,'thumbnail 由同步 route 自动解析淘宝首图后回填' + // 提取 tags(逗号分隔) const tags = product.tags?.map(tag => tag.value).join(', ') || null @@ -332,6 +352,9 @@ export function transformMedusaProductToPayload(product: MedusaProduct) { midCode: product.mid_code || product.metadata?.mid_code || product.metadata?.midCode || null, hsCode: product.hs_code || product.metadata?.hs_code || product.metadata?.hsCode || null, countryOfOrigin: product.origin_country || product.metadata?.country_of_origin || product.metadata?.countryOfOrigin || null, + + // 淘宝链接(仅存 url,其余字段由同步 route 调用 parseTaobaoMeta 填充) + taobaoLinks: taobaoLinks.length > 0 ? taobaoLinks : undefined, } } diff --git a/src/lib/taobao.ts b/src/lib/taobao.ts new file mode 100644 index 0000000..2bdd5c2 --- /dev/null +++ b/src/lib/taobao.ts @@ -0,0 +1,176 @@ +/** + * 淘宝商品数据解析 —— 基于 Onebound API + * https://open.onebound.cn/help/api/taobao.item_get.html + * + * 环境变量: + * ONEBOUND_API_KEY API Key + * ONEBOUND_API_SECRET API 密钥 + */ + +const ONEBOUND_BASE = 'https://api.onebound.cn/taobao/api_sell/' + +export interface TaobaoMeta { + title: string | null + thumbnail: string | null + price: number | null +} + +/** + * 从淘宝 URL 中提取商品 ID + * 支持格式: + * https://item.taobao.com/item.htm?id=123456 + * https://a.m.taobao.com/i123456.htm + * https://detail.tmall.com/item.htm?id=123456 + */ +export function extractTaobaoItemId(url: string): string | null { + try { + const parsed = new URL(url) + const idParam = parsed.searchParams.get('id') + if (idParam) return idParam + const mobileMatch = parsed.pathname.match(/\/i(\d+)\.htm/) + if (mobileMatch) return mobileMatch[1] + } catch { + const match = url.match(/[?&]id=(\d+)/i) || url.match(/\/i(\d+)\.htm/) + if (match) return match[1] + } + return null +} + +/** + * 通过 Onebound taobao.item_get 接口获取商品元信息 + * 返回 title、thumbnail(主图 URL 字符串)、price(CNY 浮点数) + */ +export async function parseTaobaoMeta(url: string): Promise { + const empty: TaobaoMeta = { title: null, thumbnail: null, price: null } + + const apiKey = process.env.ONEBOUND_API_KEY + const apiSecret = process.env.ONEBOUND_API_SECRET + + if (!apiKey || !apiSecret) { + console.warn('[parseTaobaoMeta] ONEBOUND_API_KEY / ONEBOUND_API_SECRET 未配置') + return empty + } + + const itemId = extractTaobaoItemId(url) + if (!itemId) { + console.warn(`[parseTaobaoMeta] 无法提取商品 ID:${url}`) + return empty + } + + const params = new URLSearchParams({ + key: apiKey, + secret: apiSecret, + api_name: 'taobao.item_get', + num_iid: itemId, + lang: 'zh-CN', + }) + + try { + const res = await fetch(`${ONEBOUND_BASE}?${params.toString()}`, { + signal: AbortSignal.timeout(15_000), + }) + + if (!res.ok) { + console.warn(`[parseTaobaoMeta] HTTP ${res.status} for item ${itemId}`) + return empty + } + + const data = await res.json() + + // Onebound 错误响应 + if (data.error || data.error_response) { + console.warn(`[parseTaobaoMeta] API 错误 (${itemId}):`, data.error || data.error_response) + return empty + } + + const item = data.item + if (!item) { + console.warn(`[parseTaobaoMeta] 响应中无 item 字段 (${itemId})`) + return empty + } + + // ── 标题 ────────────────────────────────────────────────────────────────── + const title: string | null = item.title?.trim() || null + + // ── 封面(字符串 URL) ───────────────────────────────────────────────────── + // 优先级:pic_url → item_imgs[0].url → main_imgs[0] + let thumbnail: string | null = null + if (item.pic_url) { + thumbnail = normalizeImageUrl(item.pic_url) + } else if (Array.isArray(item.item_imgs) && item.item_imgs.length > 0) { + thumbnail = normalizeImageUrl(item.item_imgs[0]?.url ?? item.item_imgs[0]) + } else if (Array.isArray(item.main_imgs) && item.main_imgs.length > 0) { + thumbnail = normalizeImageUrl(item.main_imgs[0]) + } + + // ── 价格(CNY) ──────────────────────────────────────────────────────────── + const rawPrice = item.price ?? item.original_price ?? null + const price = rawPrice != null ? parseFloat(String(rawPrice)) : null + + console.log( + `[parseTaobaoMeta] ✅ (${itemId}) title=${title} thumbnail=${thumbnail ? '✓' : '✗'} price=${price}`, + ) + return { title, thumbnail, price } + } catch (err: any) { + console.warn(`[parseTaobaoMeta] 请求失败 (${itemId}): ${err?.message}`) + return empty + } +} + +/** + * 对单个 Payload 产品的 taobaoLinks 进行解析并回写 + * @param payload Payload 实例 + * @param productId 产品 ID + * @param collection 集合名('products' | 'preorder-products') + * @param force true → 覆盖已有字段;false → 只填充空字段 + */ +export async function syncProductTaobaoLinks( + payload: any, + productId: string, + collection: 'products' | 'preorder-products', + force: boolean, +): Promise<{ updated: boolean; message: string; linksCount?: number }> { + const product = await payload.findByID({ collection, id: productId }) + if (!product) return { updated: false, message: `产品 ${productId} 未找到` } + + const taobaoLinks: any[] = product.taobaoLinks || [] + if (taobaoLinks.length === 0) return { updated: false, message: '没有淘宝链接' } + + // 对每条链接调用 Onebound API + const updatedLinks = await Promise.all( + taobaoLinks.map(async (link: any) => { + if (!link.url) return link + // 非强制模式:所有字段已有值时跳过 + if (!force && link.title && link.thumbnail && link.price != null) return link + + const meta = await parseTaobaoMeta(link.url) + return { + ...link, + title: force ? (meta.title ?? link.title) : (link.title || meta.title), + thumbnail: force ? (meta.thumbnail ?? link.thumbnail) : (link.thumbnail || meta.thumbnail), + price: force + ? (meta.price !== null ? meta.price : link.price) + : (link.price != null ? link.price : meta.price), + } + }), + ) + + const updateData: any = { taobaoLinks: updatedLinks } + + // 回填产品封面(空时或强制覆盖) + if (!product.thumbnail || force) { + const firstThumb = updatedLinks.find((l) => l.thumbnail)?.thumbnail + if (firstThumb) updateData.thumbnail = firstThumb + } + + await payload.update({ collection, id: productId, data: updateData }) + return { updated: true, message: `已更新 ${updatedLinks.length} 条链接`, linksCount: updatedLinks.length } +} + +/** 确保图片 URL 包含协议 */ +function normalizeImageUrl(url: string | null | undefined): string | null { + if (!url) return null + const s = String(url).trim() + if (s.startsWith('//')) return `https:${s}` + return s +} diff --git a/src/payload-types.ts b/src/payload-types.ts index 80e49d3..b1dd213 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -322,11 +322,18 @@ export interface Product { taobaoLinks?: | { url: string; + /** + * 淘宝商品标题(解析后自动填入) + */ title?: string | null; /** - * 淘宝商品图片地址 + * 淘宝商品图片地址(字符串 URL;解析后自动填入;首条链接的封面可作为产品封面备用来源) */ thumbnail?: string | null; + /** + * 淘宝商品人民币价格(解析后自动填入) + */ + price?: number | null; note?: string | null; id?: string | null; }[] @@ -485,11 +492,18 @@ export interface PreorderProduct { taobaoLinks?: | { url: string; + /** + * 淘宝商品标题(解析后自动填入) + */ title?: string | null; /** - * 淘宝商品图片地址 + * 淘宝商品图片地址(字符串 URL;解析后自动填入;首条链接的封面可作为产品封面备用来源) */ thumbnail?: string | null; + /** + * 淘宝商品人民币价格(解析后自动填入) + */ + price?: number | null; note?: string | null; id?: string | null; }[] @@ -1068,6 +1082,7 @@ export interface ProductsSelect { url?: T; title?: T; thumbnail?: T; + price?: T; note?: T; id?: T; }; @@ -1113,6 +1128,7 @@ export interface PreorderProductsSelect { url?: T; title?: T; thumbnail?: T; + price?: T; note?: T; id?: T; };