slider 优化
This commit is contained in:
parent
397dcb93ae
commit
029c85f1a3
|
|
@ -66,11 +66,8 @@ export async function GET(req: NextRequest) {
|
|||
totalDocs: preorders.totalDocs + products.totalDocs,
|
||||
limit,
|
||||
page,
|
||||
totalPages: Math.ceil(
|
||||
(preorders.totalDocs + products.totalDocs) / limit,
|
||||
),
|
||||
hasNextPage:
|
||||
page < Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
|
||||
totalPages: Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
|
||||
hasNextPage: page < Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
|
||||
hasPrevPage: page > 1,
|
||||
}
|
||||
} else if (type === 'preorder') {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
import type { RelationshipFieldClientComponent } from 'payload'
|
||||
|
||||
/**
|
||||
* 相关商品字段组件 - 保留原始格子搜索布局,使用 Payload 默认样式
|
||||
* 相关商品字段组件 - 支持多选和单选模式,搜索 products 和 preorder-products
|
||||
* 横向滚动显示搜索结果,支持实时搜索联想
|
||||
*/
|
||||
export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, field }) => {
|
||||
const { value, setValue } = useField<string[]>({ path })
|
||||
const hasMany = field.hasMany !== false // 默认多选
|
||||
const relationTo = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]
|
||||
|
||||
const { value, setValue } = useField<string[] | string>({ path })
|
||||
const { config } = useConfig()
|
||||
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
|
@ -19,7 +22,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
// Fetch details for selected items
|
||||
useEffect(() => {
|
||||
const fetchSelectedDetails = async () => {
|
||||
if (!value || value.length === 0) {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
setSelectedDetails([])
|
||||
return
|
||||
}
|
||||
|
|
@ -27,22 +30,30 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
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)
|
||||
})
|
||||
// Fetch from both collections
|
||||
const allDocs: any[] = []
|
||||
|
||||
const res = await fetch(
|
||||
`${config.routes.api}/products?${searchParams.toString()}&limit=${ids.length}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
for (const collection of relationTo) {
|
||||
const searchParams = new URLSearchParams()
|
||||
ids.forEach((id, index) => {
|
||||
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
|
||||
searchParams.append(`where[id][in][${index}]`, idStr)
|
||||
})
|
||||
|
||||
if (data.docs) {
|
||||
const docsMap = new Map(data.docs.map((d: any) => [d.id, d]))
|
||||
const res = await fetch(
|
||||
`${config.routes.api}/${collection}?${searchParams.toString()}&limit=${ids.length}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
if (data.docs) {
|
||||
allDocs.push(...data.docs.map((d: any) => ({ ...d, _collection: collection })))
|
||||
}
|
||||
}
|
||||
|
||||
if (allDocs.length > 0) {
|
||||
const docsMap = new Map(allDocs.map((d: any) => [d.id, d]))
|
||||
const ordered = ids
|
||||
.map((id) => {
|
||||
const idStr = typeof id === 'object' ? (id as any).id : id
|
||||
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
|
||||
return docsMap.get(idStr)
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
|
@ -56,7 +67,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
fetchSelectedDetails()
|
||||
}, [value, config.routes.api])
|
||||
|
||||
// Search function with debounce
|
||||
// Search function with debounce - search across all related collections
|
||||
const searchProducts = useCallback(
|
||||
async (term: string) => {
|
||||
if (!term) {
|
||||
|
|
@ -66,11 +77,20 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
|
||||
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 || [])
|
||||
const allResults: any[] = []
|
||||
|
||||
// Search in all relationTo collections
|
||||
for (const collection of relationTo) {
|
||||
const res = await fetch(
|
||||
`${config.routes.api}/${collection}?where[title][like]=${encodeURIComponent(term)}&limit=5`,
|
||||
)
|
||||
const data = await res.json()
|
||||
if (data.docs) {
|
||||
allResults.push(...data.docs.map((d: any) => ({ ...d, _collection: collection })))
|
||||
}
|
||||
}
|
||||
|
||||
setSearchResults(allResults)
|
||||
} catch (e) {
|
||||
console.error('Search error:', e)
|
||||
setSearchResults([])
|
||||
|
|
@ -78,7 +98,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[config.routes.api],
|
||||
[config.routes.api, relationTo],
|
||||
)
|
||||
|
||||
// Debounced search
|
||||
|
|
@ -91,26 +111,52 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
}, [inputValue, searchProducts])
|
||||
|
||||
const handleAdd = (product: any) => {
|
||||
if (!hasMany) {
|
||||
// Single select mode
|
||||
const relationValue = {
|
||||
relationTo: product._collection,
|
||||
value: product.id,
|
||||
}
|
||||
setValue(relationValue as any)
|
||||
setSelectedDetails([product])
|
||||
setInputValue('')
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
// Multiple select mode
|
||||
const currentIds = Array.isArray(value) ? value : []
|
||||
const exists = currentIds.some((id: any) => {
|
||||
const idStr = typeof id === 'object' ? id.id : id
|
||||
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
|
||||
return idStr === product.id
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
setValue([...currentIds, product.id])
|
||||
const relationValue = {
|
||||
relationTo: product._collection,
|
||||
value: product.id,
|
||||
}
|
||||
setValue([...currentIds, relationValue] as any)
|
||||
setSelectedDetails((prev) => [...prev, product])
|
||||
}
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const handleRemove = (idToRemove: string) => {
|
||||
if (!hasMany) {
|
||||
// Single select mode
|
||||
setValue(null as any)
|
||||
setSelectedDetails([])
|
||||
return
|
||||
}
|
||||
|
||||
// Multiple select mode
|
||||
const currentIds = Array.isArray(value) ? value : []
|
||||
const newValue = currentIds.filter((id: any) => {
|
||||
const idStr = typeof id === 'object' ? id.id : id
|
||||
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
|
||||
return idStr !== idToRemove
|
||||
})
|
||||
setValue(newValue)
|
||||
setValue(newValue as any)
|
||||
setSelectedDetails((prev) => prev.filter((p) => p.id !== idToRemove))
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +234,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
{product.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
|
||||
{product.status}
|
||||
{product.status} • {product._collection === 'preorder-products' ? '预售' : '常规'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -323,7 +369,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
|
|||
{product.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
|
||||
ID: {product.medusaId || product.id}
|
||||
{product._collection === 'preorder-products' ? '预售' : '常规'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,47 +61,146 @@ export const HeroSlider: GlobalConfig = {
|
|||
zh: '标题',
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Main heading text (e.g., "CHISFLASH GB")',
|
||||
zh: '主标题文字(如:"CHISFLASH GB")',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'textarea',
|
||||
type: 'text',
|
||||
label: {
|
||||
en: 'Subtitle',
|
||||
zh: '副标题',
|
||||
},
|
||||
maxLength: 200,
|
||||
required: true,
|
||||
admin: {
|
||||
rows: 2,
|
||||
description: {
|
||||
en: 'Small uppercase label (e.g., "8-Bit Architecture")',
|
||||
zh: '小标签文字(如:"8-Bit Architecture")',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'desc',
|
||||
type: 'textarea',
|
||||
label: {
|
||||
en: 'Description',
|
||||
zh: '描述',
|
||||
},
|
||||
required: true,
|
||||
maxLength: 300,
|
||||
admin: {
|
||||
rows: 3,
|
||||
description: {
|
||||
en: 'Detailed product description',
|
||||
zh: '产品详细描述',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
label: {
|
||||
en: 'Image',
|
||||
zh: '图片',
|
||||
en: 'Product Image',
|
||||
zh: '产品图片',
|
||||
},
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Recommended: 1920x800px',
|
||||
zh: '推荐尺寸:1920x800px',
|
||||
en: 'High-resolution product image (recommended: PNG with transparency)',
|
||||
zh: '高清产品图片(推荐:带透明背景的 PNG)',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'imageMobile',
|
||||
type: 'upload',
|
||||
name: 'layout',
|
||||
type: 'select',
|
||||
label: {
|
||||
en: 'Mobile Image',
|
||||
zh: '移动端图片',
|
||||
en: 'Layout',
|
||||
zh: '布局',
|
||||
},
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
defaultValue: 'left',
|
||||
options: [
|
||||
{
|
||||
label: {
|
||||
en: 'Left Aligned',
|
||||
zh: '左对齐',
|
||||
},
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
label: {
|
||||
en: 'Right Aligned',
|
||||
zh: '右对齐',
|
||||
},
|
||||
value: 'right',
|
||||
},
|
||||
{
|
||||
label: {
|
||||
en: 'Center Aligned',
|
||||
zh: '居中对齐',
|
||||
},
|
||||
value: 'center',
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Optional. Recommended: 750x1000px',
|
||||
zh: '可选。推荐尺寸:750x1000px',
|
||||
en: 'Text and button alignment position',
|
||||
zh: '文字和按钮对齐位置',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'showFocusCircle',
|
||||
type: 'checkbox',
|
||||
label: {
|
||||
en: 'Show Focus Circle',
|
||||
zh: '显示焦点圆圈',
|
||||
},
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Display subtle focus rings around the product image',
|
||||
zh: '在产品图片周围显示焦点圆圈效果',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
type: 'text',
|
||||
label: {
|
||||
en: 'Price',
|
||||
zh: '价格',
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Product price (e.g., "$45.00")',
|
||||
zh: '产品价格(如:"$45.00")',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
type: 'relationship',
|
||||
label: {
|
||||
en: 'Related Product',
|
||||
zh: '关联商品',
|
||||
},
|
||||
relationTo: ['products', 'preorder-products'],
|
||||
hasMany: false,
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Link this slide to a product (will auto-generate purchase link)',
|
||||
zh: '关联到商品(自动生成购买链接)',
|
||||
},
|
||||
components: {
|
||||
Field: '/components/fields/RelatedProductsField#RelatedProductsField',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -109,14 +208,15 @@ export const HeroSlider: GlobalConfig = {
|
|||
name: 'link',
|
||||
type: 'text',
|
||||
label: {
|
||||
en: 'Link',
|
||||
zh: '链接',
|
||||
en: 'Custom Link (Optional)',
|
||||
zh: '自定义链接(可选)',
|
||||
},
|
||||
admin: {
|
||||
description: {
|
||||
en: 'Where to go when clicked (e.g., /products)',
|
||||
zh: '点击后跳转的链接(如:/products)',
|
||||
en: 'Override with custom link if product is not set',
|
||||
zh: '如未设置商品,可使用自定义链接',
|
||||
},
|
||||
condition: (data) => !data.product,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -952,18 +952,48 @@ export interface HeroSlider {
|
|||
*/
|
||||
slides?:
|
||||
| {
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
/**
|
||||
* Recommended: 1920x800px
|
||||
* Main heading text (e.g., "CHISFLASH GB")
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Small uppercase label (e.g., "8-Bit Architecture")
|
||||
*/
|
||||
subtitle: string;
|
||||
/**
|
||||
* Detailed product description
|
||||
*/
|
||||
desc: string;
|
||||
/**
|
||||
* High-resolution product image (recommended: PNG with transparency)
|
||||
*/
|
||||
image: number | Media;
|
||||
/**
|
||||
* Optional. Recommended: 750x1000px
|
||||
* Text and button alignment position
|
||||
*/
|
||||
imageMobile?: (number | null) | Media;
|
||||
layout: 'left' | 'right' | 'center';
|
||||
/**
|
||||
* Where to go when clicked (e.g., /products)
|
||||
* Display subtle focus rings around the product image
|
||||
*/
|
||||
showFocusCircle?: boolean | null;
|
||||
/**
|
||||
* Product price (e.g., "$45.00")
|
||||
*/
|
||||
price: string;
|
||||
/**
|
||||
* Link this slide to a product (will auto-generate purchase link)
|
||||
*/
|
||||
product?:
|
||||
| ({
|
||||
relationTo: 'products';
|
||||
value: number | Product;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'preorder-products';
|
||||
value: number | PreorderProduct;
|
||||
} | null);
|
||||
/**
|
||||
* Override with custom link if product is not set
|
||||
*/
|
||||
link?: string | null;
|
||||
id?: string | null;
|
||||
|
|
@ -1042,8 +1072,12 @@ export interface HeroSliderSelect<T extends boolean = true> {
|
|||
| {
|
||||
title?: T;
|
||||
subtitle?: T;
|
||||
desc?: T;
|
||||
image?: T;
|
||||
imageMobile?: T;
|
||||
layout?: T;
|
||||
showFocusCircle?: T;
|
||||
price?: T;
|
||||
product?: T;
|
||||
link?: T;
|
||||
id?: T;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,15 +47,7 @@ export default buildConfig({
|
|||
},
|
||||
fallbackLanguage: 'zh',
|
||||
},
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Products,
|
||||
PreorderProducts,
|
||||
Announcements,
|
||||
Articles,
|
||||
Logs,
|
||||
],
|
||||
collections: [Users, Media, Products, PreorderProducts, Announcements, Articles, Logs],
|
||||
globals: [AdminSettings, LogsManager, HeroSlider, ProductRecommendations],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
|
|
|
|||
Loading…
Reference in New Issue