淘宝按钮填充

This commit is contained in:
龟男日记\www 2026-02-23 06:32:42 +08:00
parent b90005038f
commit c84eef485b
16 changed files with 921 additions and 69 deletions

View File

@ -4,6 +4,10 @@ DATABASE_URL=postgresql://user:password@localhost:5432/database
# Payload # Payload
PAYLOAD_SECRET=YOUR_SECRET_HERE 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 Configuration
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379

View File

@ -22,8 +22,11 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_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 { 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 { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview'
import { UnifiedSyncButton as UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523 } from '../../../components/sync/UnifiedSyncButton' 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 { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
import { PreorderProgressCell as PreorderProgressCell_67df47753573233f0c83480de687f13b } from '../../../components/cells/PreorderProgressCell' import { PreorderProgressCell as PreorderProgressCell_67df47753573233f0c83480de687f13b } from '../../../components/cells/PreorderProgressCell'
import { RefreshOrderCountField as RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996 } from '../../../components/fields/RefreshOrderCountField' 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#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, "/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/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959,
"/components/sync/UnifiedSyncButton#UnifiedSyncButton": UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523, "/components/sync/UnifiedSyncButton#UnifiedSyncButton": UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523,
"/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton": TaobaoSyncAllButton_e831fa632dca24f7a1678e011885f4da,
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
"/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b, "/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b,
"/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996, "/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996,

View File

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

View File

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

View File

@ -85,6 +85,8 @@ export async function POST(request: Request) {
// 转换数据 // 转换数据
const productData = transformMedusaProductToPayload(medusaProduct) const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId const seedId = productData.seedId
// 注意taobaoLinks 在此仅写入原始 URL不调用 Onebound API
// 标题/封面/价格 需要在 Payload 后台手动点击“更新淘宝信息”按鈕才会解析
// 查找现有产品(优先通过 seedId // 查找现有产品(优先通过 seedId
let existingProduct: any = null let existingProduct: any = null
@ -161,8 +163,13 @@ export async function POST(request: Request) {
mergedData.title = productData.title mergedData.title = productData.title
mergedData.handle = productData.handle mergedData.handle = productData.handle
mergedData.status = productData.status mergedData.status = productData.status
// thumbnail 只在为空时同步Payload 编辑优先 // thumbnail 只在为空时同步Payload 编辑优先;可能来自 Medusa/S3 或淘宝链接首图
if (!existingProduct.thumbnail) mergedData.thumbnail = productData.thumbnail 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 同步(纯文本,只读字段) // description 始终从 Medusa 同步(纯文本,只读字段)
mergedData.description = medusaProduct.description || null mergedData.description = medusaProduct.description || null

View File

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

View File

@ -38,6 +38,7 @@ export const PreorderProducts: CollectionConfig = {
beforeListTable: [ beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton', '/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton', '/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton',
'/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton',
'/components/list/PreorderProductGridStyler#PreorderProductGridStyler', '/components/list/PreorderProductGridStyler#PreorderProductGridStyler',
], ],
}, },
@ -243,7 +244,18 @@ export const PreorderProducts: CollectionConfig = {
MedusaAttributesTab, MedusaAttributesTab,
{ {
label: '🛒 淘宝链接', label: '🛒 淘宝链接',
fields: [TaobaoLinksField], fields: [
{
type: 'ui',
name: 'taobaoSyncButtons',
admin: {
components: {
Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons',
},
},
},
TaobaoLinksField,
],
}, },
], ],
}, },

View File

@ -1,7 +1,8 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../hooks/logAction' import { logAfterChange, logAfterDelete } from '../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation' 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 { import {
AlignFeature, AlignFeature,
BlocksFeature, BlocksFeature,
@ -37,6 +38,7 @@ export const Products: CollectionConfig = {
components: { components: {
beforeListTable: [ beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton', '/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton',
'/components/list/ProductGridStyler', '/components/list/ProductGridStyler',
], ],
}, },
@ -119,7 +121,18 @@ export const Products: CollectionConfig = {
MedusaAttributesTab, MedusaAttributesTab,
{ {
label: '🛒 淘宝链接', label: '🛒 淘宝链接',
fields: [TaobaoLinksField], fields: [
{
type: 'ui',
name: 'taobaoSyncButtons',
admin: {
components: {
Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons',
},
},
},
TaobaoLinksField,
],
}, },
], ],
}, },

View File

@ -284,66 +284,5 @@ export const RelatedProductsField: Field = {
}, },
} }
/** // TaobaoLinksField 已移至独立文件,包含自动解析功能
* export { TaobaoLinksField } from './TaobaoLinksField'
* 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',
},
},
},
],
}

View File

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

View File

@ -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<string | null>(null)
// path 例如 "taobaoLinks.0.fetchButton",取前缀 "taobaoLinks.0."
const prefix = path.replace(/[^.]+$/, '')
const { value: url } = useField<string>({ path: `${prefix}url` })
const { setValue: setTitle } = useField<string>({ path: `${prefix}title` })
const { setValue: setThumbnail } = useField<string>({ path: `${prefix}thumbnail` })
const { setValue: setPrice } = useField<number>({ 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 (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button
type="button"
onClick={handleFetch}
disabled={loading || !url}
style={{
padding: '0.4rem 0.9rem',
background: loading ? '#9ca3af' : '#f97316',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: loading || !url ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{loading ? '解析中…' : '🔍 自动解析'}
</button>
{message && (
<span style={{ fontSize: '0.78rem', color: message.startsWith('✅') ? '#16a34a' : '#dc2626' }}>
{message}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,146 @@
'use client'
import React, { useState } from 'react'
/**
*
*
* beforeListTable
* 🔄 (force=false)
* (force=true)
*
* APIPOST /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<string | null>(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 (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap',
}}
>
{/* 更新(非强制) */}
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={{
padding: '0.4rem 0.85rem',
background: busy ? '#9ca3af' : '#10b981',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.78rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{loadingNormal ? '更新中…' : '🔄 更新全部淘宝'}
</button>
{/* 强制更新(二次确认) */}
{!confirmForce ? (
<button
type="button"
disabled={busy}
onClick={() => setConfirmForce(true)}
style={{
padding: '0.4rem 0.85rem',
background: busy ? '#9ca3af' : '#ef4444',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.78rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
</button>
) : (
<>
<span style={{ fontSize: '0.78rem', color: '#dc2626', fontWeight: 600 }}>
</span>
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={{
padding: '0.35rem 0.75rem',
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.78rem',
fontWeight: 700,
}}
>
{loadingForce ? '更新中…' : '确认'}
</button>
<button
type="button"
onClick={() => setConfirmForce(false)}
style={{
padding: '0.35rem 0.75rem',
background: 'transparent',
color: 'var(--theme-elevation-600)',
border: '1px solid var(--theme-elevation-200)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.78rem',
}}
>
</button>
</>
)}
{message && (
<span
style={{
fontSize: '0.78rem',
color: message.startsWith('✅') ? '#16a34a' : '#dc2626',
}}
>
{message}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,135 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
/**
*
*
* Tab UI
* 🔄 (force=false)
* (force=true)
*
* APIPOST /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<string | null>(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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
background: 'var(--theme-elevation-50)',
borderRadius: '6px',
border: '1px solid var(--theme-elevation-150)',
}}
>
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--theme-elevation-600)' }}>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{/* 更新(非强制) */}
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={{
padding: '0.4rem 0.9rem',
background: busy ? '#9ca3af' : '#3b82f6',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{loadingNormal ? '解析中…' : '🔄 更新淘宝信息'}
</button>
{/* 强制全量更新 */}
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={{
padding: '0.4rem 0.9rem',
background: busy ? '#9ca3af' : '#f97316',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{loadingForce ? '解析中…' : '⚡ 强制更新淘宝信息'}
</button>
</div>
{/* 说明文字 */}
<div style={{ fontSize: '0.73rem', color: 'var(--theme-elevation-450)', lineHeight: 1.5 }}>
<strong>🔄 </strong>&emsp;
<strong> </strong>
</div>
{message && (
<div
style={{
padding: '0.4rem 0.75rem',
borderRadius: '4px',
background: message.startsWith('✅') ? 'var(--theme-success-50)' : 'var(--theme-error-50)',
color: message.startsWith('✅') ? 'var(--theme-success-750)' : 'var(--theme-error-750)',
fontSize: '0.8rem',
}}
>
{message}
</div>
)}
</div>
)
}

View File

@ -277,6 +277,26 @@ export function transformMedusaProductToPayload(product: MedusaProduct) {
thumbnailUrl = product.images[0].url thumbnailUrl = product.images[0].url
} }
// 解析 metadata.taobao_linksJSON 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逗号分隔 // 提取 tags逗号分隔
const tags = product.tags?.map(tag => tag.value).join(', ') || null 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, midCode: product.mid_code || product.metadata?.mid_code || product.metadata?.midCode || null,
hsCode: product.hs_code || product.metadata?.hs_code || product.metadata?.hsCode || 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, countryOfOrigin: product.origin_country || product.metadata?.country_of_origin || product.metadata?.countryOfOrigin || null,
// 淘宝链接(仅存 url其余字段由同步 route 调用 parseTaobaoMeta 填充)
taobaoLinks: taobaoLinks.length > 0 ? taobaoLinks : undefined,
} }
} }

176
src/lib/taobao.ts Normal file
View File

@ -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
* titlethumbnail URL priceCNY
*/
export async function parseTaobaoMeta(url: string): Promise<TaobaoMeta> {
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
}

View File

@ -322,11 +322,18 @@ export interface Product {
taobaoLinks?: taobaoLinks?:
| { | {
url: string; url: string;
/**
*
*/
title?: string | null; title?: string | null;
/** /**
* * URL
*/ */
thumbnail?: string | null; thumbnail?: string | null;
/**
*
*/
price?: number | null;
note?: string | null; note?: string | null;
id?: string | null; id?: string | null;
}[] }[]
@ -485,11 +492,18 @@ export interface PreorderProduct {
taobaoLinks?: taobaoLinks?:
| { | {
url: string; url: string;
/**
*
*/
title?: string | null; title?: string | null;
/** /**
* * URL
*/ */
thumbnail?: string | null; thumbnail?: string | null;
/**
*
*/
price?: number | null;
note?: string | null; note?: string | null;
id?: string | null; id?: string | null;
}[] }[]
@ -1068,6 +1082,7 @@ export interface ProductsSelect<T extends boolean = true> {
url?: T; url?: T;
title?: T; title?: T;
thumbnail?: T; thumbnail?: T;
price?: T;
note?: T; note?: T;
id?: T; id?: T;
}; };
@ -1113,6 +1128,7 @@ export interface PreorderProductsSelect<T extends boolean = true> {
url?: T; url?: T;
title?: T; title?: T;
thumbnail?: T; thumbnail?: T;
price?: T;
note?: T; note?: T;
id?: T; id?: T;
}; };