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,
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') {

View File

@ -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 {
// Fetch from both collections
const allDocs: any[] = []
for (const collection of relationTo) {
const searchParams = new URLSearchParams()
ids.forEach((id, index) => {
const idStr = typeof id === 'object' ? (id as any).id : id
const idStr = typeof id === 'object' ? (id as any).value || (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}`,
`${config.routes.api}/${collection}?${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]))
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 allResults: any[] = []
// Search in all relationTo collections
for (const collection of relationTo) {
const res = await fetch(
`${config.routes.api}/products?where[title][like]=${encodeURIComponent(term)}&limit=10`,
`${config.routes.api}/${collection}?where[title][like]=${encodeURIComponent(term)}&limit=5`,
)
const data = await res.json()
setSearchResults(data.docs || [])
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>

View File

@ -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,
},
},
],

View File

@ -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;
};

View File

@ -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 || '',