From 84f94b53d9ab7ab5a57debe3551e199005841783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=9F=E7=94=B7=E6=97=A5=E8=AE=B0=5Cwww?= Date: Mon, 9 Feb 2026 04:52:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(payload)/admin/importMap.js | 22 +- src/collections/Products.ts | 18 +- .../{products => cells}/ThumbnailCell.tsx | 0 .../{products => fields}/HiddenField.tsx | 0 .../fields/RelatedProductsField.tsx | 377 ++++++++++++++++++ .../ThumbnailAndStatusField.tsx | 0 .../{products => fields}/ThumbnailField.tsx | 0 .../{products => list}/ProductGridStyler.tsx | 1 - .../product-grid-styler.scss | 0 .../{products => sync}/ForceSyncButton.tsx | 0 .../{products => sync}/SyncMedusaButton.tsx | 0 src/payload-types.ts | 2 +- 12 files changed, 401 insertions(+), 19 deletions(-) rename src/components/{products => cells}/ThumbnailCell.tsx (100%) rename src/components/{products => fields}/HiddenField.tsx (100%) create mode 100644 src/components/fields/RelatedProductsField.tsx rename src/components/{products => fields}/ThumbnailAndStatusField.tsx (100%) rename src/components/{products => fields}/ThumbnailField.tsx (100%) rename src/components/{products => list}/ProductGridStyler.tsx (92%) rename src/components/{products => list}/product-grid-styler.scss (100%) rename src/components/{products => sync}/ForceSyncButton.tsx (100%) rename src/components/{products => sync}/SyncMedusaButton.tsx (100%) diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 4505332..6248b04 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,5 +1,5 @@ -import { ThumbnailCell as ThumbnailCell_a0b2acb813359aec894b6644d7c3bfd2 } from '../../../components/products/ThumbnailCell' -import { ThumbnailField as ThumbnailField_ba44ab32cac4d742a03a48fb6960602e } from '../../../components/products/ThumbnailField' +import { ThumbnailCell as ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d } from '../../../components/cells/ThumbnailCell' +import { ThumbnailField as ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6 } from '../../../components/fields/ThumbnailField' import { RscEntryLexicalCell as RscEntryLexicalCell_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' @@ -24,15 +24,16 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93 import { UnderlineFeatureClient as UnderlineFeatureClient_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 { SyncMedusaButton as SyncMedusaButton_8c90663551920f0510ea531726668adc } from '../../../components/products/SyncMedusaButton' -import { default as default_3fd1353246fc8a459244c8dc11f58470 } from '../../../components/products/ProductGridStyler' -import { ForceSyncButton as ForceSyncButton_86f9d5df4f20495427521354d06db618 } from '../../../components/products/ForceSyncButton' +import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField' +import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton' +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 { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - "/components/products/ThumbnailCell#ThumbnailCell": ThumbnailCell_a0b2acb813359aec894b6644d7c3bfd2, - "/components/products/ThumbnailField#ThumbnailField": ThumbnailField_ba44ab32cac4d742a03a48fb6960602e, + "/components/cells/ThumbnailCell#ThumbnailCell": ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d, + "/components/fields/ThumbnailField#ThumbnailField": ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_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#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "/components/products/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_8c90663551920f0510ea531726668adc, - "/components/products/ProductGridStyler#default": default_3fd1353246fc8a459244c8dc11f58470, - "/components/products/ForceSyncButton#ForceSyncButton": ForceSyncButton_86f9d5df4f20495427521354d06db618, + "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, + "/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08, + "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, + "/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/collections/Products.ts b/src/collections/Products.ts index 33fe9e5..b206034 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -33,11 +33,11 @@ export const Products: CollectionConfig = { }, components: { edit: { - PreviewButton: '/components/products/ForceSyncButton#ForceSyncButton', + PreviewButton: '/components/sync/ForceSyncButton#ForceSyncButton', }, beforeListTable: [ - '/components/products/SyncMedusaButton#SyncMedusaButton', - '/components/products/ProductGridStyler', + '/components/sync/SyncMedusaButton#SyncMedusaButton', + '/components/list/ProductGridStyler', ], }, }, @@ -103,8 +103,8 @@ export const Products: CollectionConfig = { readOnly: true, width: '20%', components: { - Cell: '/components/products/ThumbnailCell#ThumbnailCell', - Field: '/components/products/ThumbnailField#ThumbnailField', + Cell: '/components/cells/ThumbnailCell#ThumbnailCell', + Field: '/components/fields/ThumbnailField#ThumbnailField', }, }, }, @@ -131,7 +131,8 @@ export const Products: CollectionConfig = { hasMany: true, options: ['noopener', 'noreferrer', 'nofollow'], 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', hasMany: true, admin: { - description: '相关商品', + description: '相关商品,支持搜索联想', + components: { + Field: '/components/fields/RelatedProductsField#RelatedProductsField', + }, }, }, { diff --git a/src/components/products/ThumbnailCell.tsx b/src/components/cells/ThumbnailCell.tsx similarity index 100% rename from src/components/products/ThumbnailCell.tsx rename to src/components/cells/ThumbnailCell.tsx diff --git a/src/components/products/HiddenField.tsx b/src/components/fields/HiddenField.tsx similarity index 100% rename from src/components/products/HiddenField.tsx rename to src/components/fields/HiddenField.tsx diff --git a/src/components/fields/RelatedProductsField.tsx b/src/components/fields/RelatedProductsField.tsx new file mode 100644 index 0000000..e59da51 --- /dev/null +++ b/src/components/fields/RelatedProductsField.tsx @@ -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({ path }) + const { config } = useConfig() + + const [inputValue, setInputValue] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [selectedDetails, setSelectedDetails] = useState([]) + 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 ( +
+ + + {/* Selected Items Grid - 网格显示已选商品 */} + {selectedDetails.length > 0 && ( +
+ {selectedDetails.map((product) => ( +
{ + 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)' + }} + > +
+ {product.thumbnail ? ( + {product.title} + ) : ( + + 无图片 + + )} +
+
+
+ {product.title} +
+
+ {product.status} +
+
+ +
+ ))} +
+ )} + + {/* Search Input - 搜索输入框 */} +
+ 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 && ( +
+ {searchResults.map((product) => ( +
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)' + }} + > +
+ {product.thumbnail ? ( + + ) : ( + + 无图 + + )} +
+
+
+ {product.title} +
+
+ ID: {product.medusaId || product.id} +
+
+
+ ))} +
+ )} + + {/* No results or loading - 加载状态和空结果提示 */} + {inputValue && !isLoading && searchResults.length === 0 && ( +
+ 未找到匹配的商品 +
+ )} + {isLoading && ( +
+ 搜索中... +
+ )} +
+ + {/* Helper text */} + {field.admin?.description && ( +
+ {field.admin.description} +
+ )} +
+ ) +} diff --git a/src/components/products/ThumbnailAndStatusField.tsx b/src/components/fields/ThumbnailAndStatusField.tsx similarity index 100% rename from src/components/products/ThumbnailAndStatusField.tsx rename to src/components/fields/ThumbnailAndStatusField.tsx diff --git a/src/components/products/ThumbnailField.tsx b/src/components/fields/ThumbnailField.tsx similarity index 100% rename from src/components/products/ThumbnailField.tsx rename to src/components/fields/ThumbnailField.tsx diff --git a/src/components/products/ProductGridStyler.tsx b/src/components/list/ProductGridStyler.tsx similarity index 92% rename from src/components/products/ProductGridStyler.tsx rename to src/components/list/ProductGridStyler.tsx index 56eb3cb..e909e6d 100644 --- a/src/components/products/ProductGridStyler.tsx +++ b/src/components/list/ProductGridStyler.tsx @@ -1,6 +1,5 @@ 'use client' -import React from 'react' import './product-grid-styler.scss' // 这个组件本身不渲染任何内容,只负责在 Products 列表页注入 CSS diff --git a/src/components/products/product-grid-styler.scss b/src/components/list/product-grid-styler.scss similarity index 100% rename from src/components/products/product-grid-styler.scss rename to src/components/list/product-grid-styler.scss diff --git a/src/components/products/ForceSyncButton.tsx b/src/components/sync/ForceSyncButton.tsx similarity index 100% rename from src/components/products/ForceSyncButton.tsx rename to src/components/sync/ForceSyncButton.tsx diff --git a/src/components/products/SyncMedusaButton.tsx b/src/components/sync/SyncMedusaButton.tsx similarity index 100% rename from src/components/products/SyncMedusaButton.tsx rename to src/components/sync/SyncMedusaButton.tsx diff --git a/src/payload-types.ts b/src/payload-types.ts index 4d46818..cffc97e 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -207,7 +207,7 @@ export interface Product { */ handle?: string | null; /** - * 相关商品 + * 相关商品,支持搜索联想 */ relatedProducts?: (number | Product)[] | null; /**