重构SwiperItems组件为服务端客户端分层版
This commit is contained in:
parent
4089d1a448
commit
b3cabf7293
@ -1,10 +1,10 @@
|
|||||||
import Banner from "@/components/banner/Banner";
|
import Banner from "@/components/banner/Banner";
|
||||||
import Category from "@/components/sidebar/Category";
|
import Category from "@/components/sidebar/Category";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import DynamicListPage from "@/components/common/DynamicListPage";
|
import DynamicListPage from "@/components/common/DynamicListPage";
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { getPageData, getAllSlugs } from "@/utlis/data";
|
import { getPageData, getAllSlugs } from "@/utils/data";
|
||||||
|
|
||||||
const LoadingSpinner = () => (
|
const LoadingSpinner = () => (
|
||||||
<div className="flex justify-center items-center p-8">
|
<div className="flex justify-center items-center p-8">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Banner from "@/components/banner/Banner";
|
import Banner from "@/components/banner/Banner";
|
||||||
import Category from "@/components/sidebar/Category";
|
import Category from "@/components/sidebar/Category";
|
||||||
import Pagination1 from "@/components/common/Pagination1";
|
import Pagination1 from "@/components/common/Pagination1";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import ListPageTemplate from "@/components/common/ListPageTemplate";
|
import ListPageTemplate from "@/components/common/ListPageTemplate";
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Banner from "@/components/banner/Banner";
|
|||||||
import Category from "@/components/sidebar/Category";
|
import Category from "@/components/sidebar/Category";
|
||||||
import Gallery from "@/components/common/Gallery";
|
import Gallery from "@/components/common/Gallery";
|
||||||
import Pagination1 from "@/components/common/Pagination1";
|
import Pagination1 from "@/components/common/Pagination1";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import ListPageTemplate from "@/components/common/ListPageTemplate";
|
import ListPageTemplate from "@/components/common/ListPageTemplate";
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import "../public/assets/style.css";
|
|||||||
import "photoswipe/dist/photoswipe.css";
|
import "photoswipe/dist/photoswipe.css";
|
||||||
import iTooltip from "itooltip";
|
import iTooltip from "itooltip";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import scrollQue from "../utlis/scrollCue.min.js";
|
import scrollQue from "../utils/scrollCue.min.js";
|
||||||
import Context from "@/context/Context";
|
import Context from "@/context/Context";
|
||||||
import ProgressWrap from "@/components/common/ProgressWrap";
|
import ProgressWrap from "@/components/common/ProgressWrap";
|
||||||
import initPlayer from "@/utlis/initPlayer";
|
import initPlayer from "@/utils/initPlayer";
|
||||||
import SearchModal from "@/components/modals/SearchModal";
|
import SearchModal from "@/components/modals/SearchModal";
|
||||||
import InfoModal from "@/components/modals/InfoModal";
|
import InfoModal from "@/components/modals/InfoModal";
|
||||||
import Header from "@/components/headers/Header";
|
import Header from "@/components/headers/Header";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Hero from "@/components/homes/home-15/Hero";
|
import Hero from "@/components/homes/home-15/Hero";
|
||||||
import SwiperItems from "@/components/homes/home-15/SwiperItems";
|
import SwiperItems from "@/components/homes/home-15/SwiperItems";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import Banner from "@/components/banner/Banner";
|
import Banner from "@/components/banner/Banner";
|
||||||
import Category from "@/components/sidebar/Category";
|
import Category from "@/components/sidebar/Category";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import DynamicListPage from "@/components/common/DynamicListPage";
|
import DynamicListPage from "@/components/common/DynamicListPage";
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import SocialLinks from "@/components/contact/SocialLinks";
|
import SocialLinks from "@/components/contact/SocialLinks";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import MenuList from "./MenuList";
|
import MenuList from "./MenuList";
|
||||||
import Contact from "@/components/contact/Contact";
|
import Contact from "@/components/contact/Contact";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import SocialLinks from "@/components/contact/SocialLinks";
|
import SocialLinks from "@/components/contact/SocialLinks";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
import MenuList from "./MenuList";
|
import MenuList from "./MenuList";
|
||||||
import Contact from "@/components/contact/Contact";
|
import Contact from "@/components/contact/Contact";
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import LoginButton from "./LoginButton";
|
import LoginButton from "./LoginButton";
|
||||||
import SocialLinks from "../contact/SocialLinks";
|
import SocialLinks from "../contact/SocialLinks";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
|
|
||||||
export default function Header15() {
|
export default function Header15() {
|
||||||
const [siteSettings, setSiteSettings] = useState({});
|
const [siteSettings, setSiteSettings] = useState({});
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import LanguageSelect from "./LanguageSelect";
|
import LanguageSelect from "./LanguageSelect";
|
||||||
import SocialLinks from "../contact/SocialLinks";
|
import SocialLinks from "../contact/SocialLinks";
|
||||||
import { getSiteSettings } from "@/utlis/siteSettings";
|
import { getSiteSettings } from "@/utils/siteSettings";
|
||||||
|
|
||||||
export default function Header15() {
|
export default function Header15() {
|
||||||
const [siteSettings, setSiteSettings] = useState({});
|
const [siteSettings, setSiteSettings] = useState({});
|
||||||
|
|||||||
@ -1,282 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useState } 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";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export default function SwiperItems(props) {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// 统一字段映射,后台优先,props兜底
|
|
||||||
const title = data?.title || props.title;
|
|
||||||
const subtitle = data?.subtitle || props.subtitle;
|
|
||||||
const pagetype = data?.t1 || props.pagetype || "";
|
|
||||||
const category = data?.t2 || props.category || "";
|
|
||||||
const category_slug = data?.t3 || props.category_slug || "";
|
|
||||||
const count = data?.t4 || props.count || 8;
|
|
||||||
const columns = data?.t5 || props.columns || 4;
|
|
||||||
const rows = (data?.p1 && !isNaN(Number(data.p1))) ? Number(data.p1) : (props.rows || 1);
|
|
||||||
const button_text = data?.button_text || props.button_text;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchComponentData() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await axios.get("/api/get-component-data", {
|
|
||||||
params: { component_name: "SwiperItems" },
|
|
||||||
});
|
|
||||||
setData(res.data.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError("获取SwiperItems数据失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchComponentData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchPosts() {
|
|
||||||
if (!pagetype) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ pagetype });
|
|
||||||
if (category) params.append("category", category);
|
|
||||||
if (count !== undefined && count !== null) params.append("count", count);
|
|
||||||
const res = await fetch(`/api/get-listview-data?${params.toString()}`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (Array.isArray(json.data)) {
|
|
||||||
setPosts(json.data);
|
|
||||||
} else {
|
|
||||||
setPosts([]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setPosts([]);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
fetchPosts();
|
|
||||||
}, [pagetype, category, count]);
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
// 渲染卡片内容
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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="swiper-container blog classic-view !mb-[7rem] xl:!mb-[10rem] lg:!mb-[10rem] md:!mb-[10rem]">
|
|
||||||
{loading ? (
|
|
||||||
<div className={`text-center py-12 text-gray-400`}>加载中...</div>
|
|
||||||
) : posts.length === 0 ? (
|
|
||||||
<div className={`text-center py-12 text-gray-400`}>暂无数据</div>
|
|
||||||
) : (
|
|
||||||
<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 } },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{posts.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SwiperItems.propTypes = {
|
|
||||||
pagetype: PropTypes.string,
|
|
||||||
category: PropTypes.string,
|
|
||||||
count: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.number
|
|
||||||
]),
|
|
||||||
columns: PropTypes.number,
|
|
||||||
category_slug: PropTypes.string,
|
|
||||||
rows: PropTypes.number,
|
|
||||||
};
|
|
||||||
262
components/homes/home-15/SwiperItems/SwiperItemsUI.jsx
Normal file
262
components/homes/home-15/SwiperItems/SwiperItemsUI.jsx
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
30
components/homes/home-15/SwiperItems/index.jsx
Normal file
30
components/homes/home-15/SwiperItems/index.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import SwiperItemsUI from "./SwiperItemsUI";
|
||||||
|
import { fetchComponentData, fetchListViewData } from "@/utils/data";
|
||||||
|
|
||||||
|
export default async function SwiperItems(props) {
|
||||||
|
// 1. Fetch component settings from the CMS/backend using the centralized utility.
|
||||||
|
const { data: componentData } = await fetchComponentData("SwiperItems");
|
||||||
|
|
||||||
|
// If there are no settings, we can't proceed.
|
||||||
|
if (!componentData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine parameters for fetching the list of items, using props as a fallback.
|
||||||
|
const pagetype = componentData?.t1 || props.pagetype || "";
|
||||||
|
const category = componentData?.t2 || props.category || "";
|
||||||
|
const count = componentData?.t4 || props.count || 12;
|
||||||
|
|
||||||
|
let items = [];
|
||||||
|
// Only fetch items if a pagetype is defined.
|
||||||
|
if (pagetype) {
|
||||||
|
const { data: listViewData } = await fetchListViewData({ pagetype, category, count });
|
||||||
|
if (Array.isArray(listViewData)) {
|
||||||
|
items = listViewData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pass the server-fetched data to the client component for rendering.
|
||||||
|
// This component is now lean and only concerned with orchestrating data flow.
|
||||||
|
return <SwiperItemsUI data={componentData} items={items} {...props} />;
|
||||||
|
}
|
||||||
@ -171,3 +171,41 @@ export async function getAllSlugs() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchComponentData(componentName) {
|
||||||
|
try {
|
||||||
|
// We use no-store to ensure data is fresh, suitable for dynamic component settings.
|
||||||
|
// For more static data, you might adjust the cache policy.
|
||||||
|
const res = await fetch(`${JINGROW_SERVER_URL}/api/method/jsite.api.v1.get_component_data?component_name=${componentName}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error(`Failed to fetch component data for ${componentName}: ${res.statusText}`, errorText);
|
||||||
|
return { data: null };
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
return { data: result.message?.data || null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching component data for ${componentName}:`, error);
|
||||||
|
return { data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchListViewData({ pagetype, category, count }) {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams({ pagetype });
|
||||||
|
if (category) queryParams.append("category", category);
|
||||||
|
if (count !== undefined && count !== null) queryParams.append("count", String(count));
|
||||||
|
|
||||||
|
const res = await fetch(`${JINGROW_SERVER_URL}/api/method/jsite.api.v1.get_listview_data?${queryParams.toString()}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error(`Failed to fetch list view data: ${res.statusText}`, errorText);
|
||||||
|
return { data: [] };
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
return { data: result.message?.data || [] };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching list view data:`, error);
|
||||||
|
return { data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user