按钮精简

This commit is contained in:
龟男日记\www 2026-02-23 21:23:40 +08:00
parent c84eef485b
commit 6e75c34faf
17 changed files with 1431 additions and 1059 deletions

View File

@ -22,16 +22,14 @@ 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 { TaobaoProductSync as TaobaoProductSync_c920a85a41a3caf5464668c331ea204a } from '../../../components/sync/taobao/TaobaoProductSync'
import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton' 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'
import { PreorderOrdersField as PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f } from '../../../components/fields/PreorderOrdersField' import { PreorderOrdersField as PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f } from '../../../components/fields/PreorderOrdersField'
import { PreorderHealthCheckButton as PreorderHealthCheckButton_5c0756e0fa67593931ce171329b92892 } from '../../../components/views/PreorderHealthCheckButton'
import { PreorderProductGridStyler as PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5 } from '../../../components/list/PreorderProductGridStyler' import { PreorderProductGridStyler as PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5 } from '../../../components/list/PreorderProductGridStyler'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
@ -68,16 +66,14 @@ 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/sync/taobao/TaobaoProductSync#TaobaoProductSync": TaobaoProductSync_c920a85a41a3caf5464668c331ea204a,
"/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7, "/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,
"/components/fields/PreorderOrdersField#PreorderOrdersField": PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f, "/components/fields/PreorderOrdersField#PreorderOrdersField": PreorderOrdersField_a4aa1b8cbd6dec364a834b059228f43f,
"/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton": PreorderHealthCheckButton_5c0756e0fa67593931ce171329b92892,
"/components/list/PreorderProductGridStyler#PreorderProductGridStyler": PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5, "/components/list/PreorderProductGridStyler#PreorderProductGridStyler": PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,

View File

@ -37,8 +37,6 @@ export const PreorderProducts: CollectionConfig = {
components: { components: {
beforeListTable: [ beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton', '/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/views/PreorderHealthCheckButton#PreorderHealthCheckButton',
'/components/sync/TaobaoSyncAllButton#TaobaoSyncAllButton',
'/components/list/PreorderProductGridStyler#PreorderProductGridStyler', '/components/list/PreorderProductGridStyler#PreorderProductGridStyler',
], ],
}, },
@ -250,7 +248,7 @@ export const PreorderProducts: CollectionConfig = {
name: 'taobaoSyncButtons', name: 'taobaoSyncButtons',
admin: { admin: {
components: { components: {
Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons', Field: '/components/sync/taobao/TaobaoProductSync#TaobaoProductSync',
}, },
}, },
}, },

View File

@ -38,7 +38,6 @@ 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',
], ],
}, },
@ -127,7 +126,7 @@ export const Products: CollectionConfig = {
name: 'taobaoSyncButtons', name: 'taobaoSyncButtons',
admin: { admin: {
components: { components: {
Field: '/components/sync/TaobaoSyncButtons#TaobaoSyncButtons', Field: '/components/sync/taobao/TaobaoProductSync#TaobaoProductSync',
}, },
}, },
}, },

View File

@ -1,167 +0,0 @@
'use client'
import { useState } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
*
* Medusa orderCount
*/
export function RefreshOrderCountButton() {
const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const router = useRouter()
// 刷新所有商品的订单计数
const handleRefreshAll = async () => {
if (!confirm('确定要刷新所有预购商品的订单计数吗?')) {
return
}
setLoading(true)
setMessage('')
try {
const response = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshAll: true,
}),
})
const data = await response.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
setTimeout(() => {
router.refresh()
}, 1500)
} else {
setMessage(`❌ 刷新失败: ${data.error || '未知错误'}`)
}
} catch (error) {
setMessage('❌ 刷新出错: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setLoading(false)
}
}
// 刷新选中商品的订单计数
const handleRefreshSelected = async () => {
try {
const queryParams = getQueryParams()
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
const whereCondition = (queryParams as any).where
if (whereCondition?.id?.in) {
selectedIds = whereCondition.id.in
}
}
if (!selectedIds || selectedIds.length === 0) {
setMessage('⚠️ 请先勾选要刷新的商品(使用列表左侧的复选框)')
return
}
setLoading(true)
setMessage('')
const response = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
productIds: selectedIds,
}),
})
const data = await response.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
if (toggleAll) {
toggleAll()
}
setTimeout(() => {
router.refresh()
}, 1500)
} else {
setMessage(`❌ 刷新失败: ${data.error || '未知错误'}`)
}
} catch (error) {
setMessage('❌ 刷新出错: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div style={{ padding: '1rem', borderTop: '1px solid var(--theme-elevation-100)' }}>
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.25rem' }}>📊</span>
</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
<Button
onClick={handleRefreshSelected}
disabled={loading}
buttonStyle="secondary"
size="small"
>
{loading ? '刷新中...' : '刷新选中商品'}
</Button>
<Button
onClick={handleRefreshAll}
disabled={loading}
buttonStyle="secondary"
size="small"
>
{loading ? '刷新中...' : '刷新全部订单计数'}
</Button>
</div>
{message && (
<div
style={{
padding: '0.75rem',
marginTop: '0.5rem',
borderRadius: '4px',
backgroundColor: message.startsWith('✅')
? 'var(--theme-success-50)'
: message.startsWith('⚠️')
? 'var(--theme-warning-50)'
: 'var(--theme-error-50)',
color: message.startsWith('✅')
? 'var(--theme-success-900)'
: message.startsWith('⚠️')
? 'var(--theme-warning-900)'
: 'var(--theme-error-900)',
fontSize: '0.875rem',
}}
>
{message}
</div>
)}
<div style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--theme-elevation-500)' }}>
<p style={{ margin: '0.25rem 0' }}>
💡 <strong></strong> = + Fake计数
</p>
<p style={{ margin: '0.25rem 0' }}>
<strong></strong> Medusa
</p>
<p style={{ margin: '0.25rem 0' }}>
<strong>Fake计数</strong>
</p>
</div>
</div>
)
}

View File

@ -1,146 +0,0 @@
'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

@ -1,135 +0,0 @@
'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

@ -1,321 +1,480 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import React, { useEffect, useState } from 'react'
import { Button, useSelection } from '@payloadcms/ui' import { Button, Modal, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
/** // ── Types ─────────────────────────────────────────────────────────────────────
*
* interface HealthCheckResult {
*/ success: boolean
export function UnifiedSyncButton() { timestamp: string
summary: { total: number; healthy: number; warnings: number; errors: number }
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: { preorderStartDate: string | null; preorderEndDate: string | null }
}>
issues: string[]
}
// ── Shared helpers ────────────────────────────────────────────────────────────
function Msg({ text }: { text: string }) {
if (!text) return null
const isErr = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<p
style={{
margin: '0.25rem 0 0',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
fontSize: '0.78rem',
lineHeight: 1.4,
background: isErr
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isErr
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</p>
)
}
function Divider() {
return <div style={{ borderTop: '1px solid var(--theme-elevation-100)' }} />
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p style={{
margin: '0 0 0.25rem',
fontSize: '0.72rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--theme-elevation-500)',
}}>
{children}
</p>
)
}
// ── Section: Medusa sync ──────────────────────────────────────────────────────
function MedusaSyncSection({ collection }: { collection: string }) {
const { getQueryParams, toggleAll } = useSelection() const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [showForceAllConfirm, setShowForceAllConfirm] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [collectionSlug, setCollectionSlug] = useState('products')
const router = useRouter() const router = useRouter()
// 在客户端确定 collection slug避免 hydration 错误 const [loadingNew, setLoadingNew] = useState(false)
useEffect(() => { const [loadingBatch, setLoadingBatch] = useState(false)
if (typeof window !== 'undefined') { const [loadingForceBatch, setLoadingForceBatch] = useState(false)
const pathname = window.location.pathname const [showForceAll, setShowForceAll] = useState(false)
const slug = pathname.includes('preorder-products') const [loadingForceAll, setLoadingForceAll] = useState(false)
? 'preorder-products' const [confirmText, setConfirmText] = useState('')
: 'products' const [msg, setMsg] = useState('')
setCollectionSlug(slug)
}
}, [])
// 同步新商品 const busy = loadingNew || loadingBatch || loadingForceBatch || loadingForceAll
const handleSyncNew = async () => {
setLoading(true)
setMessage('')
const syncNew = async () => {
setLoadingNew(true); setMsg('')
try { try {
const response = await fetch('/api/sync/medusa?forceUpdate=false', { const res = await fetch('/api/sync/medusa?forceUpdate=false')
method: 'GET', const data = await res.json()
}) setMsg(data.success
? `${data.message || '同步成功'}`
const data = await response.json() : `${data.error || data.message || '同步失败'}`)
if (data.success) setTimeout(() => window.location.reload(), 1500)
if (data.success) { } catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
setMessage('✅ ' + (data.message || '同步成功!')) finally { setLoadingNew(false) }
setTimeout(() => window.location.reload(), 1500)
} else {
setMessage('❌ 同步失败: ' + (data.error || data.message || '未知错误'))
}
} catch (error) {
setMessage('❌ 同步出错: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setLoading(false)
}
} }
// 批量同步选中 const batchSync = async (force: boolean) => {
const handleBatchSync = async (forceUpdate: boolean = false) => {
try {
const queryParams = getQueryParams() const queryParams = getQueryParams()
let selectedIds: string[] = [] let ids: string[] = []
if (queryParams && typeof queryParams === 'object') { if (queryParams && typeof queryParams === 'object') {
const whereCondition = (queryParams as any).where const where = (queryParams as any).where
if (whereCondition?.id?.in) { if (where?.id?.in) ids = where.id.in
selectedIds = whereCondition.id.in
} }
} if (!ids.length) {
setMsg('⚠️ 请先勾选要同步的商品(列表左侧复选框)')
if (!selectedIds || selectedIds.length === 0) {
setMessage('⚠️ 请先勾选要同步的商品(使用列表左侧的复选框)')
return return
} }
if (force && !confirm(`确定强制更新选中的 ${ids.length} 个商品?这将覆盖本地修改。`)) return
if ( const setL = force ? setLoadingForceBatch : setLoadingBatch
forceUpdate && setL(true); setMsg('')
!confirm(`确定要强制更新选中的 ${selectedIds.length} 个商品吗?这将覆盖本地修改。`)
) {
return
}
setLoading(true)
setMessage('')
const response = await fetch('/api/admin/batch-sync-medusa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: selectedIds,
collection: collectionSlug,
forceUpdate,
}),
})
const data = await response.json()
if (data.success) {
setMessage('✅ ' + (data.message || '批量同步成功!'))
if (toggleAll) {
toggleAll()
}
setTimeout(() => {
router.refresh()
}, 1500)
} else {
setMessage('❌ 批量同步失败: ' + (data.error || '未知错误'))
}
} catch (error) {
setMessage('❌ 批量同步出错: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setLoading(false)
}
}
// 强制更新全部
const handleForceUpdateAll = async () => {
if (confirmText !== 'FORCE_UPDATE_ALL') {
setMessage('❌ 确认字符不正确,请输入: FORCE_UPDATE_ALL')
return
}
setLoading(true)
setMessage('')
setShowForceAllConfirm(false)
try { try {
const response = await fetch('/api/sync/medusa?forceUpdate=true', { const res = await fetch('/api/admin/batch-sync-medusa', {
method: 'GET', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, collection, forceUpdate: force }),
}) })
const data = await res.json()
const data = await response.json() setMsg(data.success
? `${data.message || '批量同步成功'}`
if (data.success) { : `${data.error || '失败'}`)
setMessage('✅ ' + (data.message || '强制更新成功!')) if (data.success) { toggleAll?.(); setTimeout(() => router.refresh(), 1500) }
setTimeout(() => window.location.reload(), 1500) } catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
} else { finally { setL(false) }
setMessage('❌ 同步失败: ' + (data.error || data.message || '未知错误'))
} }
} catch (error) {
setMessage('❌ 同步出错: ' + (error instanceof Error ? error.message : '未知错误')) const forceAll = async () => {
} finally { if (confirmText !== 'FORCE_UPDATE_ALL') {
setLoading(false) setMsg('❌ 请输入: FORCE_UPDATE_ALL')
setConfirmText('') return
} }
setLoadingForceAll(true); setMsg(''); setShowForceAll(false)
try {
const res = await fetch('/api/sync/medusa?forceUpdate=true')
const data = await res.json()
setMsg(data.success
? `${data.message || '强制更新成功'}`
: `${data.error || data.message || '失败'}`)
if (data.success) setTimeout(() => window.location.reload(), 1500)
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setLoadingForceAll(false); setConfirmText('') }
} }
return ( return (
<div style={{ padding: '1rem', borderTop: '1px solid var(--theme-elevation-100)' }}> <div>
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <SectionLabel>🔄 Medusa </SectionLabel>
<span style={{ fontSize: '1.25rem' }}>🔄</span> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
Medusa <Button onClick={syncNew} disabled={busy} buttonStyle="primary" size="small">
</h3> {loadingNew ? '同步中…' : '📥 同步新商品'}
</Button>
{showForceAllConfirm ? ( <Button onClick={() => batchSync(false)} disabled={busy} buttonStyle="secondary" size="small">
<div style={{ marginBottom: '1rem' }}> {loadingBatch ? '同步中…' : '🔄 同步选中'}
<div </Button>
style={{ <Button onClick={() => batchSync(true)} disabled={busy} buttonStyle="secondary" size="small">
marginBottom: '0.75rem', {loadingForceBatch ? '更新中…' : '⚡ 强制更新选中'}
padding: '0.75rem', </Button>
backgroundColor: 'var(--theme-warning-50)', {!showForceAll ? (
borderRadius: '4px', <Button
}} onClick={() => { setShowForceAll(true); setMsg(''); setConfirmText('') }}
disabled={busy}
buttonStyle="secondary"
size="small"
> >
<p 🔥
style={{ </Button>
margin: '0 0 0.5rem 0', ) : (
fontWeight: 'bold', <span style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
color: 'var(--theme-warning-900)',
}}
>
</p>
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem' }}>
</p>
<p style={{ margin: 0, fontSize: '0.875rem' }}>
{' '}
<code
style={{
padding: '0.125rem 0.25rem',
backgroundColor: 'var(--theme-elevation-100)',
borderRadius: '2px',
}}
>
FORCE_UPDATE_ALL
</code>{' '}
</p>
</div>
<input <input
type="text" type="text"
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 FORCE_UPDATE_ALL" placeholder="输入 FORCE_UPDATE_ALL 确认"
disabled={loadingForceAll}
style={{ style={{
width: '100%', padding: '0.3rem 0.5rem',
padding: '0.5rem',
marginBottom: '0.75rem',
border: '1px solid var(--theme-elevation-400)', border: '1px solid var(--theme-elevation-400)',
borderRadius: '4px', borderRadius: '4px',
fontSize: '0.875rem', fontSize: '0.78rem',
width: '200px',
}} }}
disabled={loading}
/> />
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button <Button
onClick={handleForceUpdateAll} onClick={forceAll}
disabled={loading || confirmText !== 'FORCE_UPDATE_ALL'} disabled={loadingForceAll || confirmText !== 'FORCE_UPDATE_ALL'}
size="small"
> >
{loading ? '更新中...' : '✅ 确认强制更新'} {loadingForceAll ? '更新中…' : '确认'}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => { setShowForceAll(false); setConfirmText('') }}
setShowForceAllConfirm(false) disabled={loadingForceAll}
setConfirmText('')
setMessage('')
}}
disabled={loading}
buttonStyle="secondary" buttonStyle="secondary"
size="small"
> >
</Button> </Button>
</span>
)}
</div> </div>
<Msg text={msg} />
</div> </div>
)
}
// ── Section: Taobao sync ──────────────────────────────────────────────────────
function TaobaoSyncSection() {
const [loadingNormal, setLoadingNormal] = useState(false)
const [loadingForce, setLoadingForce] = useState(false)
const [confirmForce, setConfirmForce] = useState(false)
const [msg, setMsg] = useState('')
const busy = loadingNormal || loadingForce
const run = async (force: boolean) => {
const setL = force ? setLoadingForce : setLoadingNormal
setL(true); setMsg(''); 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 || '请求失败')
setMsg(`${data.message}`)
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setL(false) }
}
return (
<div>
<SectionLabel>🛍 </SectionLabel>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Button onClick={() => run(false)} disabled={busy} buttonStyle="secondary" size="small">
{loadingNormal ? '更新中…' : '🔄 更新全部淘宝'}
</Button>
{!confirmForce ? (
<Button onClick={() => setConfirmForce(true)} disabled={busy} buttonStyle="secondary" size="small">
</Button>
) : ( ) : (
<div <span style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
style={{ <span style={{ fontSize: '0.78rem', color: 'var(--theme-error-750)', fontWeight: 600 }}>
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', </span>
gap: '0.75rem', <Button onClick={() => run(true)} disabled={busy} size="small">
marginBottom: '1rem', {loadingForce ? '更新中…' : '确认'}
}}
>
{/* 第一行:基础同步功能 */}
<Button
onClick={handleSyncNew}
disabled={loading}
buttonStyle="primary"
>
📥 {loading ? '同步中...' : '同步新商品'}
</Button> </Button>
<Button onClick={() => setConfirmForce(false)} disabled={busy} buttonStyle="secondary" size="small">
<Button
onClick={() => handleBatchSync(false)}
disabled={loading}
buttonStyle="secondary"
>
🔄 {loading ? '同步中...' : '同步选中商品'}
</Button> </Button>
</span>
{/* 第二行:强制更新功能 */}
<Button
onClick={() => handleBatchSync(true)}
disabled={loading}
buttonStyle="secondary"
>
{loading ? '更新中...' : '强制更新选中'}
</Button>
<Button
onClick={() => {
setShowForceAllConfirm(true)
setMessage('')
setConfirmText('')
}}
disabled={loading}
buttonStyle="secondary"
>
🔥
</Button>
</div>
)} )}
{message && (
<div
style={{
padding: '0.75rem',
backgroundColor: message.includes('❌')
? 'var(--theme-error-50)'
: message.includes('⚠️')
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
borderRadius: '4px',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{message}
</div> </div>
<Msg text={msg} />
</div>
)
}
// ── Section: Preorder management ──────────────────────────────────────────────
function PreorderSection() {
const { getQueryParams, toggleAll } = useSelection()
const router = useRouter()
const [hcLoading, setHcLoading] = useState(false)
const [hcError, setHcError] = useState<string | null>(null)
const [hcResult, setHcResult] = useState<HealthCheckResult | null>(null)
const [hcOpen, setHcOpen] = useState(false)
const [rcLoading, setRcLoading] = useState(false)
const [rcMsg, setRcMsg] = useState('')
const runHealthCheck = async () => {
setHcLoading(true); setHcError(null)
try {
const res = await fetch('/api/preorders/health-check')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setHcResult(data); setHcOpen(true)
} catch (e: any) { setHcError(e.message || '健康检查失败') }
finally { setHcLoading(false) }
}
const refreshSelected = async () => {
const queryParams = getQueryParams()
let ids: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) ids = where.id.in
}
if (!ids.length) {
setRcMsg('⚠️ 请先勾选要刷新的商品(列表左侧复选框)')
return
}
setRcLoading(true); setRcMsg('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productIds: ids }),
})
const data = await res.json()
setRcMsg(data.success
? `${data.message || '刷新成功'}`
: `${data.error || '失败'}`)
if (data.success) { toggleAll?.(); setTimeout(() => router.refresh(), 1500) }
} catch (e: any) { setRcMsg(`${e?.message ?? '未知错误'}`) }
finally { setRcLoading(false) }
}
const refreshAll = async () => {
if (!confirm('确定要刷新所有预购商品的订单计数吗?')) return
setRcLoading(true); setRcMsg('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshAll: true }),
})
const data = await res.json()
setRcMsg(data.success
? `${data.message || '刷新成功'}`
: `${data.error || '失败'}`)
if (data.success) setTimeout(() => router.refresh(), 1500)
} catch (e: any) { setRcMsg(`${e?.message ?? '未知错误'}`) }
finally { setRcLoading(false) }
}
const severityIcon = (s: string) =>
({ error: '❌', warning: '⚠️', healthy: '✅' } as Record<string, string>)[s] ?? ''
const fmtDate = (d: string | null) => {
if (!d) return 'N/A'
try {
return new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
} catch { return d }
}
return (
<div>
<SectionLabel>📦 </SectionLabel>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button onClick={runHealthCheck} disabled={hcLoading} buttonStyle="secondary" size="small">
{hcLoading ? '检查中…' : '🏥 健康检查'}
</Button>
<Button onClick={refreshSelected} disabled={rcLoading} buttonStyle="secondary" size="small">
{rcLoading ? '刷新中…' : '📊 刷新选中计数'}
</Button>
<Button onClick={refreshAll} disabled={rcLoading} buttonStyle="secondary" size="small">
{rcLoading ? '刷新中…' : '📊 刷新全部计数'}
</Button>
</div>
{hcError && <Msg text={`${hcError}`} />}
<Msg text={rcMsg} />
{hcOpen && hcResult && (
<Modal slug="preorder-health-check-modal" onClose={() => setHcOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.25rem', fontWeight: 700 }}>
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '0.75rem', marginBottom: '1.25rem' }}>
{([
{ label: '总数', value: hcResult.summary.total, bg: '#EFF6FF', border: '#BFDBFE', color: '#1E40AF' },
{ label: '健康', value: hcResult.summary.healthy, bg: '#F0FDF4', border: '#BBF7D0', color: '#15803D' },
{ label: '警告', value: hcResult.summary.warnings, bg: '#FEFCE8', border: '#FDE047', color: '#A16207' },
{ label: '错误', value: hcResult.summary.errors, bg: '#FEF2F2', border: '#FECACA', color: '#B91C1C' },
] as const).map(({ label, value, bg, border, color }) => (
<div key={label} style={{ padding: '0.75rem', background: bg, border: `1px solid ${border}`, borderRadius: '6px' }}>
<p style={{ margin: '0 0 0.2rem', fontSize: '0.75rem', color, fontWeight: 500 }}>{label}</p>
<p style={{ margin: 0, fontSize: '1.75rem', fontWeight: 700, color }}>{value}</p>
</div>
))}
</div>
<p style={{ fontSize: '0.8rem', color: '#6B7280', marginBottom: '1rem' }}>
: {new Date(hcResult.timestamp).toLocaleString('zh-CN')}
</p>
<div style={{ maxHeight: '480px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{hcResult.products.map((p) => {
const borderColor = p.severity === 'error' ? '#FCA5A5' : p.severity === 'warning' ? '#FCD34D' : '#86EFAC'
const bgColor = p.severity === 'error' ? '#FEF2F2' : p.severity === 'warning' ? '#FEFCE8' : '#F0FDF4'
return (
<div key={p.id} style={{ border: `1px solid ${borderColor}`, background: bgColor, borderRadius: '6px', padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span>{severityIcon(p.severity)}</span>
<strong style={{ fontSize: '0.9rem' }}>{p.title}</strong>
<span style={{
padding: '0.1rem 0.45rem', fontSize: '0.7rem', borderRadius: '999px',
background: p.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: p.status === 'published' ? '#065F46' : '#374151',
}}>{p.status}</span>
</div>
<p style={{ margin: 0, fontSize: '0.78rem', color: '#4B5563' }}>Medusa ID: {p.medusaId}</p>
</div>
<div style={{ textAlign: 'right', fontSize: '0.78rem', color: '#4B5563', flexShrink: 0 }}>
<p style={{ margin: 0 }}> {p.stats.completionPercentage}%</p>
<p style={{ margin: 0 }}>{p.stats.totalDisplayCount} / {p.stats.fundingGoal}</p>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.78rem', color: '#4B5563', marginTop: '0.4rem' }}>
<span><strong>:</strong> {fmtDate(p.dates.preorderStartDate)}</span>
<span><strong>:</strong> {fmtDate(p.dates.preorderEndDate)}</span>
</div>
{p.issues.length > 0 && (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', fontSize: '0.78rem' }}>
{p.issues.map((issue, i) => <li key={i}>{issue}</li>)}
</ul>
)} )}
{!showForceAllConfirm && (
<div
style={{
fontSize: '0.75rem',
color: 'var(--theme-elevation-400)',
lineHeight: '1.5',
background: 'var(--theme-elevation-50)',
padding: '0.75rem',
borderRadius: '4px',
}}
>
<div style={{ marginBottom: '0.5rem', fontWeight: 600 }}>💡 </div>
<div style={{ display: 'grid', gap: '0.25rem' }}>
<div>
<strong>📥 </strong>: Medusa
</div> </div>
<div> )
<strong>🔄 </strong>: })}
</div> {hcResult.products.length === 0 && (
<div> <p style={{ textAlign: 'center', padding: '2rem', color: '#6B7280' }}></p>
<strong> </strong>: )}
</div>
<div>
<strong>🔥 </strong>:
</div> </div>
<div style={{ marginTop: '1.25rem', textAlign: 'right' }}>
<Button onClick={() => setHcOpen(false)} buttonStyle="primary"></Button>
</div> </div>
</div> </div>
</Modal>
)}
</div>
)
}
// ── Root export ───────────────────────────────────────────────────────────────
/**
* beforeListTable
* Medusa / / preorder-products
*/
export function UnifiedSyncButton() {
const [isPreorder, setIsPreorder] = useState(false)
const [collection, setCollection] = useState('products')
useEffect(() => {
if (typeof window !== 'undefined') {
const isP = window.location.pathname.includes('preorder-products')
setIsPreorder(isP)
setCollection(isP ? 'preorder-products' : 'products')
}
}, [])
return (
<div style={{
margin: '0.25rem 0 0.5rem',
padding: '0.5rem 0.75rem',
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-150)',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}>
<MedusaSyncSection collection={collection} />
<Divider />
<TaobaoSyncSection />
{isPreorder && (
<>
<Divider />
<PreorderSection />
</>
)} )}
</div> </div>
) )

View File

@ -2,25 +2,20 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@payloadcms/ui' import { Button } from '@payloadcms/ui'
interface Props {
className?: string
}
/** /**
* Reset Data Button * / Medusa
* Payload + Medusa + Seed Medusa * API: POST /api/admin/reset-data
* Medusa Medusa + Seed Medusa Payload
*/ */
export function ResetDataButton({ className }: Props) { export function ResetData() {
const [loading, setLoading] = useState<'full' | 'medusa-only' | null>(null) const [loading, setLoading] = useState<'full' | 'medusa-only' | null>(null)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [details, setDetails] = useState<any>(null) const [details, setDetails] = useState<any>(null)
const handleReset = async (mode: 'full' | 'medusa-only') => { const handle = async (mode: 'full' | 'medusa-only') => {
const confirmMsg = mode === 'medusa-only' const confirmMsg =
mode === 'medusa-only'
? '⚠️ 重置 Medusa 数据\n\n此操作将\n1. 清理所有 Medusa 数据\n2. 重新导入 Medusa seed 数据\n\nPayload CMS 数据不受影响。\n\n⚠ 此操作不可撤销!确认继续吗?' ? '⚠️ 重置 Medusa 数据\n\n此操作将\n1. 清理所有 Medusa 数据\n2. 重新导入 Medusa seed 数据\n\nPayload CMS 数据不受影响。\n\n⚠ 此操作不可撤销!确认继续吗?'
: '⚠️ 危险操作:重置所有数据\n\n此操作将\n1. 清理所有 Payload CMS 数据(保留用户)\n2. 清理所有 Medusa 数据\n3. 重新导入 Medusa seed 数据\n\n⚠ 此操作不可撤销!确认要继续吗?' : '⚠️ 危险操作:重置所有数据\n\n此操作将\n1. 清理所有 Payload CMS 数据(保留用户)\n2. 清理所有 Medusa 数据\n3. 重新导入 Medusa seed 数据\n\n⚠ 此操作不可撤销!确认要继续吗?'
if (!confirm(confirmMsg)) return if (!confirm(confirmMsg)) return
setLoading(mode) setLoading(mode)
@ -28,53 +23,42 @@ export function ResetDataButton({ className }: Props) {
setDetails(null) setDetails(null)
try { try {
const response = await fetch('/api/admin/reset-data', { const res = await fetch('/api/admin/reset-data', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }), body: JSON.stringify({ mode }),
}) })
const result = await res.json()
const result = await response.json()
if (!result.success) { if (!result.success) {
// 优先显示顶级 error否则找第一个失败步骤的错误
const stepError = result.steps?.find((s: any) => !s.success && !s.skipped && s.error)?.error const stepError = result.steps?.find((s: any) => !s.success && !s.skipped && s.error)?.error
throw new Error(result.error || stepError || 'Reset failed') throw new Error(result.error || stepError || 'Reset failed')
} }
setDetails(result) setDetails(result)
setMessage( setMessage(
mode === 'medusa-only' mode === 'medusa-only'
? '✅ Medusa 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS' ? '✅ Medusa 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS'
: '✅ 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS\n2. 设置 ProductRecommendations\n3. 配置 PreorderProducts 的预购设置' : '✅ 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS\n2. 设置 ProductRecommendations\n3. 配置 PreorderProducts 的预购设置',
) )
} catch (error) { } catch (err) {
console.error('数据重置失败:', error) setMessage('❌ 重置失败: ' + (err instanceof Error ? err.message : 'Unknown error'))
setMessage('❌ 重置失败: ' + (error instanceof Error ? error.message : 'Unknown error'))
} finally { } finally {
setLoading(null) setLoading(null)
} }
} }
const handleResetData = () => handleReset('full') const busy = loading !== null
const handleResetMedusaOnly = () => handleReset('medusa-only')
return ( return (
<div className={className}> <div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}> <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Button <Button onClick={() => handle('full')} buttonStyle="error" disabled={busy} size="small">
onClick={handleResetData}
buttonStyle="error"
disabled={loading !== null}
size="medium"
>
{loading === 'full' ? '🔄 重置中...' : '🗑️ 重置所有数据'} {loading === 'full' ? '🔄 重置中...' : '🗑️ 重置所有数据'}
</Button> </Button>
<Button <Button
onClick={handleResetMedusaOnly} onClick={() => handle('medusa-only')}
buttonStyle="secondary" buttonStyle="secondary"
disabled={loading !== null} disabled={busy}
size="medium" size="small"
> >
{loading === 'medusa-only' ? '🔄 重置中...' : '🔄 仅重置 Medusa'} {loading === 'medusa-only' ? '🔄 重置中...' : '🔄 仅重置 Medusa'}
</Button> </Button>
@ -83,33 +67,37 @@ export function ResetDataButton({ className }: Props) {
{message && ( {message && (
<div <div
style={{ style={{
padding: '1rem', marginTop: '0.75rem',
backgroundColor: message.includes('✅') ? '#d4edda' : message.includes('❌') ? '#f8d7da' : '#d1ecf1', padding: '0.75rem',
border: `1px solid ${message.includes('✅') ? '#c3e6cb' : message.includes('❌') ? '#f5c6cb' : '#bee5eb'}`,
borderRadius: '4px', borderRadius: '4px',
marginTop: '1rem',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
fontSize: '0.9rem', fontSize: '0.85rem',
backgroundColor: message.includes('✅')
? '#d4edda'
: message.includes('❌')
? '#f8d7da'
: '#d1ecf1',
border: `1px solid ${message.includes('✅') ? '#c3e6cb' : message.includes('❌') ? '#f5c6cb' : '#bee5eb'}`,
}} }}
> >
{message} {message}
</div> </div>
)} )}
{details && details.steps && ( {details?.steps && (
<div <div
style={{ style={{
marginTop: '1rem', marginTop: '0.75rem',
padding: '1rem', padding: '0.75rem',
backgroundColor: '#f8f9fa', background: '#f8f9fa',
border: '1px solid #dee2e6', border: '1px solid #dee2e6',
borderRadius: '4px', borderRadius: '4px',
fontSize: '0.85rem', fontSize: '0.82rem',
}} }}
> >
<h4 style={{ marginTop: 0, marginBottom: '0.5rem' }}></h4> <h4 style={{ margin: '0 0 0.5rem' }}></h4>
{details.steps.map((step: any, index: number) => ( {details.steps.map((step: any, i: number) => (
<div key={index} style={{ marginBottom: '0.5rem' }}> <div key={i} style={{ marginBottom: '0.4rem' }}>
<strong> <strong>
[{step.step}/3] {step.name}:{' '} [{step.step}/3] {step.name}:{' '}
</strong> </strong>
@ -117,9 +105,7 @@ export function ResetDataButton({ className }: Props) {
{step.skipped ? '⏭️ 跳过' : step.success ? '✅ 成功' : '❌ 失败'} {step.skipped ? '⏭️ 跳过' : step.success ? '✅ 成功' : '❌ 失败'}
</span> </span>
{step.deleted !== undefined && ( {step.deleted !== undefined && (
<span style={{ marginLeft: '0.5rem' }}> <span style={{ marginLeft: '0.4rem' }}>( {step.deleted} )</span>
( {step.deleted} )
</span>
)} )}
</div> </div>
))} ))}

View File

@ -0,0 +1,119 @@
'use client'
import { useState, useEffect } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
* /
* API: POST /api/admin/batch-sync-medusa
*/
export function BatchSyncProducts() {
const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [collectionSlug, setCollectionSlug] = useState('products')
const router = useRouter()
useEffect(() => {
if (typeof window !== 'undefined') {
setCollectionSlug(
window.location.pathname.includes('preorder-products')
? 'preorder-products'
: 'products',
)
}
}, [])
const handle = async (forceUpdate: boolean) => {
const queryParams = getQueryParams()
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) selectedIds = where.id.in
}
if (!selectedIds.length) {
setMessage('⚠️ 请先勾选要同步的商品(使用列表左侧的复选框)')
return
}
if (
forceUpdate &&
!confirm(`确定要强制更新选中的 ${selectedIds.length} 个商品吗?这将覆盖本地修改。`)
)
return
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/admin/batch-sync-medusa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedIds, collection: collectionSlug, forceUpdate }),
})
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '批量同步成功!'))
toggleAll?.()
setTimeout(() => router.refresh(), 1500)
} else {
setMessage('❌ ' + (data.error || '批量同步失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button
onClick={() => handle(false)}
disabled={loading}
buttonStyle="secondary"
size="small"
>
🔄 {loading ? '同步中...' : '同步选中商品'}
</Button>
<Button
onClick={() => handle(true)}
disabled={loading}
buttonStyle="secondary"
size="small"
>
{loading ? '更新中...' : '强制更新选中'}
</Button>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
/**
* Medusa
* API: GET /api/sync/medusa?forceUpdate=true
*/
export function ForceUpdateAll() {
const [showConfirm, setShowConfirm] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const handle = async () => {
if (confirmText !== 'FORCE_UPDATE_ALL') {
setMessage('❌ 确认字符不正确,请输入: FORCE_UPDATE_ALL')
return
}
setLoading(true)
setMessage('')
setShowConfirm(false)
try {
const res = await fetch('/api/sync/medusa?forceUpdate=true')
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '强制更新成功!'))
setTimeout(() => window.location.reload(), 1500)
} else {
setMessage('❌ ' + (data.error || data.message || '更新失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
setConfirmText('')
}
}
const cancel = () => {
setShowConfirm(false)
setConfirmText('')
setMessage('')
}
if (showConfirm) {
return (
<div>
<div
style={{
marginBottom: '0.75rem',
padding: '0.75rem',
background: 'var(--theme-warning-50)',
borderRadius: '4px',
fontSize: '0.85rem',
}}
>
<p style={{ margin: '0 0 0.4rem', fontWeight: 700, color: 'var(--theme-warning-900)' }}>
</p>
<p style={{ margin: '0 0 0.4rem' }}></p>
<p style={{ margin: 0 }}>
{' '}
<code
style={{
padding: '0.1rem 0.3rem',
background: 'var(--theme-elevation-100)',
borderRadius: '2px',
}}
>
FORCE_UPDATE_ALL
</code>{' '}
</p>
</div>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 FORCE_UPDATE_ALL"
disabled={loading}
style={{
width: '100%',
padding: '0.4rem 0.5rem',
marginBottom: '0.5rem',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '4px',
fontSize: '0.875rem',
}}
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
onClick={handle}
disabled={loading || confirmText !== 'FORCE_UPDATE_ALL'}
size="small"
>
{loading ? '更新中...' : '✅ 确认强制更新'}
</Button>
<Button onClick={cancel} disabled={loading} buttonStyle="secondary" size="small">
</Button>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
return (
<div>
<Button
onClick={() => {
setShowConfirm(true)
setMessage('')
setConfirmText('')
}}
disabled={loading}
buttonStyle="secondary"
size="small"
>
🔥
</Button>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
/**
* Medusa
* API: GET /api/sync/medusa?forceUpdate=false
*/
export function SyncNewProducts() {
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const handle = async () => {
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/sync/medusa?forceUpdate=false')
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '同步成功!'))
setTimeout(() => window.location.reload(), 1500)
} else {
setMessage('❌ ' + (data.error || data.message || '同步失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<Button onClick={handle} disabled={loading} buttonStyle="primary" size="small">
📥 {loading ? '同步中...' : '同步新商品'}
</Button>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import React, { useState } from 'react'
import { Button, Modal } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: { total: number; healthy: number; warnings: number; errors: number }
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: { preorderStartDate: string | null; preorderEndDate: string | null }
}>
issues: string[]
}
/**
*
* API: GET /api/preorders/health-check
*/
export function HealthCheck() {
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const run = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/preorders/health-check')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setResult(data)
setIsOpen(true)
} catch (err: any) {
setError(err.message || '健康检查失败')
} finally {
setLoading(false)
}
}
const severityIcon = (s: string) =>
({ error: '❌', warning: '⚠️', healthy: '✅' })[s] ?? ''
const fmtDate = (d: string | null) => {
if (!d) return 'N/A'
try {
return new Date(d).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return d
}
}
return (
<>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.75rem' }}>
<Button onClick={run} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '检查中...' : '🏥 健康检查'}
</Button>
{error && <span style={{ fontSize: '0.8rem', color: 'var(--theme-error-750)' }}> {error}</span>}
</div>
{isOpen && result && (
<Modal slug="preorder-health-check-modal" onClose={() => setIsOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.5rem', fontWeight: 'bold' }}>
</h2>
{/* 概览 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
marginBottom: '1.5rem',
}}
>
{(
[
{ label: '总数', value: result.summary.total, bg: '#EFF6FF', border: '#BFDBFE', text: '#2563EB', bold: '#1E40AF' },
{ label: '健康', value: result.summary.healthy, bg: '#F0FDF4', border: '#BBF7D0', text: '#16A34A', bold: '#15803D' },
{ label: '警告', value: result.summary.warnings, bg: '#FEFCE8', border: '#FDE047', text: '#CA8A04', bold: '#A16207' },
{ label: '错误', value: result.summary.errors, bg: '#FEF2F2', border: '#FECACA', text: '#DC2626', bold: '#B91C1C' },
] as const
).map(({ label, value, bg, border, text, bold }) => (
<div
key={label}
style={{ padding: '1rem', backgroundColor: bg, borderRadius: '0.5rem', border: `1px solid ${border}` }}
>
<p style={{ fontSize: '0.875rem', color: text, fontWeight: '500', marginBottom: '0.25rem' }}>{label}</p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: bold }}>{value}</p>
</div>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#6B7280', marginBottom: '1.5rem' }}>
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{result.products.map((product) => {
const borderColor =
product.severity === 'error' ? '#FCA5A5' : product.severity === 'warning' ? '#FCD34D' : '#86EFAC'
const bgColor =
product.severity === 'error' ? '#FEF2F2' : product.severity === 'warning' ? '#FEFCE8' : '#F0FDF4'
return (
<div
key={product.id}
style={{
border: `1px solid ${borderColor}`,
backgroundColor: bgColor,
borderRadius: '0.5rem',
padding: '1rem',
marginBottom: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>{severityIcon(product.severity)}</span>
<h3 style={{ fontSize: '1.125rem', fontWeight: '600' }}>{product.title}</h3>
<span
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
borderRadius: '9999px',
backgroundColor: product.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: product.status === 'published' ? '#065F46' : '#374151',
}}
>
{product.status}
</span>
</div>
<div style={{ fontSize: '0.875rem', color: '#4B5563' }}>
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.875rem', color: '#4B5563' }}>
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
<div
style={{
display: 'flex',
gap: '1rem',
fontSize: '0.875rem',
color: '#4B5563',
marginBottom: '0.75rem',
}}
>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{fmtDate(product.dates.preorderStartDate)}
</div>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{fmtDate(product.dates.preorderEndDate)}
</div>
</div>
{product.issues.length > 0 && (
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid #D1D5DB',
}}
>
<p style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>:</p>
<ul style={{ fontSize: '0.875rem', paddingLeft: '1rem' }}>
{product.issues.map((issue, i) => (
<li key={i} style={{ marginBottom: '0.25rem' }}>
{issue}
</li>
))}
</ul>
</div>
)}
</div>
)
})}
{result.products.length === 0 && (
<p style={{ textAlign: 'center', padding: '3rem', color: '#6B7280' }}>
</p>
)}
</div>
<div style={{ marginTop: '1.5rem', textAlign: 'right' }}>
<Button onClick={() => setIsOpen(false)} buttonStyle="primary">
</Button>
</div>
</div>
</Modal>
)}
</>
)
}

View File

@ -0,0 +1,136 @@
'use client'
import { useState } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
* /
* API: POST /api/preorders/refresh-order-counts
*/
export function RefreshOrderCounts() {
const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const router = useRouter()
const handleAll = async () => {
if (!confirm('确定要刷新所有预购商品的订单计数吗?')) return
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshAll: true }),
})
const data = await res.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
setTimeout(() => router.refresh(), 1500)
} else {
setMessage(`${data.error || '刷新失败'}`)
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
const handleSelected = async () => {
const queryParams = getQueryParams()
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) selectedIds = where.id.in
}
if (!selectedIds.length) {
setMessage('⚠️ 请先勾选要刷新的商品(使用列表左侧的复选框)')
return
}
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productIds: selectedIds }),
})
const data = await res.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
toggleAll?.()
setTimeout(() => router.refresh(), 1500)
} else {
setMessage(`${data.error || '刷新失败'}`)
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button onClick={handleSelected} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '刷新中...' : '刷新选中订单计数'}
</Button>
<Button onClick={handleAll} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '刷新中...' : '刷新全部订单计数'}
</Button>
</div>
{message && <StatusMsg text={message} />}
<div
style={{
marginTop: '0.75rem',
fontSize: '0.78rem',
color: 'var(--theme-elevation-500)',
lineHeight: 1.6,
}}
>
<p style={{ margin: '0.2rem 0' }}>
💡 <strong></strong> = + Fake计数
</p>
<p style={{ margin: '0.2rem 0' }}>
<strong></strong> Medusa
</p>
<p style={{ margin: '0.2rem 0' }}>
<strong>Fake计数</strong>
</p>
</div>
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,123 @@
'use client'
import { useState } from 'react'
/**
* /
* API: POST /api/admin/taobao/sync-all
*/
export function TaobaoAllSync() {
const [loadingNormal, setLoadingNormal] = useState(false)
const [loadingForce, setLoadingForce] = useState(false)
const [confirmForce, setConfirmForce] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const busy = loadingNormal || loadingForce
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)
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={btnStyle(busy, '#10b981')}
>
{loadingNormal ? '更新中…' : '🔄 更新全部淘宝'}
</button>
{!confirmForce ? (
<button
type="button"
disabled={busy}
onClick={() => setConfirmForce(true)}
style={btnStyle(busy, '#ef4444')}
>
</button>
) : (
<>
<span style={{ fontSize: '0.78rem', color: '#dc2626', fontWeight: 600 }}>
</span>
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={btnStyle(busy, '#dc2626')}
>
{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>
</>
)}
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
const btnStyle = (busy: boolean, color: string): React.CSSProperties => ({
padding: '0.4rem 0.85rem',
background: busy ? '#9ca3af' : color,
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.78rem',
fontWeight: 500,
whiteSpace: 'nowrap',
})
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError ? 'var(--theme-error-50)' : 'var(--theme-success-50)',
color: isError ? 'var(--theme-error-750)' : 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
/**
* /
* API: POST /api/admin/taobao/sync-product
*
* 使 useDocumentInfo Product / PreorderProduct 使
*/
export function TaobaoProductSync() {
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 isValid = collectionSlug === 'products' || collectionSlug === 'preorder-products'
if (!isValid) return null
const busy = loadingNormal || loadingForce
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)
}
}
return (
<div>
<div
style={{
fontSize: '0.8rem',
fontWeight: 600,
color: 'var(--theme-elevation-600)',
marginBottom: '0.5rem',
}}
>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={btnStyle(busy, '#3b82f6')}
>
{loadingNormal ? '解析中…' : '🔄 更新淘宝信息'}
</button>
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={btnStyle(busy, '#f97316')}
>
{loadingForce ? '解析中…' : '⚡ 强制更新淘宝信息'}
</button>
</div>
<div
style={{
marginTop: '0.5rem',
fontSize: '0.73rem',
color: 'var(--theme-elevation-450)',
lineHeight: 1.5,
}}
>
<strong>🔄 </strong>&emsp;
<strong> </strong>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
const btnStyle = (busy: boolean, color: string): React.CSSProperties => ({
padding: '0.4rem 0.9rem',
background: busy ? '#9ca3af' : color,
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
})
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.75rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError ? 'var(--theme-error-50)' : 'var(--theme-success-50)',
color: isError ? 'var(--theme-error-750)' : 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -2,7 +2,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button } from '@payloadcms/ui' import { Button } from '@payloadcms/ui'
import { ResetDataButton } from '../sync/ResetDataButton' import { ResetData } from '../sync/admin/ResetData'
/** /**
* *
@ -168,7 +168,7 @@ export default function AdminPanel() {
Payload CMS Medusa Medusa seed Payload CMS Medusa Medusa seed
</p> </p>
<ResetDataButton /> <ResetData />
</div> </div>
{/* 清理数据库 */} {/* 清理数据库 */}

View File

@ -1,260 +0,0 @@
'use client'
import React, { useState } from 'react'
import { Button, Modal } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: {
total: number
healthy: number
warnings: number
errors: number
}
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: {
preorderStartDate: string | null
preorderEndDate: string | null
}
}>
issues: string[]
}
export const PreorderHealthCheckButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runHealthCheck = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/preorders/health-check')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setResult(data)
setIsOpen(true)
} catch (err: any) {
setError(err.message || 'Failed to run health check')
console.error('Health check error:', err)
} finally {
setLoading(false)
}
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return '❌'
case 'warning':
return '⚠️'
case 'healthy':
return '✅'
default:
return ''
}
}
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A'
try {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return dateString
}
}
return (
<>
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem' }}>
<Button
onClick={runHealthCheck}
disabled={loading}
buttonStyle="secondary"
icon="health"
>
{loading ? '检查中...' : '🏥 健康检查'}
</Button>
{error && (
<span style={{ color: 'red', marginLeft: '1rem', alignSelf: 'center' }}>
: {error}
</span>
)}
</div>
{isOpen && result && (
<Modal slug="health-check-modal" onClose={() => setIsOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.5rem', fontWeight: 'bold' }}>
</h2>
{/* 概览统计 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<div style={{
padding: '1rem',
backgroundColor: '#EFF6FF',
borderRadius: '0.5rem',
border: '1px solid #BFDBFE'
}}>
<p style={{ fontSize: '0.875rem', color: '#2563EB', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#1E40AF' }}>{result.summary.total}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#F0FDF4',
borderRadius: '0.5rem',
border: '1px solid #BBF7D0'
}}>
<p style={{ fontSize: '0.875rem', color: '#16A34A', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#15803D' }}>{result.summary.healthy}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#FEFCE8',
borderRadius: '0.5rem',
border: '1px solid #FDE047'
}}>
<p style={{ fontSize: '0.875rem', color: '#CA8A04', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#A16207' }}>{result.summary.warnings}</p>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#FEF2F2',
borderRadius: '0.5rem',
border: '1px solid #FECACA'
}}>
<p style={{ fontSize: '0.875rem', color: '#DC2626', fontWeight: '500', marginBottom: '0.25rem' }}></p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: '#B91C1C' }}>{result.summary.errors}</p>
</div>
</div>
<p style={{ fontSize: '0.875rem', color: '#6B7280', marginBottom: '1.5rem' }}>
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{result.products.map((product) => (
<div
key={product.id}
style={{
border: `1px solid ${
product.severity === 'error'
? '#FCA5A5'
: product.severity === 'warning'
? '#FCD34D'
: '#86EFAC'
}`,
backgroundColor: product.severity === 'error'
? '#FEF2F2'
: product.severity === 'warning'
? '#FEFCE8'
: '#F0FDF4',
borderRadius: '0.5rem',
padding: '1rem',
marginBottom: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>{getSeverityIcon(product.severity)}</span>
<h3 style={{ fontSize: '1.125rem', fontWeight: '600' }}>{product.title}</h3>
<span
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
borderRadius: '9999px',
backgroundColor: product.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: product.status === 'published' ? '#065F46' : '#374151',
}}
>
{product.status}
</span>
</div>
<div style={{ fontSize: '0.875rem', color: '#4B5563' }}>
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.875rem', color: '#4B5563' }}>
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#4B5563', marginBottom: '0.75rem' }}>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{formatDate(product.dates.preorderStartDate)}
</div>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{formatDate(product.dates.preorderEndDate)}
</div>
</div>
{product.issues.length > 0 && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid #D1D5DB' }}>
<p style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>:</p>
<ul style={{ fontSize: '0.875rem', paddingLeft: '1rem' }}>
{product.issues.map((issue, idx) => (
<li key={idx} style={{ marginBottom: '0.25rem' }}>
{issue}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
{result.products.length === 0 && (
<p style={{ textAlign: 'center', padding: '3rem', color: '#6B7280' }}>
</p>
)}
<div style={{ marginTop: '1.5rem', textAlign: 'right' }}>
<Button onClick={() => setIsOpen(false)} buttonStyle="primary">
</Button>
</div>
</div>
</Modal>
)}
</>
)
}