262 lines
11 KiB
JavaScript

"use client";
import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Grid } from "swiper/modules";
import Link from "next/link";
import Image from "next/image";
import PropTypes from "prop-types";
// This is now a "dumb" component. It receives all data via props and is only
// responsible for rendering the UI. All data fetching logic has been moved
// to the parent server component.
export default function SwiperItemsUI({ data, items, ...props }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// This effect runs only on the client, after the component has mounted.
setIsMounted(true);
}, []);
// Field mapping from data, with props as fallback.
const title = data?.title || props.title;
const subtitle = data?.subtitle || props.subtitle;
const category_slug = data?.t3 || props.category_slug || "";
const columns = data?.t5 || props.columns || 4;
const rows = (data?.p1 && !isNaN(Number(data.p1))) ? Number(data.p1) : (props.rows || 1);
// Loading and error states are now handled by the server component and React Suspense.
// We only need to handle the case where there are no items to show.
if (!items || items.length === 0) {
return (
<div className="w-full max-w-full mx-auto my-[150px] px-4 sm:px-6 md:px-8 xl:w-10/12 xl:px-0">
{(title || subtitle) && (
<div className="relative z-20 text-center mb-10 select-text">
{title && <h2 className="text-3xl font-bold mb-2">{title}</h2>}
{subtitle && <p className="text-lg text-gray-500">{subtitle}</p>}
</div>
)}
<div className="text-center py-12 text-gray-400">暂无数据</div>
</div>
);
}
// Helper functions for rendering. They are pure and belong with the UI.
function renderCardImage(post, idx) {
// 多图轮播
if (Array.isArray(post.images) && post.images.length > 1) {
return (
<Swiper
className="swiper"
modules={[Navigation, Pagination]}
pagination={{ clickable: true, el: `.spdb${idx}` }}
navigation={{ prevEl: `.snbpb${idx}`, nextEl: `.snbnb${idx}` }}
>
{post.images.map((img, i) => (
<SwiperSlide key={i} className="swiper-slide">
<Image
className="!transition-all !duration-[0.35s] !ease-in-out group-hover:scale-105 w-full h-auto"
alt={post.title || "image"}
src={img}
width={960}
height={600}
/>
</SwiperSlide>
))}
</Swiper>
);
}
// 视频
if (post.video_src || post.videoId) {
if (post.video_src && (post.video_src.endsWith('.mp4') || post.video_src.startsWith('/files/'))) {
return (
<video controls className="w-full h-56 rounded-xl object-cover">
<source src={post.video_src} type="video/mp4" />
您的浏览器不支持视频播放
</video>
);
}
const vid = post.videoId || (post.video_src && post.video_src.includes('youtube') ? post.video_src.split('embed/')[1] : null);
if (vid) {
return (
<iframe
className="w-full h-56 rounded-xl"
src={`https://www.youtube.com/embed/${vid}`}
title="YouTube video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
}
// 单图
const img = post.image || (Array.isArray(post.images) && post.images[0]);
if (img) {
return (
<Image
className="!transition-all !duration-[0.35s] !ease-in-out group-hover:scale-105 w-full h-auto"
alt={post.title || "image"}
src={img}
width={960}
height={600}
/>
);
}
return null;
}
function getSummary(text, maxLen = 58) {
if (!text) return "";
// 中文:直接按字符截断
if (/[\u4e00-\u9fa5]/.test(text)) {
return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
}
// 英文:按字符截断,但不截断单词
if (text.length <= maxLen) return text;
let cut = text.slice(0, maxLen);
// 如果最后一个字符不是空格,向前找到最近的空格
if (!/\s/.test(text[maxLen])) {
const lastSpace = cut.lastIndexOf(" ");
if (lastSpace > 0) cut = cut.slice(0, lastSpace);
}
return cut + "...";
}
// 日期格式化函数,支持自定义是否显示时分秒
function formatDate(dateStr, options = {}) {
if (!dateStr) return "";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const { showTime = false } = options;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
let result = `${yyyy}-${mm}-${dd}`;
if (showTime) {
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
result += ` ${hh}:${min}:${ss}`;
}
return result;
}
// By default, the component is rendered with opacity-0.
// Once the client-side JS loads and the component mounts, isMounted becomes true,
// and the component smoothly fades in, preventing any layout flash.
const containerClasses = `
w-full max-w-full mx-auto my-[150px] px-4 sm:px-6 md:px-8 xl:w-10/12 xl:px-0
transition-opacity duration-500 ease-in-out
${isMounted ? 'opacity-100' : 'opacity-0'}
`;
return (
<div className={containerClasses}>
{/* 新增模块标题和副标题显示 */}
{(title || subtitle) && (
<div className="relative z-20 text-center mb-10 select-text">
{title && <h2 className="text-3xl font-bold mb-2">{title}</h2>}
{subtitle && <p className="text-lg text-gray-500">{subtitle}</p>}
</div>
)}
<div className="swiper-container blog classic-view !mb-[7rem] xl:!mb-[10rem] lg:!mb-[10rem] md:!mb-[10rem]">
<div className="relative">
<Swiper
className="swiper w-full"
modules={[Navigation, Pagination, Grid]}
spaceBetween={16}
slidesPerView={columns}
slidesPerGroup={columns * rows}
grid={{ rows }}
pagination={{ clickable: true, el: ".spdb15" }}
navigation={{
prevEl: '.swiper-nav-prev-swiperitems',
nextEl: '.swiper-nav-next-swiperitems'
}}
breakpoints={{
0: { slidesPerView: 1, slidesPerGroup: 1, grid: { rows: 1 } },
575: { slidesPerView: 1, slidesPerGroup: 1, grid: { rows: 1 } },
768: { slidesPerView: 2, slidesPerGroup: 2, grid: { rows: rows > 1 ? 2 : 1 } },
992: { slidesPerView: columns, slidesPerGroup: columns * rows, grid: { rows } },
}}
>
{items.map((post, idx) => (
<SwiperSlide key={post.slug || post.id || idx} className="swiper-slide">
<article className="post !mb-8">
<div className="card">
{/* 图片/轮播/视频部分 */}
<figure className="card-img-top overlay overlay-1 hover-scale group">
<Link href={post.slug ? `/${category_slug}/${post.slug}` : "#"} className="block relative">
{renderCardImage(post, idx)}
<span className="bg"></span>
<figcaption className="group-hover:opacity-100 absolute w-full h-full opacity-0 text-center px-4 py-3 inset-0 z-[5] pointer-events-none p-2">
<h5 className="from-top !mb-0 absolute w-full translate-y-[-80%] p-[.75rem_1rem] left-0 top-2/4">
查看详情
</h5>
</figcaption>
</Link>
</figure>
{/* 文字内容 */}
<div className="card-body flex-[1_1_auto] p-[40px] xl:!p-[2rem_2.5rem_1.25rem] lg:!p-[2rem_2.5rem_1.25rem] md:!p-[2rem_2.5rem_1.25rem] max-md:pb-4">
<div className="post-header !mb-[.9rem]">
<div className="inline-flex !mb-[.4rem] uppercase !tracking-[0.02rem] text-[0.7rem] font-bold !text-[#aab0bc] relative align-top !pl-[1.4rem] before:content-[''] before:absolute before:inline-block before:translate-y-[-60%] before:w-3 before:h-[0.05rem] before:left-0 before:top-2/4 before:bg-[#1fc76f]">
{post.additional_title || ""}
</div>
<h2 className="post-title !mt-1 !leading-[1.35] !mb-0 text-base md:!text-[0.85rem]">
<Link
className="!text-[#333333] hover:!text-[#1fc76f]"
href={post.slug ? `/${category_slug}/${post.slug}` : "#"}
>
{post.title}
</Link>
</h2>
</div>
<div className="!relative !text-[0.7rem]">
<p>{getSummary(post.subtitle)}</p>
</div>
</div>
</div>
</article>
</SwiperSlide>
))}
</Swiper>
{/* 美化后的导航按钮 */}
<button
className="swiper-nav-prev-swiperitems absolute left-[-32px] top-1/2 z-20 -translate-y-1/2 w-10 h-10 flex items-center justify-center !rounded-full border-2 border-[#e5e7eb] bg-transparent transition-all duration-200 hover:border-[#1fc76f] group"
style={{ color: '#b0b7c3' }}
aria-label="上一页"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M15.5 19L9.5 12L15.5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="transition-all duration-200 group-hover:stroke-[#1fc76f]"/>
</svg>
</button>
<button
className="swiper-nav-next-swiperitems absolute right-[-32px] top-1/2 z-20 -translate-y-1/2 w-10 h-10 flex items-center justify-center !rounded-full border-2 border-[#e5e7eb] bg-transparent transition-all duration-200 hover:border-[#1fc76f] group"
style={{ color: '#b0b7c3' }}
aria-label="下一页"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M8.5 5L14.5 12L8.5 19" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="transition-all duration-200 group-hover:stroke-[#1fc76f]"/>
</svg>
</button>
</div>
<div className="swiper-controls">
<div className="swiper-pagination spdb15"></div>
</div>
</div>
</div>
);
}
SwiperItemsUI.propTypes = {
data: PropTypes.object,
items: PropTypes.array,
pagetype: PropTypes.string,
category: PropTypes.string,
count: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
]),
columns: PropTypes.number,
category_slug: PropTypes.string,
rows: PropTypes.number,
};