feat: 移除轮播图管理,系列活动改卡片布局,课程描述限10行,首页配置优化
All checks were successful
Deploy to Server / deploy (push) Successful in 2m4s

This commit is contained in:
爽哒哒
2026-03-22 00:10:34 +08:00
parent b24bba3753
commit 21f892fdf6
11 changed files with 188 additions and 417 deletions

View File

@@ -1,100 +1,102 @@
import React, { useState, useRef, useLayoutEffect } from 'react';
import { motion } from 'framer-motion';
import { CalendarOutlined } from '@ant-design/icons';
import React from 'react';
import { Card, Tag, Typography, Space, Divider } from 'antd';
import { CalendarOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import styles from './activity.module.less';
import { hoverScale } from '../../animation';
const { Title } = Typography;
const getImageUrl = (url) => {
if (!url) return '';
if (url.startsWith('http') || url.startsWith('//')) {
try { return new URL(url).pathname; } catch { return url; }
}
return url;
};
const ActivityCard = ({ activity }) => {
const navigate = useNavigate();
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const imgRef = useRef(null);
const handleCardClick = () => {
navigate(`/activity/${activity.id}`);
};
const imgSrc = getImageUrl(activity.display_banner_url || activity.banner_url || activity.cover_image)
|| 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop';
const getStatus = (startTime) => {
const now = new Date();
const start = new Date(startTime);
if (now < start) return '即将开始';
return '报名中';
const getStatusColor = (status) => {
if (!status) return 'blue';
if (status.includes('报名')) return 'green';
if (status.includes('即将')) return 'cyan';
if (status.includes('结束')) return 'red';
return 'blue';
};
const formatDate = (dateStr) => {
if (!dateStr) return 'TBD';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
};
const imgSrc = hasError
? 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop'
: (activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop');
// Check if image is already loaded (cached) to prevent flashing
useLayoutEffect(() => {
if (imgRef.current && imgRef.current.complete) {
setIsLoaded(true);
}
}, [imgSrc]);
const statusText = activity.status || (
new Date() < new Date(activity.start_time) ? '即将开始' : '报名中'
);
return (
<motion.div
className={styles.activityCard}
variants={hoverScale}
whileHover="hover"
onClick={handleCardClick}
layoutId={`activity-card-${activity.id}`}
style={{ willChange: 'transform' }}
>
<div className={styles.imageContainer}>
{/* Placeholder Background - Always visible behind the image */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#f5f5f5',
zIndex: 0,
}}
/>
<img
ref={imgRef}
src={imgSrc}
alt={activity.title}
style={{
position: 'relative',
zIndex: 1,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-out'
}}
onLoad={() => setIsLoaded(true)}
onError={() => {
setHasError(true);
setIsLoaded(true);
}}
loading="lazy"
/>
<div className={styles.overlay} style={{ zIndex: 2 }}>
<div className={styles.statusTag}>
{activity.status || getStatus(activity.start_time)}
</div>
<h3 className={styles.title}>{activity.title}</h3>
<div className={styles.time}>
<CalendarOutlined />
<span>{formatDate(activity.start_time)}</span>
<Card
hoverable
cover={
<div style={{ height: 280, overflow: 'hidden', position: 'relative' }}>
<img
alt={activity.title}
src={imgSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.target.src = 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop';
}}
/>
<div style={{ position: 'absolute', top: 10, right: 10 }}>
<Tag color={getStatusColor(statusText)} style={{ marginRight: 0, fontSize: 14, padding: '4px 12px' }}>
{statusText}
</Tag>
</div>
</div>
}
style={{ height: '100%', display: 'flex', flexDirection: 'column', fontSize: 16 }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 24 } }}
onClick={() => navigate(`/activity/${activity.id}`)}
>
<Title level={3} ellipsis={{ rows: 2 }} style={{ marginBottom: 12, height: 64, fontSize: 20 }}>
{activity.title}
</Title>
<div style={{
flex: 1,
color: 'rgba(255,255,255,0.65)',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
fontSize: '15px',
lineHeight: 1.6,
}}>
{activity.description || activity.subtitle || ''}
</div>
</motion.div>
<Divider style={{ margin: '12px 0' }} />
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{activity.start_time && (
<Space>
<CalendarOutlined style={{ color: '#1890ff', fontSize: 16 }} />
<span style={{ fontSize: 14 }}>
{formatDate(activity.start_time)}
{activity.end_time ? ` ~ ${formatDate(activity.end_time)}` : ''}
</span>
</Space>
)}
{activity.location && (
<Space>
<EnvironmentOutlined style={{ color: '#1890ff', fontSize: 16 }} />
<span style={{ fontSize: 14 }}>{activity.location}</span>
</Space>
)}
</Space>
</Card>
);
};

View File

@@ -1,109 +1,76 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Row, Col, Empty, Spin, Input, Select } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
import { getActivities } from '../../api';
import ActivityCard from './ActivityCard';
import styles from './activity.module.less';
import { fadeInUp, staggerContainer } from '../../animation';
const { Search } = Input;
const { Option } = Select;
const ActivityList = () => {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const { data: activities, isLoading, error } = useQuery({
queryKey: ['activities'],
queryFn: async () => {
const res = await getActivities();
// Handle different response structures
return Array.isArray(res.data) ? res.data : (res.data?.results || []);
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
staleTime: 5 * 60 * 1000,
});
const [currentIndex, setCurrentIndex] = useState(0);
const filtered = (activities || []).filter(a => {
const matchSearch = !search || a.title?.includes(search);
const matchStatus = statusFilter === 'all' || a.status === statusFilter;
return matchSearch && matchStatus;
});
// Auto-play for desktop carousel
useEffect(() => {
if (!activities || activities.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % activities.length);
}, 5000);
return () => clearInterval(interval);
}, [activities]);
const nextSlide = () => {
if (!activities) return;
setCurrentIndex((prev) => (prev + 1) % activities.length);
};
const prevSlide = () => {
if (!activities) return;
setCurrentIndex((prev) => (prev - 1 + activities.length) % activities.length);
};
if (isLoading) return <div className={styles.loading}>Loading activities...</div>;
if (error) return null; // Or error state
if (!activities || activities.length === 0) return null;
if (isLoading) return (
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Spin size="large" tip="正在加载活动..." />
</div>
);
if (error) return <Empty description="加载失败,请稍后重试" />;
return (
<motion.div
className={styles.activitySection}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={staggerContainer}
>
<div className={styles.header}>
<h2 className={styles.sectionTitle}>
近期活动 / EVENTS
</h2>
<div className={styles.controls}>
<button onClick={prevSlide} className={styles.navBtn}><LeftOutlined /></button>
<button onClick={nextSlide} className={styles.navBtn}><RightOutlined /></button>
<div>
<div style={{ marginBottom: 32, textAlign: 'center' }}>
<div style={{ maxWidth: 800, margin: '0 auto', display: 'flex', gap: 16 }}>
<Search
placeholder="搜索活动名称"
allowClear
enterButton="搜索"
size="large"
onSearch={setSearch}
style={{ flex: 1 }}
/>
<Select
defaultValue="all"
size="large"
style={{ width: 120 }}
onChange={setStatusFilter}
>
<Option value="all">全部状态</Option>
<Option value="报名中">报名中</Option>
<Option value="即将开始">即将开始</Option>
<Option value="已结束">已结束</Option>
</Select>
</div>
</div>
{/* Desktop: Carousel (Show one prominent, but allows list structure if needed)
User said: "Activity only shows one, and in the form of a sliding page"
*/}
<div className={styles.desktopCarousel}>
<AnimatePresence>
<motion.div
key={currentIndex}
initial={{ x: '100%' }}
animate={{ x: 0, zIndex: 1 }}
exit={{ x: '-100%', zIndex: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={{
width: '100%',
position: 'absolute',
top: 0,
left: 0,
}}
>
<ActivityCard activity={activities[currentIndex]} />
</motion.div>
</AnimatePresence>
<div className={styles.dots} style={{ position: 'absolute', bottom: '10px', width: '100%', zIndex: 10 }}>
{activities.map((_, idx) => (
<span
key={idx}
className={`${styles.dot} ${idx === currentIndex ? styles.activeDot : ''}`}
onClick={() => setCurrentIndex(idx)}
/>
))}
</div>
</div>
{/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */}
<div className={styles.mobileList}>
{activities.map((item, index) => (
<motion.div key={item.id} variants={fadeInUp} custom={index}>
<ActivityCard activity={item} />
</motion.div>
))}
</div>
</motion.div>
{filtered.length > 0 ? (
<Row gutter={[32, 32]} justify="center">
{filtered.map((activity) => (
<Col key={activity.id} xs={24} sm={12} md={8} lg={8} xl={8} xxl={8}>
<ActivityCard activity={activity} />
</Col>
))}
</Row>
) : (
<Empty description="暂无相关活动" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
);
};

View File

@@ -22,18 +22,15 @@ const Home = () => {
const [loading, setLoading] = useState(false);
const [typedText, setTypedText] = useState('');
const [isTypingComplete, setIsTypingComplete] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
const [currentSlide2, setCurrentSlide2] = useState(0);
const [currentSlide3, setCurrentSlide3] = useState(0);
const [homeConfig, setHomeConfig] = useState(null);
const [carousel1Items, setCarousel1Items] = useState([]);
const [competitions, setCompetitions] = useState([]);
const [activities, setActivities] = useState([]);
const fullText = "未来已来 AI 核心驱动";
const defaultMainTitle = '"创赢未来"云南2026创业大赛';
const [mainTitleText, setMainTitleText] = useState(defaultMainTitle);
const navigate = useNavigate();
const carouselRef = useRef(null);
const carouselRef2 = useRef(null);
const carouselRef3 = useRef(null);
@@ -66,9 +63,7 @@ const Home = () => {
const fetchHomePageConfig = async () => {
try {
const response = await getHomePageConfig();
const data = response.data;
setHomeConfig(data);
setCarousel1Items(data.carousel1_items || []);
setHomeConfig(response.data);
} catch (error) {
console.error('Failed to fetch homepage config:', error);
}
@@ -159,135 +154,6 @@ const Home = () => {
/>
</motion.div>
{/* 轮播图 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
style={{ marginTop: 40, maxWidth: 1200, marginLeft: 'auto', marginRight: 'auto' }}
>
{/* 轮播图主体 */}
<div style={{
position: 'relative',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(24, 144, 255, 0.2)',
}}>
<Carousel
ref={carouselRef}
autoplay
dots={false}
beforeChange={(_, next) => setCurrentSlide(next)}
>
{(carousel1Items.length > 0 ? carousel1Items : []).map((image, index) => (
<div key={index}>
<div style={{
height: 450,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
}}>
<img
src={getImageUrl(image.display_image) || image.image_url}
alt={image.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
{/* 渐变遮罩 */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.6) 100%)',
}} />
{/* 标题区域 - 图片上方 */}
<div style={{
position: 'absolute',
top: 40,
left: 40,
textAlign: 'left',
}}>
<h2 style={{ fontSize: 36, fontWeight: 'bold', color: '#fff', margin: 0, textShadow: '2px 2px 4px rgba(0,0,0,0.3)' }}>
{image.title}
</h2>
<p style={{ fontSize: 18, color: 'rgba(255,255,255,0.9)', margin: '8px 0 0 0', textShadow: '1px 1px 2px rgba(0,0,0,0.3)' }}>
{image.subtitle}
</p>
</div>
{/* 底部信息 */}
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: '24px 40px',
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Tag style={{
background: image.status_color,
color: '#fff',
border: 'none',
fontSize: 14,
padding: '4px 16px',
borderRadius: 20,
}}>
{image.status}
</Tag>
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>{image.location}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h3 style={{ color: '#fff', fontSize: 24, fontWeight: 'bold', margin: 0 }}>{image.title}</h3>
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14, margin: '8px 0 0 0' }}>
<span style={{ marginRight: 20 }}>📅 {image.date}</span>
<span>📍 {image.location}</span>
</p>
</div>
<Button type="primary" size="large" style={{ borderRadius: 24, paddingLeft: 24, paddingRight: 24 }} onClick={() => navigate('/competitions')}>
立即报名
</Button>
</div>
</div>
</div>
</div>
))}
</Carousel>
{/* 自定义分页指示器 */}
<div style={{
position: 'absolute',
bottom: 100,
right: 40,
display: 'flex',
gap: 8,
}}>
{(carousel1Items.length > 0 ? carousel1Items : []).map((_, index) => (
<div
key={index}
onClick={() => carouselRef.current?.goTo(index)}
style={{
width: currentSlide === index ? 32 : 10,
height: 10,
borderRadius: 5,
background: currentSlide === index ? '#fff' : 'rgba(255,255,255,0.4)',
cursor: 'pointer',
transition: 'all 0.3s',
}}
/>
))}
</div>
</div>
</motion.div>
{/* 赛事中心轮播图 */}
{competitions.length > 0 && (
<motion.div
@@ -360,12 +226,15 @@ const Home = () => {
>
{competitions.map((competition, index) => (
<div key={competition.id}>
<div style={{
height: 450,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
}}>
<div
onClick={() => navigate(`/competitions/${competition.id}`)}
style={{
height: 600,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
cursor: 'pointer',
}}>
<img
src={getImageUrl(competition.display_cover_image) || competition.cover_image}
alt={competition.title}
@@ -419,7 +288,7 @@ const Home = () => {
<span style={{ marginRight: 20 }}>📅 {competition.start_time?.split('T')[0]} ~ {competition.end_time?.split('T')[0]}</span>
</p>
</div>
<Button type="primary" size="large" style={{ borderRadius: 24, paddingLeft: 24, paddingRight: 24 }} onClick={() => navigate(`/competitions/${competition.id}`)}>
<Button type="primary" size="large" style={{ borderRadius: 24, paddingLeft: 24, paddingRight: 24 }} onClick={(e) => { e.stopPropagation(); navigate(`/competitions/${competition.id}`); }}>
查看详情
</Button>
</div>
@@ -528,12 +397,15 @@ const Home = () => {
>
{activities.map((activity, index) => (
<div key={activity.id}>
<div style={{
height: 450,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
}}>
<div
onClick={() => navigate(`/activity/${activity.id}`)}
style={{
height: 600,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
cursor: 'pointer',
}}>
<img
src={getImageUrl(activity.display_banner_url || activity.banner_url || activity.cover_image)}
alt={activity.title}
@@ -601,7 +473,7 @@ const Home = () => {
{activity.location && <span>📍 {activity.location}</span>}
</p>
</div>
<Button type="primary" size="large" style={{ borderRadius: 24, paddingLeft: 24, paddingRight: 24 }} onClick={() => navigate('/activities')}>
<Button type="primary" size="large" style={{ borderRadius: 24, paddingLeft: 24, paddingRight: 24 }} onClick={(e) => { e.stopPropagation(); navigate(`/activity/${activity.id}`); }}>
查看详情
</Button>
</div>

View File

@@ -78,7 +78,7 @@ const VCCourses = () => {
<span style={{ marginRight: 15 }}><ClockCircleOutlined /> {item.duration}</span>
<span><BookOutlined /> {item.lesson_count} 课时</span>
</div>
<p style={{ color: '#aaa', marginBottom: 20, flex: 1 }}>{item.description}</p>
<p style={{ color: '#aaa', marginBottom: 20, flex: 1, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 10, WebkitBoxOrient: 'vertical' }}>{item.description}</p>
<Button type="primary" block ghost style={{ borderColor: '#00f0ff', color: '#00f0ff' }}>
点击报名
</Button>