import { useState, useEffect, useRef, useCallback } from 'react' import { Head, Link, router } from '@inertiajs/react' import * as Form from '@radix-ui/react-form' import * as Collapsible from '@radix-ui/react-collapsible' import * as Tabs from '@radix-ui/react-tabs' import { uniqBy } from 'lodash-es' import { ComImageCard } from './ComImageCard' import TagSearchInput from '../TagSearchInput' import DateRangePicker from '../DateRangePicker' // import ComImageStatusSelect from './ComImageStatusSelect' export default function ComImageIndex({ title, description, path, images, pagination, filters, auth, backLink, actionButton, showUserName = false, allTags = {} }) { const [allImages, setAllImages] = useState(images || []) const [page, setPage] = useState(pagination?.current_page || 1) const [maxVisiblePage, setMaxVisiblePage] = useState(0) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(pagination?.current_page < pagination?.total_pages) const [filterCount, setFilterCount] = useState(0) const [loadedPageMap, setLoadedPageMap] = useState({}) const bottomObserver = useRef() const [searchParams, setSearchParams] = useState(() => { // 尝试从 localStorage 获取保存的搜索参数 const savedParams = localStorage.getItem(`img_manager_search_params_${path}`); if (savedParams) { try { const parsedParams = JSON.parse(savedParams); return { title_cont: parsedParams.title_cont || '', tags_name_cont: parsedParams.tags_name_cont || '', tags_id_in: parsedParams.tags_id_in || [], created_at_gteq: parsedParams.created_at_gteq || '', created_at_lteq: parsedParams.created_at_lteq || '', status_eq: parsedParams.status_eq || '', }; } catch (e) { console.error('Error parsing saved search params:', e); } } // 如果没有保存的参数或解析出错,使用传入的 filters return { title_cont: filters.title_cont || '', tags_name_cont: filters.tags_name_cont || '', tags_id_in: filters.tags_id_in || [], created_at_gteq: filters.created_at_gteq || '', created_at_lteq: filters.created_at_lteq || '', status_eq: filters.status_eq || '', }; }) const [isFilterOpen, setIsFilterOpen] = useState(false) const handleInputChange = (e) => { const { name, value } = e.target setSearchParams({ ...searchParams, [name]: value }) } const handleTagsChange = (value) => { setSearchParams({ ...searchParams, tags_id_in: value }) } const handleStatusChange = (value) => { const statusValue = value === 'all' ? '' : value setSearchParams({ ...searchParams, status_eq: statusValue }) } const handleSearch = (e) => { e.preventDefault() setLoadedPageMap({}) setMaxVisiblePage(0) setAllImages([]) loadPage(1) } const loadPage = async (currentPage) => { if (loadedPageMap[currentPage]) { return loadedPageMap[currentPage]; } try { setLoading(true) await router.visit( path, { method: 'get', data: { page: currentPage, q: { title_cont: searchParams.title_cont, tags_name_cont: searchParams.tags_name_cont, tags_id_in: searchParams.tags_id_in, created_at_gteq: searchParams.created_at_gteq ? searchParams.created_at_gteq : '', created_at_lteq: searchParams.created_at_lteq ? searchParams.created_at_lteq : '', status_eq: searchParams.status_eq } }, preserveState: true, preserveScroll: true, replace: true, only: ['images', 'pagination'], onSuccess: (response) => { setPage(response.props.pagination.current_page) setHasMore(maxVisiblePage < response.props.pagination.total_pages) setFilterCount(Object.entries(searchParams).filter(([k, v]) => k !== 'status_eq' && (Array.isArray(v) ? v.length : !!v)).length) setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images}) setLoading(false) }, onError: () => { console.error('Error loading images') setLoading(false) } }) } catch (error) { console.error('Error loading images:', error) setLoading(false) } } const setVisibleImages = async (expectPage) =>{ if (!loadedPageMap[expectPage]) { await loadPage(expectPage) } const imagesPage = Math.max(expectPage, maxVisiblePage) const images = [] for (let i = 1; i <= imagesPage; i++) { if (loadedPageMap[i]) { images.push(...loadedPageMap[i]) } } setAllImages( uniqBy(images, (image) => image.id) ) setMaxVisiblePage(expectPage) } // 当搜索参数变化时,更新 localStorage useEffect(() => { localStorage.setItem(`img_manager_search_params_${path}`, JSON.stringify(searchParams)); }, [searchParams, path]); // 初始化时自动执行搜索,使用保存的搜索参数 useEffect(() => { // 如果有保存的搜索参数且不是初始加载,则执行搜索 const savedParams = localStorage.getItem(`img_manager_search_params_${path}`); if (savedParams && Object.keys(loadedPageMap).length === 0) { loadPage(1); } }, []); useEffect(() => { if (page >= 1 && !loadedPageMap[1]) { setVisibleImages(1) } else { setVisibleImages(page) } }, [page, loadedPageMap, maxVisiblePage]) const loadMoreImages = useCallback(() => { loadPage(page + 1) }, [page, loadPage]) // Observer for the last image (bottom of the page) const lastImageElementRef = useCallback(node => { if (loading) return if (bottomObserver.current) bottomObserver.current.disconnect() bottomObserver.current = new IntersectionObserver(entries => { if (entries[0].isIntersecting && hasMore) { loadMoreImages() } }, { threshold: 0.1, rootMargin: '100px' }) if (node) bottomObserver.current.observe(node) }, [loading, hasMore, loadMoreImages]) return ( <> <div className="px-4 py-5 sm:px-6 flex justify-between items-center"> <div> {backLink && ( <Link href={backLink.url} className="inline-flex items-center mb-3 text-sm font-medium text-indigo-600 hover:text-indigo-500" > <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /> </svg> {backLink.label} </Link> )} <h1 className="text-2xl font-bold text-gray-900">{title}</h1> <p className="mt-1 max-w-2xl text-sm text-gray-500"> {description} </p> </div> <div className="flex flex-col space-y-0 sm:flex-row"> <button onClick={() => setIsFilterOpen(!isFilterOpen)} className={`inline-flex items-center justify-center mb-2 ml-2 px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${filterCount ? '!bg-indigo-700 !text-white' : ''}`} > <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /> </svg> {isFilterOpen ? '隐藏过滤' : '显示过滤'}{ filterCount ? `(${filterCount})` : ''} </button> {actionButton ? ( actionButton ) : auth && ( <Link href={`${path}/new`} className="inline-flex items-center justify-center mb-2 ml-2 px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 上传新图片 </Link> )} </div> </div> <div className="px-4 sm:px-6 pb-5"> <Tabs.Root defaultValue="all" className="mb-6" onValueChange={(value) => { const statusValue = value === 'all' ? '' : value setSearchParams({ ...searchParams, status_eq: statusValue }) setLoadedPageMap({}) setMaxVisiblePage(0) setAllImages([]) loadPage(1) }} > <Tabs.List className="flex border-b border-gray-200 mb-4"> <Tabs.Trigger value="all" className={`px-4 py-2 text-sm font-medium ${searchParams.status_eq === '' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300'}`} > 全部 </Tabs.Trigger> <Tabs.Trigger value="pending" className={`px-4 py-2 text-sm font-medium ${searchParams.status_eq === 'pending' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300'}`} > 待审核 </Tabs.Trigger> <Tabs.Trigger value="approved" className={`px-4 py-2 text-sm font-medium ${searchParams.status_eq === 'approved' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300'}`} > 已完成 </Tabs.Trigger> <Tabs.Trigger value="rejected" className={`px-4 py-2 text-sm font-medium ${searchParams.status_eq === 'rejected' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300'}`} > 已拒绝 </Tabs.Trigger> </Tabs.List> </Tabs.Root> <Collapsible.Root open={isFilterOpen} onOpenChange={setIsFilterOpen} className="w-full"> <Collapsible.Content className="mb-6 mt-4 overflow-hidden"> <Form.Root className="space-y-6 border border-gray-200 rounded-md p-4 bg-gray-50" onSubmit={handleSearch} > <div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> <div className="sm:col-span-3"> <Form.Field name="title_cont"> <Form.Label className="block text-sm font-medium text-gray-700"> 名称 </Form.Label> <Form.Control asChild> <input type="text" name="title_cont" value={searchParams.title_cont} onChange={handleInputChange} className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" placeholder="按名称搜索" /> </Form.Control> </Form.Field> </div> <div className="sm:col-span-3"> <Form.Field name="tags_name_cont"> <Form.Label className="block text-sm font-medium text-gray-700"> 标签名称 </Form.Label> <Form.Control asChild> <input type="text" name="tags_name_cont" value={searchParams.tags_name_cont} onChange={handleInputChange} className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" placeholder="按标签名称搜索" /> </Form.Control> </Form.Field> </div> <div className="sm:col-span-3"> <Form.Field name="tags_id_in"> <Form.Label className="block text-sm font-medium text-gray-700"> 选择标签 </Form.Label> <Form.Control asChild> <TagSearchInput value={searchParams.tags_id_in} onChange={handleTagsChange} allTags={allTags} placeholder="选择标签进行筛选" /> </Form.Control> </Form.Field> </div> {/* <div className="sm:col-span-3"> <Form.Field name="status_eq"> <Form.Label className="block text-sm font-medium text-gray-700"> 状态 </Form.Label> <Form.Control asChild> <ComImageStatusSelect value={searchParams.status_eq || "all"} onChange={handleStatusChange} /> </Form.Control> </Form.Field> </div> */} <div className="sm:col-span-3"> <Form.Field name="date_range"> <Form.Label className="block text-sm font-medium text-gray-700"> 日期范围 </Form.Label> <Form.Control asChild> <DateRangePicker startDate={searchParams.created_at_gteq} endDate={searchParams.created_at_lteq} onChange={(startDate, endDate) => { const newParams = { ...searchParams, created_at_gteq: startDate, created_at_lteq: endDate } setSearchParams(newParams) }} /> </Form.Control> </Form.Field> </div> </div> <div className="flex justify-end space-x-2"> <button className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" onClick={() => { setSearchParams({ title_cont: '', tags_name_cont: '', tags_id_in: [], created_at_gteq: '', created_at_lteq: '', status_eq: '', }) }} > 清空 </button> <button type="submit" className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 搜索 </button> </div> </Form.Root> </Collapsible.Content> </Collapsible.Root> {allImages.length > 0 ? ( <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 mt-6"> {loading && ( <div className="col-span-full text-center py-4"> <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status"> <span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"> 加载中... </span> </div> </div> )} {allImages.map((image, index) => ( <ComImageCard key={image.id} ref={index === allImages.length - 1 ? lastImageElementRef : null} image={image} path={path} showUserName={showUserName} // showActions={showActions} /> ))} </div> ) : ( <div className="text-center py-12"> {loading && allImages.length === 0 ? ( <div className="flex justify-center items-center"> <svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> </div> ) : ( <div> <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> </svg> <h3 className="mt-2 text-sm font-medium text-gray-900"> 暂无图片 </h3> <p className="mt-1 text-sm text-gray-500"> 开始上传图片 </p> <div className="mt-6"> <Link href={`${path}/new`} className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 上传新图片 </Link> </div> </div> )} </div> )} {loading && allImages.length > 0 && ( <div className="flex justify-center mt-6 pb-6"> <svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> </div> )} { !loading && allImages.length > 0 && !hasMore && (<div className="flex justify-center mt-6 pb-6">没有更多了</div>) } </div> </> ) }