slider 优化

This commit is contained in:
龟男日记\www 2026-02-15 18:56:35 +08:00
parent 397dcb93ae
commit 029c85f1a3
5 changed files with 236 additions and 67 deletions

View File

@ -66,11 +66,8 @@ export async function GET(req: NextRequest) {
totalDocs: preorders.totalDocs + products.totalDocs, totalDocs: preorders.totalDocs + products.totalDocs,
limit, limit,
page, page,
totalPages: Math.ceil( totalPages: Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
(preorders.totalDocs + products.totalDocs) / limit, hasNextPage: page < Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
),
hasNextPage:
page < Math.ceil((preorders.totalDocs + products.totalDocs) / limit),
hasPrevPage: page > 1, hasPrevPage: page > 1,
} }
} else if (type === 'preorder') { } else if (type === 'preorder') {

View File

@ -4,11 +4,14 @@ import { useState, useEffect, useCallback } from 'react'
import type { RelationshipFieldClientComponent } from 'payload' import type { RelationshipFieldClientComponent } from 'payload'
/** /**
* - 使 Payload * - products preorder-products
* *
*/ */
export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, field }) => { 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 { config } = useConfig()
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
@ -19,7 +22,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
// Fetch details for selected items // Fetch details for selected items
useEffect(() => { useEffect(() => {
const fetchSelectedDetails = async () => { const fetchSelectedDetails = async () => {
if (!value || value.length === 0) { if (!value || (Array.isArray(value) && value.length === 0)) {
setSelectedDetails([]) setSelectedDetails([])
return return
} }
@ -27,22 +30,30 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
const ids = Array.isArray(value) ? value : [value as unknown as string] const ids = Array.isArray(value) ? value : [value as unknown as string]
try { try {
const searchParams = new URLSearchParams() // Fetch from both collections
ids.forEach((id, index) => { const allDocs: any[] = []
const idStr = typeof id === 'object' ? (id as any).id : id
searchParams.append(`where[id][in][${index}]`, idStr)
})
const res = await fetch( for (const collection of relationTo) {
`${config.routes.api}/products?${searchParams.toString()}&limit=${ids.length}`, const searchParams = new URLSearchParams()
) ids.forEach((id, index) => {
const data = await res.json() 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 res = await fetch(
const docsMap = new Map(data.docs.map((d: any) => [d.id, d])) `${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 const ordered = ids
.map((id) => { .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) return docsMap.get(idStr)
}) })
.filter(Boolean) .filter(Boolean)
@ -56,7 +67,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
fetchSelectedDetails() fetchSelectedDetails()
}, [value, config.routes.api]) }, [value, config.routes.api])
// Search function with debounce // Search function with debounce - search across all related collections
const searchProducts = useCallback( const searchProducts = useCallback(
async (term: string) => { async (term: string) => {
if (!term) { if (!term) {
@ -66,11 +77,20 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
setIsLoading(true) setIsLoading(true)
try { try {
const res = await fetch( const allResults: any[] = []
`${config.routes.api}/products?where[title][like]=${encodeURIComponent(term)}&limit=10`,
) // Search in all relationTo collections
const data = await res.json() for (const collection of relationTo) {
setSearchResults(data.docs || []) 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) { } catch (e) {
console.error('Search error:', e) console.error('Search error:', e)
setSearchResults([]) setSearchResults([])
@ -78,7 +98,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
setIsLoading(false) setIsLoading(false)
} }
}, },
[config.routes.api], [config.routes.api, relationTo],
) )
// Debounced search // Debounced search
@ -91,26 +111,52 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
}, [inputValue, searchProducts]) }, [inputValue, searchProducts])
const handleAdd = (product: any) => { 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 currentIds = Array.isArray(value) ? value : []
const exists = currentIds.some((id: any) => { 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 return idStr === product.id
}) })
if (!exists) { if (!exists) {
setValue([...currentIds, product.id]) const relationValue = {
relationTo: product._collection,
value: product.id,
}
setValue([...currentIds, relationValue] as any)
setSelectedDetails((prev) => [...prev, product]) setSelectedDetails((prev) => [...prev, product])
} }
setInputValue('') setInputValue('')
} }
const handleRemove = (idToRemove: string) => { 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 currentIds = Array.isArray(value) ? value : []
const newValue = currentIds.filter((id: any) => { 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 return idStr !== idToRemove
}) })
setValue(newValue) setValue(newValue as any)
setSelectedDetails((prev) => prev.filter((p) => p.id !== idToRemove)) setSelectedDetails((prev) => prev.filter((p) => p.id !== idToRemove))
} }
@ -188,7 +234,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
{product.title} {product.title}
</div> </div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}> <div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
{product.status} {product.status} {product._collection === 'preorder-products' ? '预售' : '常规'}
</div> </div>
</div> </div>
<button <button
@ -323,7 +369,7 @@ export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, f
{product.title} {product.title}
</div> </div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}> <div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
ID: {product.medusaId || product.id} {product._collection === 'preorder-products' ? '预售' : '常规'}
</div> </div>
</div> </div>
</div> </div>

View File

@ -61,47 +61,146 @@ export const HeroSlider: GlobalConfig = {
zh: '标题', zh: '标题',
}, },
required: true, required: true,
admin: {
description: {
en: 'Main heading text (e.g., "CHISFLASH GB")',
zh: '主标题文字(如:"CHISFLASH GB"',
},
},
}, },
{ {
name: 'subtitle', name: 'subtitle',
type: 'textarea', type: 'text',
label: { label: {
en: 'Subtitle', en: 'Subtitle',
zh: '副标题', zh: '副标题',
}, },
maxLength: 200, required: true,
admin: { 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', name: 'image',
type: 'upload', type: 'upload',
label: { label: {
en: 'Image', en: 'Product Image',
zh: '图片', zh: '产品图片',
}, },
relationTo: 'media', relationTo: 'media',
required: true, required: true,
admin: { admin: {
description: { description: {
en: 'Recommended: 1920x800px', en: 'High-resolution product image (recommended: PNG with transparency)',
zh: '推荐尺寸1920x800px', zh: '高清产品图片(推荐:带透明背景的 PNG',
}, },
}, },
}, },
{ {
name: 'imageMobile', name: 'layout',
type: 'upload', type: 'select',
label: { label: {
en: 'Mobile Image', en: 'Layout',
zh: '移动端图片', 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: { admin: {
description: { description: {
en: 'Optional. Recommended: 750x1000px', en: 'Text and button alignment position',
zh: '可选。推荐尺寸750x1000px', 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', name: 'link',
type: 'text', type: 'text',
label: { label: {
en: 'Link', en: 'Custom Link (Optional)',
zh: '链接', zh: '自定义链接(可选)',
}, },
admin: { admin: {
description: { description: {
en: 'Where to go when clicked (e.g., /products)', en: 'Override with custom link if product is not set',
zh: '点击后跳转的链接(如:/products', zh: '如未设置商品,可使用自定义链接',
}, },
condition: (data) => !data.product,
}, },
}, },
], ],

View File

@ -952,18 +952,48 @@ export interface HeroSlider {
*/ */
slides?: 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; 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; link?: string | null;
id?: string | null; id?: string | null;
@ -1042,8 +1072,12 @@ export interface HeroSliderSelect<T extends boolean = true> {
| { | {
title?: T; title?: T;
subtitle?: T; subtitle?: T;
desc?: T;
image?: T; image?: T;
imageMobile?: T; layout?: T;
showFocusCircle?: T;
price?: T;
product?: T;
link?: T; link?: T;
id?: T; id?: T;
}; };

View File

@ -47,15 +47,7 @@ export default buildConfig({
}, },
fallbackLanguage: 'zh', fallbackLanguage: 'zh',
}, },
collections: [ collections: [Users, Media, Products, PreorderProducts, Announcements, Articles, Logs],
Users,
Media,
Products,
PreorderProducts,
Announcements,
Articles,
Logs,
],
globals: [AdminSettings, LogsManager, HeroSlider, ProductRecommendations], globals: [AdminSettings, LogsManager, HeroSlider, ProductRecommendations],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '', secret: process.env.PAYLOAD_SECRET || '',