Files
market_page/frontend/src/pages/activity/Detail.jsx
jeremygan2021 c3fab398bb
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
报名表单
2026-02-23 15:28:41 +08:00

511 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, UserOutlined, UploadOutlined, PayCircleOutlined } from '@ant-design/icons';
import confetti from 'canvas-confetti';
import { message, Spin, Button, Result, Modal, Form, Input, Select, Radio, Checkbox, Upload } from 'antd';
import { getActivityDetail, signUpActivity, queryOrderStatus } from '../../api';
import styles from '../../components/activity/activity.module.less';
import { pageTransition, buttonTap } from '../../animation';
import LoginModal from '../../components/LoginModal';
import { useAuth } from '../../context/AuthContext';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { QRCodeSVG } from 'qrcode.react';
const ActivityDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { scrollY } = useScroll();
const { login, user } = useAuth();
const [loginVisible, setLoginVisible] = useState(false);
const [signupFormVisible, setSignupFormVisible] = useState(false);
const [paymentModalVisible, setPaymentModalVisible] = useState(false);
const [paymentInfo, setPaymentInfo] = useState(null);
const [form] = Form.useForm();
// 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 titleOpacity = useTransform(scrollY, [100, 200], [0, 1]);
const { data: activity, isLoading, error, refetch } = 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: 0, // Ensure fresh data
refetchOnMount: 'always', // Force refetch on mount
});
//// /
// Force a refresh if needed (as requested by user)
useEffect(() => {
// 1. Force React Query refetch
refetch();
// 2. Hard refresh logic after 1 second delay
const timer = setTimeout(() => {
const hasRefreshedKey = `has_refreshed_activity_${id}`;
if (!sessionStorage.getItem(hasRefreshedKey)) {
sessionStorage.setItem(hasRefreshedKey, 'true');
window.location.reload();
}
}, 0);
return () => clearTimeout(timer);
}, [id, refetch]);
// Auto-fill form fields when the signup form becomes visible
useEffect(() => {
if (signupFormVisible && user && activity?.signup_form_config) {
const initialValues = {};
activity.signup_form_config.forEach(field => {
// Auto-fill phone number
if (field.name === 'phone' && user.phone_number) {
initialValues[field.name] = user.phone_number;
}
// Auto-fill name (nickname) if the field name is 'name'
if (field.name === 'name' && user.nickname) {
initialValues[field.name] = user.nickname;
}
});
if (Object.keys(initialValues).length > 0) {
form.setFieldsValue(initialValues);
}
}
}, [signupFormVisible, user, activity, form]);
const signUpMutation = useMutation({
mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }),
onSuccess: (data) => {
// 检查是否需要支付
if (data.payment_required) {
setPaymentInfo(data);
// 先关闭报名表单,确保层级正确
setSignupFormVisible(false);
// 延迟一点点时间打开支付弹窗,避免状态更新冲突
setTimeout(() => {
setPaymentModalVisible(true);
}, 300);
// 不再显示 message避免遮挡
return;
}
message.success('报名成功!');
setSignupFormVisible(false);
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 || err.response?.data?.error || '报名失败,请稍后重试');
}
});
// Polling for payment status
useEffect(() => {
let timer;
if (paymentModalVisible && paymentInfo?.order_id) {
timer = setInterval(async () => {
try {
const response = await queryOrderStatus(paymentInfo.order_id);
if (response.data.status === 'paid') {
message.success('支付成功,报名已确认!');
setPaymentModalVisible(false);
setPaymentInfo(null);
// Trigger success effects
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ['#00b96b', '#1890ff', '#ffffff']
});
queryClient.invalidateQueries(['activity', id]);
queryClient.invalidateQueries(['activities']);
clearInterval(timer);
}
} catch (error) {
// ignore error during polling
}
}, 3000);
}
return () => clearInterval(timer);
}, [paymentModalVisible, paymentInfo, id, queryClient]);
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('链接已复制到剪贴板');
}
};
const handleSignUp = () => {
if (!localStorage.getItem('token')) {
message.warning('请先登录后报名');
setLoginVisible(true);
return;
}
// Check if we need to collect info
if (activity.signup_form_config && activity.signup_form_config.length > 0) {
setSignupFormVisible(true);
} else {
// Direct signup if no info needed
signUpMutation.mutate({});
}
};
const handleFormSubmit = (values) => {
// Handle file uploads if any (convert to base64 or just warn if not supported)
// For now, we just pass values.
// Note: File objects won't serialize to JSON well.
signUpMutation.mutate(values);
};
const normFile = (e) => {
if (Array.isArray(e)) {
return e;
}
return e?.fileList;
};
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#1f1f1f' }}>
<Spin size="large" />
</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>
);
}
const cleanUrl = (url) => {
if (!url) return '';
return url.replace(/[`\s]/g, '');
};
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: titleOpacity }}
>
{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 || cleanUrl(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 }}>
<ClockCircleOutlined />
<span>{activity.start_time ? new Date(activity.start_time).toLocaleTimeString() : 'TBD'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EnvironmentOutlined />
<span>{activity.location || '线上活动'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<UserOutlined />
<span>{activity.current_signups || 0} / {activity.max_participants} 已报名</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PayCircleOutlined />
<span>{activity.is_paid ? `¥${activity.price}` : '免费'}</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 style={{ color: '#ccc', lineHeight: '1.8' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{activity.description || activity.content || '暂无详情描述'}
</ReactMarkdown>
</div>
</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>
<LoginModal
visible={loginVisible}
onClose={() => setLoginVisible(false)}
onLoginSuccess={(userData) => {
login(userData);
// Auto trigger signup after login if needed, or just let user click again
}}
/>
<Modal
title="填写报名信息"
open={signupFormVisible}
onCancel={() => setSignupFormVisible(false)}
onOk={form.submit}
okText={activity?.is_paid && activity?.price > 0 ? `支付 ¥${activity.price}` : '提交报名'}
confirmLoading={signUpMutation.isPending}
destroyOnHidden
>
<Form form={form} onFinish={handleFormSubmit} layout="vertical">
{activity.signup_form_config && activity.signup_form_config.map(field => {
let inputNode;
const commonProps = {
placeholder: field.placeholder || `请输入${field.label}`,
};
switch (field.type) {
case 'select':
inputNode = (
<Select placeholder={field.placeholder || `请选择${field.label}`}>
{field.options?.map(opt => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
break;
case 'radio':
inputNode = (
<Radio.Group>
{field.options?.map(opt => (
<Radio key={opt.value} value={opt.value}>
{opt.label}
</Radio>
))}
</Radio.Group>
);
break;
case 'checkbox':
inputNode = (
<Checkbox.Group>
{field.options?.map(opt => (
<Checkbox key={opt.value} value={opt.value}>
{opt.label}
</Checkbox>
))}
</Checkbox.Group>
);
break;
case 'textarea':
inputNode = <Input.TextArea {...commonProps} rows={4} />;
break;
case 'file':
inputNode = (
<Upload beforeUpload={() => false} maxCount={1}>
<Button icon={<UploadOutlined />}>点击上传</Button>
</Upload>
);
break;
default:
inputNode = <Input {...commonProps} type={field.type === 'tel' ? 'tel' : 'text'} />;
}
const itemProps = {
key: field.name,
name: field.name,
label: field.label,
rules: [{ required: field.required, message: `请填写${field.label}` }],
};
if (field.type === 'file') {
itemProps.valuePropName = 'fileList';
itemProps.getValueFromEvent = normFile;
}
return (
<Form.Item {...itemProps}>
{inputNode}
</Form.Item>
);
})}
</Form>
</Modal>
<Modal
title="微信支付"
open={paymentModalVisible}
onCancel={() => setPaymentModalVisible(false)}
footer={null}
destroyOnHidden
width={360}
zIndex={1001} // 确保层级高于其他弹窗
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
{paymentInfo?.code_url ? (
<>
<div style={{ background: '#fff', padding: 10, display: 'inline-block' }}>
<QRCodeSVG value={paymentInfo.code_url} size={200} />
</div>
<p style={{ marginTop: 20, fontSize: 18, fontWeight: 'bold' }}>¥{paymentInfo.price}</p>
<p style={{ color: '#666' }}>请使用微信扫一扫支付</p>
</>
) : (
<Spin tip="正在生成二维码..." />
)}
<div style={{ marginTop: 20 }}>
<Button type="primary" onClick={() => window.location.reload()}>
我已支付
</Button>
</div>
</div>
</Modal>
</motion.div>
);
};
export default ActivityDetail;