feat: 移除轮播图管理,系列活动改卡片布局,课程描述限10行,首页配置优化
All checks were successful
Deploy to Server / deploy (push) Successful in 2m4s
All checks were successful
Deploy to Server / deploy (push) Successful in 2m4s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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="承办单位")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = [
|
||||||
# 首页配置
|
# 首页配置
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}
|
|
||||||
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
|
<img
|
||||||
ref={imgRef}
|
|
||||||
src={imgSrc}
|
|
||||||
alt={activity.title}
|
alt={activity.title}
|
||||||
style={{
|
src={imgSrc}
|
||||||
position: 'relative',
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
zIndex: 1,
|
onError={(e) => {
|
||||||
width: '100%',
|
e.target.src = 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop';
|
||||||
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 style={{ position: 'absolute', top: 10, right: 10 }}>
|
||||||
<div className={styles.statusTag}>
|
<Tag color={getStatusColor(statusText)} style={{ marginRight: 0, fontSize: 14, padding: '4px 12px' }}>
|
||||||
{activity.status || getStatus(activity.start_time)}
|
{statusText}
|
||||||
</div>
|
</Tag>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
|
||||||
<button onClick={prevSlide} className={styles.navBtn}><LeftOutlined /></button>
|
|
||||||
<button onClick={nextSlide} className={styles.navBtn}><RightOutlined /></button>
|
|
||||||
</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)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */}
|
{filtered.length > 0 ? (
|
||||||
<div className={styles.mobileList}>
|
<Row gutter={[32, 32]} justify="center">
|
||||||
{activities.map((item, index) => (
|
{filtered.map((activity) => (
|
||||||
<motion.div key={item.id} variants={fadeInUp} custom={index}>
|
<Col key={activity.id} xs={24} sm={12} md={8} lg={8} xl={8} xxl={8}>
|
||||||
<ActivityCard activity={item} />
|
<ActivityCard activity={activity} />
|
||||||
</motion.div>
|
</Col>
|
||||||
))}
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无相关活动" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +226,14 @@ 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}`)}
|
||||||
|
style={{
|
||||||
|
height: 600,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
|
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}
|
||||||
@@ -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,11 +397,14 @@ 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}`)}
|
||||||
|
style={{
|
||||||
|
height: 600,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'linear-gradient(135deg, #1890ff 0%, #69c0ff 100%)',
|
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)}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user