重新布局

This commit is contained in:
龟男日记\www 2026-02-09 04:52:14 +08:00
parent b4991fcefd
commit 84f94b53d9
12 changed files with 401 additions and 19 deletions

View File

@ -1,5 +1,5 @@
import { ThumbnailCell as ThumbnailCell_a0b2acb813359aec894b6644d7c3bfd2 } from '../../../components/products/ThumbnailCell' import { ThumbnailCell as ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d } from '../../../components/cells/ThumbnailCell'
import { ThumbnailField as ThumbnailField_ba44ab32cac4d742a03a48fb6960602e } from '../../../components/products/ThumbnailField' import { ThumbnailField as ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6 } from '../../../components/fields/ThumbnailField'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
@ -24,15 +24,16 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SyncMedusaButton as SyncMedusaButton_8c90663551920f0510ea531726668adc } from '../../../components/products/SyncMedusaButton' import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField'
import { default as default_3fd1353246fc8a459244c8dc11f58470 } from '../../../components/products/ProductGridStyler' import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton'
import { ForceSyncButton as ForceSyncButton_86f9d5df4f20495427521354d06db618 } from '../../../components/products/ForceSyncButton' import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = { export const importMap = {
"/components/products/ThumbnailCell#ThumbnailCell": ThumbnailCell_a0b2acb813359aec894b6644d7c3bfd2, "/components/cells/ThumbnailCell#ThumbnailCell": ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d,
"/components/products/ThumbnailField#ThumbnailField": ThumbnailField_ba44ab32cac4d742a03a48fb6960602e, "/components/fields/ThumbnailField#ThumbnailField": ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
@ -57,9 +58,10 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/products/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_8c90663551920f0510ea531726668adc, "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
"/components/products/ProductGridStyler#default": default_3fd1353246fc8a459244c8dc11f58470, "/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08,
"/components/products/ForceSyncButton#ForceSyncButton": ForceSyncButton_86f9d5df4f20495427521354d06db618, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
"/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }

View File

@ -33,11 +33,11 @@ export const Products: CollectionConfig = {
}, },
components: { components: {
edit: { edit: {
PreviewButton: '/components/products/ForceSyncButton#ForceSyncButton', PreviewButton: '/components/sync/ForceSyncButton#ForceSyncButton',
}, },
beforeListTable: [ beforeListTable: [
'/components/products/SyncMedusaButton#SyncMedusaButton', '/components/sync/SyncMedusaButton#SyncMedusaButton',
'/components/products/ProductGridStyler', '/components/list/ProductGridStyler',
], ],
}, },
}, },
@ -103,8 +103,8 @@ export const Products: CollectionConfig = {
readOnly: true, readOnly: true,
width: '20%', width: '20%',
components: { components: {
Cell: '/components/products/ThumbnailCell#ThumbnailCell', Cell: '/components/cells/ThumbnailCell#ThumbnailCell',
Field: '/components/products/ThumbnailField#ThumbnailField', Field: '/components/fields/ThumbnailField#ThumbnailField',
}, },
}, },
}, },
@ -131,7 +131,8 @@ export const Products: CollectionConfig = {
hasMany: true, hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'], options: ['noopener', 'noreferrer', 'nofollow'],
admin: { admin: {
description: 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
}, },
}, },
], ],
@ -170,7 +171,10 @@ export const Products: CollectionConfig = {
relationTo: 'products', relationTo: 'products',
hasMany: true, hasMany: true,
admin: { admin: {
description: '相关商品', description: '相关商品,支持搜索联想',
components: {
Field: '/components/fields/RelatedProductsField#RelatedProductsField',
},
}, },
}, },
{ {

View File

@ -0,0 +1,377 @@
'use client'
import { useField, useConfig, FieldLabel } from '@payloadcms/ui'
import { useState, useEffect, useCallback } from 'react'
import type { RelationshipFieldClientComponent } from 'payload'
/**
* - 使 Payload
*
*/
export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, field }) => {
const { value, setValue } = useField<string[]>({ path })
const { config } = useConfig()
const [inputValue, setInputValue] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const [selectedDetails, setSelectedDetails] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(false)
// Fetch details for selected items
useEffect(() => {
const fetchSelectedDetails = async () => {
if (!value || value.length === 0) {
setSelectedDetails([])
return
}
const ids = Array.isArray(value) ? value : [value as unknown as string]
try {
const searchParams = new URLSearchParams()
ids.forEach((id, index) => {
const idStr = typeof id === 'object' ? (id as any).id : id
searchParams.append(`where[id][in][${index}]`, idStr)
})
const res = await fetch(
`${config.routes.api}/products?${searchParams.toString()}&limit=${ids.length}`,
)
const data = await res.json()
if (data.docs) {
const docsMap = new Map(data.docs.map((d: any) => [d.id, d]))
const ordered = ids
.map((id) => {
const idStr = typeof id === 'object' ? (id as any).id : id
return docsMap.get(idStr)
})
.filter(Boolean)
setSelectedDetails(ordered)
}
} catch (e) {
console.error('Error fetching selected products:', e)
}
}
fetchSelectedDetails()
}, [value, config.routes.api])
// Search function with debounce
const searchProducts = useCallback(
async (term: string) => {
if (!term) {
setSearchResults([])
return
}
setIsLoading(true)
try {
const res = await fetch(
`${config.routes.api}/products?where[title][like]=${encodeURIComponent(term)}&limit=10`,
)
const data = await res.json()
setSearchResults(data.docs || [])
} catch (e) {
console.error('Search error:', e)
setSearchResults([])
} finally {
setIsLoading(false)
}
},
[config.routes.api],
)
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
searchProducts(inputValue)
}, 300)
return () => clearTimeout(timer)
}, [inputValue, searchProducts])
const handleAdd = (product: any) => {
const currentIds = Array.isArray(value) ? value : []
const exists = currentIds.some((id: any) => {
const idStr = typeof id === 'object' ? id.id : id
return idStr === product.id
})
if (!exists) {
setValue([...currentIds, product.id])
setSelectedDetails((prev) => [...prev, product])
}
setInputValue('')
}
const handleRemove = (idToRemove: string) => {
const currentIds = Array.isArray(value) ? value : []
const newValue = currentIds.filter((id: any) => {
const idStr = typeof id === 'object' ? id.id : id
return idStr !== idToRemove
})
setValue(newValue)
setSelectedDetails((prev) => prev.filter((p) => p.id !== idToRemove))
}
return (
<div style={{ marginBottom: 'var(--base)' }}>
<FieldLabel field={field} />
{/* Selected Items Grid - 网格显示已选商品 */}
{selectedDetails.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 'var(--base)',
marginBottom: 'var(--base)',
}}
>
{selectedDetails.map((product) => (
<div
key={product.id}
style={{
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--border-radius-m)',
overflow: 'hidden',
background: 'var(--theme-elevation-50)',
position: 'relative',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-400)'
e.currentTarget.style.background = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-150)'
e.currentTarget.style.background = 'var(--theme-elevation-50)'
}}
>
<div
style={{
height: '120px',
overflow: 'hidden',
background: 'var(--theme-elevation-100)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{product.thumbnail ? (
<img
src={product.thumbnail}
alt={product.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)' }}>
</span>
)}
</div>
<div style={{ padding: 'calc(var(--base) / 2)' }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'var(--theme-text)',
}}
>
{product.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
{product.status}
</div>
</div>
<button
type="button"
onClick={() => handleRemove(product.id)}
style={{
position: 'absolute',
top: '4px',
right: '4px',
background: 'var(--theme-elevation-0)',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '50%',
width: '24px',
height: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--theme-text)',
opacity: 0.8,
fontSize: '16px',
lineHeight: '1',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--theme-error-100)'
e.currentTarget.style.color = 'var(--theme-error-600)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.8'
e.currentTarget.style.background = 'var(--theme-elevation-0)'
e.currentTarget.style.color = 'var(--theme-text)'
}}
>
×
</button>
</div>
))}
</div>
)}
{/* Search Input - 搜索输入框 */}
<div>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="搜索商品..."
style={{
width: '100%',
padding: 'calc(var(--base) / 2) var(--base)',
borderRadius: 'var(--border-radius-m)',
border: '1px solid var(--theme-elevation-400)',
background: 'var(--theme-input-bg)',
color: 'var(--theme-text)',
fontFamily: 'inherit',
fontSize: '1rem',
}}
onFocus={() => inputValue && searchProducts(inputValue)}
/>
{/* Horizontal Scroll Results - 横向滚动搜索结果(保留原始格子布局) */}
{inputValue && searchResults.length > 0 && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
display: 'flex',
overflowX: 'auto',
gap: 'var(--base)',
padding: 'calc(var(--base) / 2) 0',
}}
>
{searchResults.map((product) => (
<div
key={product.id}
onClick={() => handleAdd(product)}
style={{
flex: '0 0 160px',
cursor: 'pointer',
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--border-radius-m)',
background: 'var(--theme-elevation-50)',
overflow: 'hidden',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-primary-500)'
e.currentTarget.style.background = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-150)'
e.currentTarget.style.background = 'var(--theme-elevation-50)'
}}
>
<div
style={{
height: '120px',
overflow: 'hidden',
background: 'var(--theme-elevation-100)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{product.thumbnail ? (
<img
src={product.thumbnail}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<span style={{ fontSize: '10px', color: 'var(--theme-elevation-500)' }}>
</span>
)}
</div>
<div style={{ padding: 'calc(var(--base) / 2)' }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'var(--theme-text)',
}}
>
{product.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
ID: {product.medusaId || product.id}
</div>
</div>
</div>
))}
</div>
)}
{/* No results or loading - 加载状态和空结果提示 */}
{inputValue && !isLoading && searchResults.length === 0 && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
padding: 'var(--base)',
textAlign: 'center',
color: 'var(--theme-elevation-500)',
fontSize: '14px',
}}
>
</div>
)}
{isLoading && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
padding: 'var(--base)',
textAlign: 'center',
color: 'var(--theme-elevation-500)',
fontSize: '14px',
}}
>
...
</div>
)}
</div>
{/* Helper text */}
{field.admin?.description && (
<div
style={{
marginTop: 'calc(var(--base) / 4)',
fontSize: '0.875rem',
color: 'var(--theme-elevation-500)',
}}
>
{field.admin.description}
</div>
)}
</div>
)
}

View File

@ -1,6 +1,5 @@
'use client' 'use client'
import React from 'react'
import './product-grid-styler.scss' import './product-grid-styler.scss'
// 这个组件本身不渲染任何内容,只负责在 Products 列表页注入 CSS // 这个组件本身不渲染任何内容,只负责在 Products 列表页注入 CSS

View File

@ -207,7 +207,7 @@ export interface Product {
*/ */
handle?: string | null; handle?: string | null;
/** /**
* *
*/ */
relatedProducts?: (number | Product)[] | null; relatedProducts?: (number | Product)[] | null;
/** /**