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,
|
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') {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
// Fetch from both collections
|
||||||
|
const allDocs: any[] = []
|
||||||
|
|
||||||
|
for (const collection of relationTo) {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
ids.forEach((id, index) => {
|
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)
|
searchParams.append(`where[id][in][${index}]`, idStr)
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await fetch(
|
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()
|
const data = await res.json()
|
||||||
|
|
||||||
if (data.docs) {
|
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
|
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 allResults: any[] = []
|
||||||
|
|
||||||
|
// Search in all relationTo collections
|
||||||
|
for (const collection of relationTo) {
|
||||||
const res = await fetch(
|
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()
|
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) {
|
} 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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue