Files
Scoring-System/frontend/src/pages/VCCourseDetail.jsx

553 lines
26 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, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message } from 'antd';
import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, FormOutlined, CalendarOutlined, PlayCircleOutlined, LockOutlined } from '@ant-design/icons';
import { getVCCourseDetail, createOrder, nativePay, queryOrderStatus } from '../api';
import { useAuth } from '../context/AuthContext';
import { QRCodeSVG } from 'qrcode.react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import 'katex/dist/katex.min.css';
import styles from './VCCourseDetail.module.less';
import CodeBlock from '../components/CodeBlock';
const { Title, Paragraph } = Typography;
const VCCourseDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { user, showLoginModal } = useAuth();
const [course, setCourse] = useState(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
// Payment states
const [payMode, setPayMode] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState(null);
const [currentOrderId, setCurrentOrderId] = useState(null);
const [paySuccess, setPaySuccess] = useState(false);
// 优先从 URL 获取,如果没有则从 localStorage 获取
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
const markdownComponents = {
// eslint-disable-next-line no-unused-vars
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<CodeBlock
language={match[1]}
{...props}
>
{String(children).replace(/\n$/, '')}
</CodeBlock>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
// eslint-disable-next-line no-unused-vars
img({node, ...props}) {
return (
<img
{...props}
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%', margin: '10px 0' }}
/>
);
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await getVCCourseDetail(id);
console.log('Course detail:', response.data);
setCourse(response.data);
} catch (error) {
console.error("Failed to fetch course detail:", error);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [id]);
useEffect(() => {
if (isModalOpen) {
// Reset payment state when modal opens
setPayMode(false);
setQrCodeUrl(null);
setCurrentOrderId(null);
setPaySuccess(false);
if (user && user.phone_number) {
form.setFieldsValue({
phone_number: user.phone_number
});
}
}
}, [isModalOpen, user, form]);
// Polling for payment status
useEffect(() => {
let timer;
if (payMode && !paySuccess && currentOrderId) {
timer = setInterval(async () => {
try {
const response = await queryOrderStatus(currentOrderId);
if (response.data.status === 'paid') {
setPaySuccess(true);
message.success('支付成功!报名已完成。');
setTimeout(() => {
setIsModalOpen(false);
// 刷新课程详情以获取解锁后的视频URL
const fetchDetail = async () => {
try {
const res = await getVCCourseDetail(id);
setCourse(res.data);
} catch (error) {
console.error("Failed to refresh course detail:", error);
}
};
fetchDetail();
}, 2000); // Wait 2 seconds before closing
clearInterval(timer);
}
} catch (error) {
console.error('Check payment status failed:', error);
}
}, 3000);
}
return () => clearInterval(timer);
}, [payMode, paySuccess, currentOrderId, id]);
const handleEnroll = async (values) => {
setSubmitting(true);
try {
const isFree = course.price === 0 || parseFloat(course.price) === 0;
if (isFree) {
const orderData = {
course: course.id,
customer_name: values.customer_name,
phone_number: values.phone_number,
ref_code: refCode || "",
quantity: 1,
// 将其他信息放入收货地址字段中
shipping_address: `[课程报名] 微信号: ${values.wechat_id || '无'}, 邮箱: ${values.email || '无'}, 备注: ${values.message || '无'}`
};
await createOrder(orderData);
message.success('报名成功!您已成功加入课程。');
setIsModalOpen(false);
} else {
// Paid course - use nativePay to generate QR code
const orderData = {
goodid: course.id,
type: 'course',
quantity: 1,
customer_name: values.customer_name,
phone_number: values.phone_number,
ref_code: refCode || "",
shipping_address: `[课程报名] 微信号: ${values.wechat_id || '无'}, 邮箱: ${values.email || '无'}, 备注: ${values.message || '无'}`
};
const response = await nativePay(orderData);
if (response.data && response.data.code_url) {
setQrCodeUrl(response.data.code_url);
setCurrentOrderId(response.data.order_id);
setPayMode(true);
message.success('订单创建成功,请扫码支付');
} else {
throw new Error('Failed to generate payment QR code');
}
}
} catch (error) {
console.error(error);
message.error('提交失败,请重试');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 20 }}>Loading...</div>
</div>
);
}
if (!course) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Empty description="Course not found" />
<Button type="primary" onClick={() => navigate('/courses')} style={{ marginTop: 20 }}>
Return to Courses
</Button>
</div>
);
}
return (
<div style={{ padding: '20px 0', minHeight: '80vh' }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
style={{ color: '#fff', marginBottom: 20 }}
onClick={() => navigate('/courses')}
>
返回课程列表
</Button>
<div>
<Row gutter={[40, 40]}>
<Col xs={24} md={16}>
<div style={{ textAlign: 'left', marginBottom: 40 }}>
<div style={{ display: 'flex', gap: '10px', marginBottom: 10 }}>
{course.tag && <Tag color="volcano">{course.tag}</Tag>}
<Tag color={course.course_type === 'hardware' ? 'purple' : 'cyan'}>
{course.course_type_display || (course.course_type === 'hardware' ? '硬件课程' : '软件课程')}
</Tag>
</div>
<Title level={1} style={{ color: '#fff', marginTop: 0 }}>
{course.title}
</Title>
<Paragraph style={{ color: '#888', fontSize: 18 }}>
{course.description}
</Paragraph>
{/* 视频课程播放区域 */}
{course.is_video_course && (
<div style={{
margin: '30px 0',
background: '#000',
borderRadius: 16,
overflow: 'hidden',
border: '1px solid rgba(0, 240, 255, 0.2)',
boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
position: 'relative',
aspectRatio: '16/9'
}}>
{course.video_embed_code ? (
<div
style={{ width: '100%', height: '100%' }}
dangerouslySetInnerHTML={{ __html: course.video_embed_code }}
/>
) : course.video_url ? (
<video
src={course.video_url}
controls
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
poster={course.cover_image_url}
>
您的浏览器不支持视频播放
</video>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#fff',
background: `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), url(${course.cover_image_url}) no-repeat center/cover`
}}>
<LockOutlined style={{ fontSize: 48, color: '#00f0ff', marginBottom: 20 }} />
<Title level={4} style={{ color: '#fff', marginBottom: 10 }}>课程视频内容已锁定</Title>
<p style={{ color: '#ccc', fontSize: 16 }}>
请购买或报名该课程以解锁完整视频内容
</p>
<Button
type="primary"
icon={<PlayCircleOutlined />}
size="large"
style={{
marginTop: 20,
background: '#00f0ff',
borderColor: '#00f0ff',
color: '#000',
fontWeight: 'bold'
}}
onClick={() => {
if (!user) {
message.info('请先登录后再进行报名或购买');
showLoginModal();
return;
}
setIsModalOpen(true);
}}
>
立即解锁观看
</Button>
</div>
)}
</div>
)}
<div style={{
marginTop: 30,
background: 'rgba(255,255,255,0.03)',
padding: '24px',
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}>
<Title level={4} style={{ color: '#fff', marginBottom: 20, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 18, background: '#00f0ff', marginRight: 10, borderRadius: 2 }} />
课程信息
</Title>
<Descriptions
column={{ xs: 1, sm: 2, md: 3 }}
labelStyle={{ color: '#888', fontWeight: 'normal' }}
contentStyle={{ color: '#fff', fontWeight: '500' }}
>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><UserOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 讲师</span>}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{course.instructor_avatar_url && (
<img src={course.instructor_avatar_url} alt="avatar" style={{ width: 24, height: 24, borderRadius: '50%', marginRight: 8, objectFit: 'cover' }} />
)}
<span>{course.instructor}</span>
{course.instructor_title && (
<span style={{
fontSize: 12,
background: 'rgba(0, 240, 255, 0.1)',
color: '#00f0ff',
padding: '2px 6px',
borderRadius: 4,
marginLeft: 8
}}>
{course.instructor_title}
</span>
)}
</div>
</Descriptions.Item>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><ClockCircleOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 时长</span>}>
{course.duration}
</Descriptions.Item>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><BookOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 课时</span>}>
{course.lesson_count} 课时
</Descriptions.Item>
{course.is_fixed_schedule && (course.start_time || course.end_time) && (
<Descriptions.Item span={3} label={<span style={{ display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 开课时间</span>}>
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap' }}>
{course.start_time && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 12, color: '#888', marginBottom: 2 }}>开始时间</span>
<span style={{ fontSize: 16, color: '#fff', fontFamily: 'DIN Alternate, Consolas, monospace', letterSpacing: 1 }}>
{new Date(course.start_time).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}
</span>
</div>
)}
{course.start_time && course.end_time && (
<div style={{ width: 30, height: 1, background: '#333' }} />
)}
{course.end_time && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 12, color: '#888', marginBottom: 2 }}>结束时间</span>
<span style={{ fontSize: 16, color: '#fff', fontFamily: 'DIN Alternate, Consolas, monospace', letterSpacing: 1 }}>
{new Date(course.end_time).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}
</span>
</div>
)}
</div>
</Descriptions.Item>
)}
</Descriptions>
{/* 讲师简介 */}
{course.instructor_desc && (
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid rgba(255,255,255,0.05)', color: '#aaa', fontSize: 14 }}>
<span style={{ color: '#666', marginRight: 10 }}>讲师简介:</span>
{course.instructor_desc}
</div>
)}
</div>
{/* 课程详细内容区域 */}
{course.content && (
<div style={{ marginTop: 40 }}>
<Title level={3} style={{ color: '#fff', marginBottom: 20 }}>课程大纲与详情</Title>
<div className={styles['markdown-body']}>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={markdownComponents}
>
{course.content}
</ReactMarkdown>
</div>
</div>
)}
</div>
{course.display_detail_image ? (
<div style={{
width: '100%',
maxWidth: '900px',
margin: '40px auto 0',
background: '#111',
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 10px 40px rgba(0, 240, 255, 0.1)`,
border: `1px solid rgba(0, 240, 255, 0.2)`
}}>
<img
src={course.display_detail_image}
alt={course.title}
style={{ width: '100%', display: 'block', height: 'auto' }}
/>
</div>
) : null}
</Col>
<Col xs={24} md={8}>
<div style={{ position: 'sticky', top: 100 }}>
<div style={{
background: '#1f1f1f',
padding: 30,
borderRadius: 16,
border: `1px solid rgba(0, 240, 255, 0.2)`,
boxShadow: `0 0 20px rgba(0, 240, 255, 0.05)`
}}>
<Title level={3} style={{ color: '#fff', marginTop: 0 }}>
{course.is_video_course ? '购买课程' : '报名咨询'}
</Title>
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 20 }}>
{parseFloat(course.price) > 0 ? (
<>
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>¥{course.price}</span>
</>
) : (
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>免费咨询</span>
)}
</div>
<Button
type="primary"
size="large"
block
icon={course.is_video_course ? <PlayCircleOutlined /> : <FormOutlined />}
disabled={course.is_purchased}
style={{
height: 50,
background: course.is_purchased ? '#333' : '#00f0ff',
borderColor: course.is_purchased ? '#444' : '#00f0ff',
color: course.is_purchased ? '#888' : '#000',
fontWeight: 'bold',
fontSize: '16px',
cursor: course.is_purchased ? 'not-allowed' : 'pointer'
}}
onClick={() => {
if (course.is_purchased) return;
if (!user) {
message.info('请先登录后再进行报名或购买');
showLoginModal();
return;
}
setIsModalOpen(true);
}}
>
{course.is_purchased ? '已购买' : (course.is_video_course ? '购买视频课程' : '立即报名 / 咨询')}
</Button>
<p style={{ color: '#666', marginTop: 15, fontSize: 12, textAlign: 'center' }}>
{course.is_purchased
? '* 您已拥有该课程,可直接观看视频'
: (course.is_video_course
? '* 支付成功后自动解锁视频内容'
: '* 提交后我们的顾问将尽快与您联系确认')}
</p>
</div>
</div>
</Col>
</Row>
</div>
{/* Enroll Modal */}
<Modal
title={payMode ? '微信扫码支付' : `${course.is_video_course ? '购买课程' : '报名/咨询'} - ${course.title}`}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
destroyOnHidden
width={payMode ? 400 : 520}
>
{payMode ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
{paySuccess ? (
<div style={{ color: '#52c41a' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🎉</div>
<h3>支付成功</h3>
<p>正在跳转...</p>
</div>
) : (
<>
<div style={{ background: '#fff', padding: '10px', borderRadius: '4px', display: 'inline-block', border: '1px solid #eee' }}>
{qrCodeUrl ? (
<QRCodeSVG value={qrCodeUrl} size={200} />
) : (
<Spin size="large" />
)}
</div>
<p style={{ marginTop: 20, fontSize: 16, fontWeight: 'bold' }}>¥{course.price}</p>
<p style={{ color: '#666', marginTop: 10 }}>请使用微信扫一扫支付</p>
<div style={{ marginTop: 20, fontSize: 12, color: '#999' }}>
支付完成后将自动{course.is_video_course ? '解锁视频' : '完成报名'}
</div>
</>
)}
</div>
) : (
<>
<p style={{ marginBottom: 20, color: '#666' }}>
{course.is_video_course
? '请确认您的联系方式,以便我们记录您的购买信息。'
: '请填写您的联系方式,我们将为您安排课程顾问。'}
</p>
<Form
form={form}
layout="vertical"
onFinish={handleEnroll}
>
<Form.Item label="您的姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="例如:李同学" />
</Form.Item>
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
<Input placeholder="13800000000" />
</Form.Item>
<Form.Item label="微信号" name="wechat_id">
<Input placeholder="选填,方便微信沟通" />
</Form.Item>
<Form.Item label="电子邮箱" name="email" rules={[{ type: 'email' }]}>
<Input placeholder="example@email.com" />
</Form.Item>
<Form.Item label="备注/留言" name="message">
<Input.TextArea rows={4} placeholder="您想了解的任何问题..." />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
{parseFloat(course.price) > 0 ? '去支付' : '提交报名'}
</Button>
</div>
</Form>
</>
)}
</Modal>
</div>
);
};
export default VCCourseDetail;