new
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
@@ -12,29 +14,35 @@ import VCCourseDetail from './pages/VCCourseDetail';
|
||||
import MyOrders from './pages/MyOrders';
|
||||
import ForumList from './pages/ForumList';
|
||||
import ForumDetail from './pages/ForumDetail';
|
||||
import ActivityDetail from './pages/activity/Detail';
|
||||
import 'antd/dist/reset.css';
|
||||
import './App.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/services" element={<AIServices />} />
|
||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||
<Route path="/courses" element={<VCCourses />} />
|
||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||
<Route path="/forum" element={<ForumList />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/product/:id" element={<ProductDetail />} />
|
||||
<Route path="/payment/:orderId" element={<Payment />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/services" element={<AIServices />} />
|
||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||
<Route path="/courses" element={<VCCourses />} />
|
||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||
<Route path="/forum" element={<ForumList />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/activity/:id" element={<ActivityDetail />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/product/:id" element={<ProductDetail />} />
|
||||
<Route path="/payment/:orderId" element={<Payment />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,5 +68,6 @@ export const getAnnouncements = () => api.get('/community/announcements/');
|
||||
export const getActivities = () => api.get('/community/activities/');
|
||||
export const getActivityDetail = (id) => api.get(`/community/activities/${id}/`);
|
||||
export const signUpActivity = (id) => api.post(`/community/activities/${id}/signup/`);
|
||||
export const getMySignups = () => api.get('/community/activities/my_signups/');
|
||||
|
||||
export default api;
|
||||
|
||||
67
frontend/src/components/activity/ActivityCard.stories.jsx
Normal file
67
frontend/src/components/activity/ActivityCard.stories.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import ActivityCard from './ActivityCard';
|
||||
import '../../index.css'; // Global styles
|
||||
import '../../App.css';
|
||||
|
||||
export default {
|
||||
title: 'Components/Activity/ActivityCard',
|
||||
component: ActivityCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<div style={{ maxWidth: '400px', padding: '20px' }}>
|
||||
<Story />
|
||||
</div>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
const Template = (args) => <ActivityCard {...args} />;
|
||||
|
||||
export const NotStarted = Template.bind({});
|
||||
NotStarted.args = {
|
||||
activity: {
|
||||
id: 1,
|
||||
title: 'Future AI Hardware Summit 2026',
|
||||
start_time: '2026-12-01T09:00:00',
|
||||
status: '即将开始',
|
||||
cover_image: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?auto=format&fit=crop&q=80',
|
||||
},
|
||||
};
|
||||
|
||||
export const Ongoing = Template.bind({});
|
||||
Ongoing.args = {
|
||||
activity: {
|
||||
id: 2,
|
||||
title: 'Edge Computing Hackathon',
|
||||
start_time: '2025-10-20T10:00:00',
|
||||
status: '报名中',
|
||||
cover_image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&q=80',
|
||||
},
|
||||
};
|
||||
|
||||
export const Ended = Template.bind({});
|
||||
Ended.args = {
|
||||
activity: {
|
||||
id: 3,
|
||||
title: 'Deep Learning Workshop',
|
||||
start_time: '2023-05-15T14:00:00',
|
||||
status: '已结束',
|
||||
cover_image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80',
|
||||
},
|
||||
};
|
||||
|
||||
export const SignedUp = Template.bind({});
|
||||
SignedUp.args = {
|
||||
activity: {
|
||||
id: 4,
|
||||
title: 'Exclusive Developer Meetup',
|
||||
start_time: '2025-11-11T18:00:00',
|
||||
status: '已报名',
|
||||
cover_image: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { RocketOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getConfigs } from '../api';
|
||||
import ActivityList from '../components/activity/ActivityList';
|
||||
import './Home.css';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
@@ -108,6 +109,10 @@ const Home = () => {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '0 24px' }}>
|
||||
<ActivityList />
|
||||
</div>
|
||||
|
||||
<div className="product-scroll-container">
|
||||
<Row gutter={[24, 24]} wrap={false}>
|
||||
{products.map((product, index) => (
|
||||
|
||||
212
frontend/src/pages/activity/Detail.jsx
Normal file
212
frontend/src/pages/activity/Detail.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { message, Spin, Button, Result } from 'antd';
|
||||
import { getActivityDetail, signUpActivity } from '../../api';
|
||||
import styles from '../../components/activity/activity.module.less';
|
||||
import { pageTransition, buttonTap } from '../../animation';
|
||||
|
||||
const ActivityDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
// Header animation: transparent to white with shadow
|
||||
const headerBg = useTransform(scrollY, [0, 60], ['rgba(255,255,255,0)', 'rgba(255,255,255,1)']);
|
||||
const headerShadow = useTransform(scrollY, [0, 60], ['none', '0 2px 8px rgba(0,0,0,0.1)']);
|
||||
const headerColor = useTransform(scrollY, [0, 60], ['rgba(255,255,255,1)', 'rgba(0,0,0,0.85)']);
|
||||
|
||||
const { data: activity, isLoading, error } = useQuery({
|
||||
queryKey: ['activity', id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await getActivityDetail(id);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.detail || 'Failed to load activity');
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const signUpMutation = useMutation({
|
||||
mutationFn: () => signUpActivity(id),
|
||||
onSuccess: () => {
|
||||
message.success('报名成功!');
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
colors: ['#00b96b', '#1890ff', '#ffffff']
|
||||
});
|
||||
queryClient.invalidateQueries(['activity', id]);
|
||||
queryClient.invalidateQueries(['activities']);
|
||||
},
|
||||
onError: (err) => {
|
||||
message.error(err.response?.data?.detail || '报名失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = window.location.href;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: activity?.title,
|
||||
text: '来看看这个精彩活动!',
|
||||
url: url
|
||||
});
|
||||
} catch (err) {
|
||||
console.log('Share canceled');
|
||||
}
|
||||
} else {
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('链接已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#1f1f1f' }}>
|
||||
<Spin size="large" tip="加载活动详情..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 40, background: '#1f1f1f', minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle={error.message}
|
||||
extra={[
|
||||
<Button type="primary" key="back" onClick={() => navigate(-1)}>
|
||||
返回列表
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={pageTransition}
|
||||
style={{ background: '#1f1f1f', minHeight: '100vh', color: '#fff' }}
|
||||
>
|
||||
{/* Sticky Header */}
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 60,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
background: headerBg,
|
||||
boxShadow: headerShadow,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
style={{ color: headerColor, fontWeight: 600, opacity: useTransform(scrollY, [100, 200], [0, 1]) }}
|
||||
>
|
||||
{activity.title}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
onClick={handleShare}
|
||||
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hero Image with LayoutId for shared transition */}
|
||||
<div className={styles.detailHeader}>
|
||||
<motion.img
|
||||
layoutId={`activity-card-${id}`}
|
||||
src={activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://via.placeholder.com/800x600'}
|
||||
alt={activity.title}
|
||||
className={styles.detailImage}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
background: 'linear-gradient(to top, #1f1f1f, transparent)'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.infoCard}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 16, color: '#fff' }}>{activity.title}</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, marginBottom: 16, color: 'rgba(255,255,255,0.7)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CalendarOutlined />
|
||||
<span>{activity.start_time ? new Date(activity.start_time).toLocaleDateString() : 'TBD'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<EnvironmentOutlined />
|
||||
<span>{activity.location || '线上活动'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<span className={styles.statusTag}>
|
||||
{activity.status || (new Date() < new Date(activity.start_time) ? '报名中' : '已结束')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.richText}>
|
||||
<h3>活动详情</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: activity.content || '<p>暂无详情描述</p>' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className={styles.fixedFooter}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>距离报名截止</span>
|
||||
<span style={{ color: '#00b96b', fontWeight: 'bold' }}>
|
||||
{/* Simple countdown placeholder */}
|
||||
3天 12小时
|
||||
</span>
|
||||
</div>
|
||||
<motion.button
|
||||
className={styles.actionBtn}
|
||||
variants={buttonTap}
|
||||
whileTap="tap"
|
||||
onClick={handleSignUp}
|
||||
disabled={signUpMutation.isPending || activity.is_signed_up}
|
||||
>
|
||||
{signUpMutation.isPending ? '提交中...' : activity.is_signed_up ? '已报名' : '立即报名'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityDetail;
|
||||
Reference in New Issue
Block a user