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

@@ -2,28 +2,20 @@ from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from unfold.decorators import display from unfold.decorators import display
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig
class CarouselItemInline(admin.TabularInline):
model = CarouselItem
extra = 1
tab = True
fields = ('carousel_type', 'image', 'image_url', 'title', 'subtitle', 'status', 'status_color', 'date', 'location', 'order', 'is_active')
autocomplete_fields = []
@admin.register(HomePageConfig) @admin.register(HomePageConfig)
class HomePageConfigAdmin(ModelAdmin): class HomePageConfigAdmin(ModelAdmin):
list_display = ['id', 'main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active'] list_display = ['id', 'main_title', 'organizer', 'undertaker', 'is_active']
list_editable = ['main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active'] list_editable = ['main_title', 'organizer', 'undertaker', 'is_active']
fieldsets = ( fieldsets = (
('首页Banner', { ('封面图', {
'fields': ('banner_image', 'banner_image_url'), 'fields': ('banner_image', 'banner_image_url'),
'description': '首页顶部Banner图片,可上传本地图片或填写URL' 'description': '首页标题下方的封面图可上传本地图片或填写URL'
}), }),
('标题设置', { ('标题设置', {
'fields': ('main_title', 'carousel1_title', 'carousel2_title') 'fields': ('main_title',)
}), }),
('主办单位', { ('主办单位', {
'fields': ('organizer', 'undertaker') 'fields': ('organizer', 'undertaker')
@@ -34,37 +26,6 @@ class HomePageConfigAdmin(ModelAdmin):
) )
@admin.register(CarouselItem)
class CarouselItemAdmin(ModelAdmin):
list_display = ['title', 'carousel_type', 'status', 'location', 'order', 'is_active', 'created_at']
list_filter = ['carousel_type', 'status', 'is_active']
search_fields = ['title', 'subtitle', 'location']
readonly_fields = ['image_preview']
fieldsets = (
('轮播图类型', {
'fields': ('carousel_type',)
}),
('图片设置', {
'fields': ('image', 'image_preview', 'image_url'),
'description': '优先使用本地上传的图片,上传后可预览'
}),
('内容设置', {
'fields': ('title', 'subtitle', 'status', 'status_color', 'date', 'location')
}),
('显示设置', {
'fields': ('order', 'is_active')
}),
)
@display(description='图片预览')
def image_preview(self, obj):
if obj.image:
return format_html('<img src="{}" style="max-width: 400px; max-height: 200px; border-radius: 8px;" />', obj.image.url)
elif obj.image_url:
return format_html('<img src="{}" style="max-width: 400px; max-height: 200px; border-radius: 8px;" />', obj.image_url)
return "暂无图片"
class ScoreDimensionInline(admin.TabularInline): class ScoreDimensionInline(admin.TabularInline):
model = ScoreDimension model = ScoreDimension
extra = 1 extra = 1

View File

@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('competition', '0009_alter_carouselitem_id_alter_comment_id_and_more'),
]
operations = [
migrations.RemoveField(
model_name='homepageconfig',
name='carousel1_title',
),
migrations.RemoveField(
model_name='homepageconfig',
name='carousel2_title',
),
]

View File

@@ -9,9 +9,6 @@ class HomePageConfig(models.Model):
main_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="主标题") main_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="主标题")
carousel1_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="轮播图1标题")
carousel2_title = models.CharField(max_length=200, default='"七彩云南创业福地"创业主题系列活动', verbose_name="轮播图2标题")
organizer = models.CharField(max_length=200, default='云南省人力资源和社会保障厅', verbose_name="主办单位") organizer = models.CharField(max_length=200, default='云南省人力资源和社会保障厅', verbose_name="主办单位")
undertaker = models.CharField(max_length=200, default='云南省就业局', verbose_name="承办单位") undertaker = models.CharField(max_length=200, default='云南省就业局', verbose_name="承办单位")

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from django.conf import settings from django.conf import settings
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig
from shop.serializers import WeChatUserSerializer from shop.serializers import WeChatUserSerializer
@@ -11,41 +11,16 @@ def _media_url(file_field):
return None return None
class CarouselItemSerializer(serializers.ModelSerializer):
display_image = serializers.SerializerMethodField()
class Meta:
model = CarouselItem
fields = ['id', 'carousel_type', 'image', 'image_url', 'display_image',
'title', 'subtitle', 'status', 'status_color', 'date', 'location',
'order', 'is_active']
def get_display_image(self, obj):
return _media_url(obj.image) or obj.image_url
class HomePageConfigSerializer(serializers.ModelSerializer): class HomePageConfigSerializer(serializers.ModelSerializer):
display_banner = serializers.SerializerMethodField() display_banner = serializers.SerializerMethodField()
carousel1_items = serializers.SerializerMethodField()
carousel2_items = serializers.SerializerMethodField()
class Meta: class Meta:
model = HomePageConfig model = HomePageConfig
fields = ['id', 'banner_image', 'banner_image_url', 'display_banner', fields = ['id', 'banner_image', 'banner_image_url', 'display_banner',
'main_title', 'carousel1_title', 'carousel2_title', 'main_title', 'organizer', 'undertaker']
'organizer', 'undertaker', 'carousel1_items', 'carousel2_items']
def get_display_banner(self, obj): def get_display_banner(self, obj):
return _media_url(obj.banner_image) or obj.banner_image_url return _media_url(obj.banner_image) or obj.banner_image_url
def get_carousel1_items(self, obj):
items = CarouselItem.objects.filter(carousel_type='carousel1', is_active=True)
return CarouselItemSerializer(items, many=True, context=self.context).data
def get_carousel2_items(self, obj):
items = CarouselItem.objects.filter(carousel_type='carousel2', is_active=True)
return CarouselItemSerializer(items, many=True, context=self.context).data
class ScoreDimensionSerializer(serializers.ModelSerializer): class ScoreDimensionSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@@ -2,7 +2,7 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet, CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
ScoreViewSet, CommentViewSet, CarouselItemViewSet, get_homepage_config ScoreViewSet, CommentViewSet, get_homepage_config
) )
from . import judge_views from . import judge_views
@@ -12,7 +12,6 @@ router.register(r'projects', ProjectViewSet, basename='project')
router.register(r'files', ProjectFileViewSet, basename='projectfile') router.register(r'files', ProjectFileViewSet, basename='projectfile')
router.register(r'scores', ScoreViewSet, basename='score') router.register(r'scores', ScoreViewSet, basename='score')
router.register(r'comments', CommentViewSet, basename='comment') router.register(r'comments', CommentViewSet, basename='comment')
router.register(r'carousel-items', CarouselItemViewSet, basename='carouselitem')
urlpatterns = [ urlpatterns = [
# 首页配置 # 首页配置

View File

@@ -3,12 +3,12 @@ from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Q from django.db.models import Q
from shop.utils import get_current_wechat_user from shop.utils import get_current_wechat_user
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension, HomePageConfig, CarouselItem from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension, HomePageConfig
from .serializers import ( from .serializers import (
CompetitionSerializer, CompetitionEnrollmentSerializer, CompetitionSerializer, CompetitionEnrollmentSerializer,
ProjectSerializer, ProjectFileSerializer, ProjectSerializer, ProjectFileSerializer,
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer, ScoreSerializer, CommentSerializer, ScoreDimensionSerializer,
HomePageConfigSerializer, CarouselItemSerializer HomePageConfigSerializer
) )
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -28,22 +28,6 @@ def get_homepage_config(request):
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class CarouselItemViewSet(viewsets.ModelViewSet):
"""轮播图项目管理"""
queryset = CarouselItem.objects.all()
serializer_class = CarouselItemSerializer
permission_classes = [permissions.AllowAny]
filter_backends = [filters.SearchFilter]
search_fields = ['title']
def get_queryset(self):
queryset = CarouselItem.objects.all()
carousel_type = self.request.query_params.get('carousel_type')
if carousel_type:
queryset = queryset.filter(carousel_type=carousel_type)
return queryset
class StandardResultsSetPagination(PageNumberPagination): class StandardResultsSetPagination(PageNumberPagination):
page_size = 10 page_size = 10
page_size_query_param = 'page_size' page_size_query_param = 'page_size'

View File

@@ -255,11 +255,6 @@ UNFOLD = {
"icon": "home", "icon": "home",
"link": reverse_lazy("admin:competition_homepageconfig_changelist"), "link": reverse_lazy("admin:competition_homepageconfig_changelist"),
}, },
{
"title": "轮播图管理",
"icon": "image",
"link": reverse_lazy("admin:competition_carouselitem_changelist"),
},
], ],
}, },
{ {

View File

@@ -1,100 +1,102 @@
import React from 'react';
import React, { useState, useRef, useLayoutEffect } from 'react'; import { Card, Tag, Typography, Space, Divider } from 'antd';
import { motion } from 'framer-motion'; import { CalendarOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { CalendarOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; 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 ActivityCard = ({ activity }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const imgRef = useRef(null);
const handleCardClick = () => { const imgSrc = getImageUrl(activity.display_banner_url || activity.banner_url || activity.cover_image)
navigate(`/activity/${activity.id}`); || 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop';
};
const getStatus = (startTime) => { const getStatusColor = (status) => {
const now = new Date(); if (!status) return 'blue';
const start = new Date(startTime); if (status.includes('报名')) return 'green';
if (now < start) return '即将开始'; if (status.includes('即将')) return 'cyan';
return '报名中'; if (status.includes('结束')) return 'red';
return 'blue';
}; };
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return 'TBD'; if (!dateStr) return 'TBD';
const date = new Date(dateStr); return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
}; };
const imgSrc = hasError const statusText = activity.status || (
? 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop' new Date() < new Date(activity.start_time) ? '即将开始' : '报名中'
: (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]);
return ( return (
<motion.div <Card
className={styles.activityCard} hoverable
variants={hoverScale} cover={
whileHover="hover" <div style={{ height: 280, overflow: 'hidden', position: 'relative' }}>
onClick={handleCardClick} <img
layoutId={`activity-card-${activity.id}`} alt={activity.title}
style={{ willChange: 'transform' }} src={imgSrc}
> style={{ width: '100%', height: '100%', objectFit: 'cover' }}
<div className={styles.imageContainer}> onError={(e) => {
{/* Placeholder Background - Always visible behind the image */} e.target.src = 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop';
<div }}
style={{ />
position: 'absolute', <div style={{ position: 'absolute', top: 10, right: 10 }}>
top: 0, <Tag color={getStatusColor(statusText)} style={{ marginRight: 0, fontSize: 14, padding: '4px 12px' }}>
left: 0, {statusText}
width: '100%', </Tag>
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>
</div> </div>
</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> </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 } from 'react';
import React, { useState, useEffect } from 'react'; import { Row, Col, Empty, Spin, Input, Select } from 'antd';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
import { getActivities } from '../../api'; import { getActivities } from '../../api';
import ActivityCard from './ActivityCard'; import ActivityCard from './ActivityCard';
import styles from './activity.module.less';
import { fadeInUp, staggerContainer } from '../../animation'; const { Search } = Input;
const { Option } = Select;
const ActivityList = () => { const ActivityList = () => {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const { data: activities, isLoading, error } = useQuery({ const { data: activities, isLoading, error } = useQuery({
queryKey: ['activities'], queryKey: ['activities'],
queryFn: async () => { queryFn: async () => {
const res = await getActivities(); const res = await getActivities();
// Handle different response structures
return Array.isArray(res.data) ? res.data : (res.data?.results || []); 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 if (isLoading) return (
useEffect(() => { <div style={{ textAlign: 'center', padding: '50px 0' }}>
if (!activities || activities.length <= 1) return; <Spin size="large" tip="正在加载活动..." />
const interval = setInterval(() => { </div>
setCurrentIndex((prev) => (prev + 1) % activities.length); );
}, 5000); if (error) return <Empty description="加载失败,请稍后重试" />;
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;
return ( return (
<motion.div <div>
className={styles.activitySection} <div style={{ marginBottom: 32, textAlign: 'center' }}>
initial="hidden" <div style={{ maxWidth: 800, margin: '0 auto', display: 'flex', gap: 16 }}>
whileInView="visible" <Search
viewport={{ once: true, amount: 0.2 }} placeholder="搜索活动名称"
variants={staggerContainer} allowClear
> enterButton="搜索"
<div className={styles.header}> size="large"
<h2 className={styles.sectionTitle}> onSearch={setSearch}
近期活动 / EVENTS style={{ flex: 1 }}
</h2> />
<div className={styles.controls}> <Select
<button onClick={prevSlide} className={styles.navBtn}><LeftOutlined /></button> defaultValue="all"
<button onClick={nextSlide} className={styles.navBtn}><RightOutlined /></button> size="large"
style={{ width: 120 }}
onChange={setStatusFilter}
>
<Option value="all">全部状态</Option>
<Option value="报名中">报名中</Option>
<Option value="即将开始">即将开始</Option>
<Option value="已结束">已结束</Option>
</Select>
</div> </div>
</div> </div>
{/* Desktop: Carousel (Show one prominent, but allows list structure if needed) {filtered.length > 0 ? (
User said: "Activity only shows one, and in the form of a sliding page" <Row gutter={[32, 32]} justify="center">
*/} {filtered.map((activity) => (
<div className={styles.desktopCarousel}> <Col key={activity.id} xs={24} sm={12} md={8} lg={8} xl={8} xxl={8}>
<AnimatePresence> <ActivityCard activity={activity} />
<motion.div </Col>
key={currentIndex} ))}
initial={{ x: '100%' }} </Row>
animate={{ x: 0, zIndex: 1 }} ) : (
exit={{ x: '-100%', zIndex: 0 }} <Empty description="暂无相关活动" image={Empty.PRESENTED_IMAGE_SIMPLE} />
transition={{ duration: 0.5, ease: "easeInOut" }} )}
style={{ </div>
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>
); );
}; };

View File

@@ -22,18 +22,15 @@ const Home = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [typedText, setTypedText] = useState(''); const [typedText, setTypedText] = useState('');
const [isTypingComplete, setIsTypingComplete] = useState(false); const [isTypingComplete, setIsTypingComplete] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
const [currentSlide2, setCurrentSlide2] = useState(0); const [currentSlide2, setCurrentSlide2] = useState(0);
const [currentSlide3, setCurrentSlide3] = useState(0); const [currentSlide3, setCurrentSlide3] = useState(0);
const [homeConfig, setHomeConfig] = useState(null); const [homeConfig, setHomeConfig] = useState(null);
const [carousel1Items, setCarousel1Items] = useState([]);
const [competitions, setCompetitions] = useState([]); const [competitions, setCompetitions] = useState([]);
const [activities, setActivities] = useState([]); const [activities, setActivities] = useState([]);
const fullText = "未来已来 AI 核心驱动"; const fullText = "未来已来 AI 核心驱动";
const defaultMainTitle = '"创赢未来"云南2026创业大赛'; const defaultMainTitle = '"创赢未来"云南2026创业大赛';
const [mainTitleText, setMainTitleText] = useState(defaultMainTitle); const [mainTitleText, setMainTitleText] = useState(defaultMainTitle);
const navigate = useNavigate(); const navigate = useNavigate();
const carouselRef = useRef(null);
const carouselRef2 = useRef(null); const carouselRef2 = useRef(null);
const carouselRef3 = useRef(null); const carouselRef3 = useRef(null);
@@ -66,9 +63,7 @@ const Home = () => {
const fetchHomePageConfig = async () => { const fetchHomePageConfig = async () => {
try { try {
const response = await getHomePageConfig(); const response = await getHomePageConfig();
const data = response.data; setHomeConfig(response.data);
setHomeConfig(data);
setCarousel1Items(data.carousel1_items || []);
} catch (error) { } catch (error) {
console.error('Failed to fetch homepage config:', error); console.error('Failed to fetch homepage config:', error);
} }
@@ -159,135 +154,6 @@ const Home = () => {
/> />
</motion.div> </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 && ( {competitions.length > 0 && (
<motion.div <motion.div
@@ -360,12 +226,15 @@ const Home = () => {
> >
{competitions.map((competition, index) => ( {competitions.map((competition, index) => (
<div key={competition.id}> <div key={competition.id}>
<div style={{ <div
height: 450, onClick={() => navigate(`/competitions/${competition.id}`)}
position: 'relative', style={{
overflow: 'hidden', height: 600,
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)', position: 'relative',
}}> overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
cursor: 'pointer',
}}>
<img <img
src={getImageUrl(competition.display_cover_image) || competition.cover_image} src={getImageUrl(competition.display_cover_image) || competition.cover_image}
alt={competition.title} 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> <span style={{ marginRight: 20 }}>📅 {competition.start_time?.split('T')[0]} ~ {competition.end_time?.split('T')[0]}</span>
</p> </p>
</div> </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> </Button>
</div> </div>
@@ -528,12 +397,15 @@ const Home = () => {
> >
{activities.map((activity, index) => ( {activities.map((activity, index) => (
<div key={activity.id}> <div key={activity.id}>
<div style={{ <div
height: 450, onClick={() => navigate(`/activity/${activity.id}`)}
position: 'relative', style={{
overflow: 'hidden', height: 600,
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)', position: 'relative',
}}> overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
cursor: 'pointer',
}}>
<img <img
src={getImageUrl(activity.display_banner_url || activity.banner_url || activity.cover_image)} src={getImageUrl(activity.display_banner_url || activity.banner_url || activity.cover_image)}
alt={activity.title} alt={activity.title}
@@ -601,7 +473,7 @@ const Home = () => {
{activity.location && <span>📍 {activity.location}</span>} {activity.location && <span>📍 {activity.location}</span>}
</p> </p>
</div> </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> </Button>
</div> </div>

View File

@@ -78,7 +78,7 @@ const VCCourses = () => {
<span style={{ marginRight: 15 }}><ClockCircleOutlined /> {item.duration}</span> <span style={{ marginRight: 15 }}><ClockCircleOutlined /> {item.duration}</span>
<span><BookOutlined /> {item.lesson_count} 课时</span> <span><BookOutlined /> {item.lesson_count} 课时</span>
</div> </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 type="primary" block ghost style={{ borderColor: '#00f0ff', color: '#00f0ff' }}>
点击报名 点击报名
</Button> </Button>