mini
All checks were successful
Deploy to Server / deploy (push) Successful in 40s

This commit is contained in:
jeremygan2021
2026-02-24 00:31:57 +08:00
parent 0d01a5f2a8
commit 441e080328
15 changed files with 535 additions and 106 deletions

View File

@@ -101,19 +101,7 @@ DATABASES = {
#从环境变量获取数据库配置 (Docker 环境会自动注入这些变量。 #从环境变量获取数据库配置 (Docker 环境会自动注入这些变量。
DB_HOST = os.environ.get('DB_HOST', '6.6.6.66') # DB_HOST = os.environ.get('DB_HOST', '6.6.6.66')
if DB_HOST:
DATABASES['default'] = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'market'),
'USER': os.environ.get('DB_USER', 'market'),
'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
'HOST': DB_HOST,
'PORT': os.environ.get('DB_PORT', '5432'),
}
# DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
# if DB_HOST: # if DB_HOST:
# DATABASES['default'] = { # DATABASES['default'] = {
# 'ENGINE': 'django.db.backends.postgresql', # 'ENGINE': 'django.db.backends.postgresql',
@@ -121,10 +109,22 @@ if DB_HOST:
# 'USER': os.environ.get('DB_USER', 'market'), # 'USER': os.environ.get('DB_USER', 'market'),
# 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'), # 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
# 'HOST': DB_HOST, # 'HOST': DB_HOST,
# 'PORT': os.environ.get('DB_PORT', '6433'), # 'PORT': os.environ.get('DB_PORT', '5432'),
# } # }
DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
if DB_HOST:
DATABASES['default'] = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'market'),
'USER': os.environ.get('DB_USER', 'market'),
'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
'HOST': DB_HOST,
'PORT': os.environ.get('DB_PORT', '6433'),
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators

View File

@@ -235,6 +235,10 @@ class VCCourseAdmin(OrderableAdminMixin, ModelAdmin):
('基本信息', { ('基本信息', {
'fields': ('title', 'description', 'course_type', 'tag', 'price') 'fields': ('title', 'description', 'course_type', 'tag', 'price')
}), }),
('课程安排', {
'fields': ('is_fixed_schedule', 'start_time', 'end_time'),
'description': '勾选“是否固定时间课程”后,请设置开始和结束时间'
}),
('讲师信息', { ('讲师信息', {
'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'), 'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'),
'description': '讲师头像上传和URL二选一优先使用URL' 'description': '讲师头像上传和URL二选一优先使用URL'

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-02-23 15:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0032_order_activity'),
]
operations = [
migrations.AddField(
model_name='vccourse',
name='is_fixed_schedule',
field=models.BooleanField(default=False, help_text='勾选后,前端将显示具体的开课时间', verbose_name='是否固定时间课程'),
),
migrations.AddField(
model_name='vccourse',
name='schedule_time',
field=models.CharField(blank=True, help_text='例如:每周六晚 20:00', max_length=100, null=True, verbose_name='课程具体时间'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-02-23 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0033_vccourse_is_fixed_schedule_vccourse_schedule_time'),
]
operations = [
migrations.RemoveField(
model_name='vccourse',
name='schedule_time',
),
migrations.AddField(
model_name='vccourse',
name='end_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='结束时间'),
),
migrations.AddField(
model_name='vccourse',
name='start_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='开始时间'),
),
]

View File

@@ -348,6 +348,11 @@ class VCCourse(models.Model):
instructor_desc = models.TextField(blank=True, verbose_name="讲师简介", default="拥有多年开发经验,擅长...") instructor_desc = models.TextField(blank=True, verbose_name="讲师简介", default="拥有多年开发经验,擅长...")
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶") tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
# 课程时间安排
is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间")
start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间")
end_time = models.DateTimeField(blank=True, null=True, verbose_name="结束时间")
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费") price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费")
content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML") content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML")

View File

@@ -92,7 +92,7 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer):
课程报名序列化器 课程报名序列化器
""" """
course_title = serializers.CharField(source='course.title', read_only=True) course_title = serializers.CharField(source='course.title', read_only=True)
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True, allow_null=True)
class Meta: class Meta:
model = CourseEnrollment model = CourseEnrollment
@@ -124,7 +124,7 @@ class ServiceOrderSerializer(serializers.ModelSerializer):
""" """
service_name = serializers.CharField(source='service.title', read_only=True) service_name = serializers.CharField(source='service.title', read_only=True)
# 接收前端传来的 ref_code # 接收前端传来的 ref_code
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True, allow_null=True)
class Meta: class Meta:
model = ServiceOrder model = ServiceOrder
@@ -212,7 +212,7 @@ class OrderSerializer(serializers.ModelSerializer):
salesperson_name = serializers.CharField(source='salesperson.name', read_only=True) salesperson_name = serializers.CharField(source='salesperson.name', read_only=True)
salesperson_code = serializers.CharField(source='salesperson.code', read_only=True) salesperson_code = serializers.CharField(source='salesperson.code', read_only=True)
# 接收前端传来的 ref_code用于查找 Salesperson # 接收前端传来的 ref_code用于查找 Salesperson
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True, allow_null=True)
class Meta: class Meta:
model = Order model = Order

View File

@@ -268,8 +268,8 @@ def pay(request):
product = None product = None
if order_type == 'course': if order_type == 'course':
try: try:
product = VBCourse.objects.get(id=good_id) product = VCCourse.objects.get(id=good_id)
except VBCourse.DoesNotExist: except VCCourse.DoesNotExist:
print(f"课程不存在: {good_id}") print(f"课程不存在: {good_id}")
return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND)
else: else:
@@ -355,7 +355,8 @@ def pay(request):
print(f"微信支付 V3 Native 下单成功!") print(f"微信支付 V3 Native 下单成功!")
print(f"订单 ID: {order.id}") print(f"订单 ID: {order.id}")
print(f"商户订单号: {out_trade_no}") print(f"商户订单号: {out_trade_no}")
print(f"商品: {product.name} x {quantity}") product_name = getattr(product, 'name', getattr(product, 'title', 'Unknown Product'))
print(f"商品: {product_name} x {quantity}")
print(f"总额: {total_price}") print(f"总额: {total_price}")
print(f"code_url: {code_url}") print(f"code_url: {code_url}")
print(f"========================================") print(f"========================================")
@@ -596,6 +597,16 @@ class OrderViewSet(viewsets.ModelViewSet):
return queryset.filter(wechat_user=user).order_by('-created_at') return queryset.filter(wechat_user=user).order_by('-created_at')
return queryset.order_by('-created_at') return queryset.order_by('-created_at')
def create(self, request, *args, **kwargs):
print(f"Creating order with data: {request.data}")
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
print(f"Order validation failed: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer): def perform_create(self, serializer):
""" """
创建订单时自动关联当前微信用户 创建订单时自动关联当前微信用户

View File

@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, List, Tag, Typography, message, Space, Statistic, Divider, Modal, Descriptions, Tabs } from 'antd'; import { Form, Input, Button, Card, List, Tag, Typography, message, Space, Statistic, Divider, Modal, Descriptions, Tabs } from 'antd';
import { MobileOutlined, LockOutlined, SearchOutlined, CarOutlined, InboxOutlined, SafetyCertificateOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, UserOutlined, EnvironmentOutlined, PhoneOutlined, CalendarOutlined } from '@ant-design/icons'; import { MobileOutlined, LockOutlined, SearchOutlined, CarOutlined, InboxOutlined, SafetyCertificateOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, UserOutlined, EnvironmentOutlined, PhoneOutlined, CalendarOutlined } from '@ant-design/icons';
import { queryMyOrders, getMySignups } from '../api'; import { getMySignups } from '../api';
import { motion } from 'framer-motion';
import LoginModal from '../components/LoginModal'; import LoginModal from '../components/LoginModal';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -75,6 +74,24 @@ const MyOrders = () => {
} }
}; };
const getOrderTypeTag = (order) => {
if (order.config) return <Tag color="blue">硬件</Tag>;
if (order.course) return <Tag color="purple">VC课程</Tag>;
if (order.activity) return <Tag color="orange">活动</Tag>;
return <Tag>其他</Tag>;
};
const getOrderTitle = (order) => {
if (order.config_name) return order.config_name;
if (order.course_title) return order.course_title;
if (order.activity_title) return order.activity_title;
// Fallback to ID if no name/title
if (order.config) return `硬件 ID: ${order.config}`;
if (order.course) return `课程 ID: ${order.course}`;
if (order.activity) return `活动 ID: ${order.activity}`;
return '未知商品';
};
return ( return (
<div style={{ <div style={{
minHeight: '80vh', minHeight: '80vh',
@@ -96,7 +113,7 @@ const MyOrders = () => {
<Button type="primary" size="large" onClick={() => setLoginVisible(true)}>立即登录</Button> <Button type="primary" size="large" onClick={() => setLoginVisible(true)}>立即登录</Button>
</div> </div>
) : ( ) : (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <div>
<div style={{ marginBottom: 20, textAlign: 'right', color: '#fff' }}> <div style={{ marginBottom: 20, textAlign: 'right', color: '#fff' }}>
当前登录用户: <span style={{ color: '#00b96b', fontWeight: 'bold', marginRight: 10 }}>{user.nickname}</span> 当前登录用户: <span style={{ color: '#00b96b', fontWeight: 'bold', marginRight: 10 }}>{user.nickname}</span>
<Button <Button
@@ -122,7 +139,7 @@ const MyOrders = () => {
<Card <Card
hoverable hoverable
onClick={() => showDetail(order)} onClick={() => showDetail(order)}
title={<Space><span style={{ color: '#fff' }}>订单号: {order.id}</span> {getStatusTag(order.status)}</Space>} title={<Space>{getOrderTypeTag(order)}<span style={{ color: '#fff' }}>订单号: {order.id}</span> {getStatusTag(order.status)}</Space>}
style={{ style={{
background: 'rgba(0,0,0,0.6)', background: 'rgba(0,0,0,0.6)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
@@ -143,7 +160,7 @@ const MyOrders = () => {
{order.config_image ? ( {order.config_image ? (
<img <img
src={order.config_image} src={order.config_image}
alt={order.config_name} alt={getOrderTitle(order)}
style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }} style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}
/> />
) : ( ) : (
@@ -161,7 +178,7 @@ const MyOrders = () => {
</div> </div>
)} )}
<div> <div>
<div style={{ color: '#fff', fontSize: 16, fontWeight: '500', marginBottom: 4 }}>{order.config_name || `商品 ID: ${order.config}`}</div> <div style={{ color: '#fff', fontSize: 16, fontWeight: '500', marginBottom: 4 }}>{getOrderTitle(order)}</div>
<div style={{ color: '#888' }}>数量: <span style={{ color: '#00b96b' }}>x{order.quantity}</span></div> <div style={{ color: '#888' }}>数量: <span style={{ color: '#00b96b' }}>x{order.quantity}</span></div>
</div> </div>
</Space> </Space>
@@ -277,7 +294,7 @@ const MyOrders = () => {
) )
} }
]} /> ]} />
</motion.div> </div>
)} )}
<Modal <Modal
@@ -297,7 +314,8 @@ const MyOrders = () => {
<Descriptions.Item label="订单号"> <Descriptions.Item label="订单号">
<Paragraph copyable={{ text: currentOrder.id }} style={{ marginBottom: 0 }}>{currentOrder.id}</Paragraph> <Paragraph copyable={{ text: currentOrder.id }} style={{ marginBottom: 0 }}>{currentOrder.id}</Paragraph>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="商品名称">{currentOrder.config_name}</Descriptions.Item> <Descriptions.Item label="订单类型">{getOrderTypeTag(currentOrder)}</Descriptions.Item>
<Descriptions.Item label="商品名称">{getOrderTitle(currentOrder)}</Descriptions.Item>
<Descriptions.Item label="下单时间">{new Date(currentOrder.created_at).toLocaleString()}</Descriptions.Item> <Descriptions.Item label="下单时间">{new Date(currentOrder.created_at).toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="状态更新时间">{new Date(currentOrder.updated_at).toLocaleString()}</Descriptions.Item> <Descriptions.Item label="状态更新时间">{new Date(currentOrder.updated_at).toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="当前状态">{getStatusTag(currentOrder.status)}</Descriptions.Item> <Descriptions.Item label="当前状态">{getStatusTag(currentOrder.status)}</Descriptions.Item>
@@ -344,7 +362,7 @@ const MyOrders = () => {
onLoginSuccess={(userData) => { onLoginSuccess={(userData) => {
login(userData); login(userData);
if (userData.phone_number) { if (userData.phone_number) {
handleQueryOrders(userData.phone_number); handleQueryData();
} }
}} }}
/> />

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message } from 'antd'; import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message } from 'antd';
import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, FormOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, FormOutlined, CalendarOutlined } from '@ant-design/icons';
import { getVCCourseDetail, createOrder } from '../api'; import { getVCCourseDetail, createOrder, nativePay, queryOrderStatus } from '../api';
import { motion } from 'framer-motion';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { QRCodeSVG } from 'qrcode.react';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
@@ -19,6 +19,12 @@ const VCCourseDetail = () => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm(); 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 获取 // 优先从 URL 获取,如果没有则从 localStorage 获取
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code'); const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
@@ -37,33 +43,85 @@ const VCCourseDetail = () => {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
if (isModalOpen && user && user.phone_number) { if (isModalOpen) {
form.setFieldsValue({ // Reset payment state when modal opens
phone_number: user.phone_number setPayMode(false);
}); setQrCodeUrl(null);
setCurrentOrderId(null);
setPaySuccess(false);
if (user && user.phone_number) {
form.setFieldsValue({
phone_number: user.phone_number
});
}
} }
}, [isModalOpen, user, form]); }, [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);
}, 2000); // Wait 2 seconds before closing
clearInterval(timer);
}
} catch (error) {
console.error('Check payment status failed:', error);
}
}, 3000);
}
return () => clearInterval(timer);
}, [payMode, paySuccess, currentOrderId]);
const handleEnroll = async (values) => { const handleEnroll = async (values) => {
setSubmitting(true); setSubmitting(true);
try { try {
const orderData = { const isFree = course.price === 0 || parseFloat(course.price) === 0;
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); if (isFree) {
if (course.price === 0 || parseFloat(course.price) === 0) { 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('报名成功!您已成功加入课程。'); message.success('报名成功!您已成功加入课程。');
setIsModalOpen(false);
} else { } else {
message.success('报名咨询已提交,我们的课程顾问将尽快与您联系!'); // 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');
}
} }
setIsModalOpen(false);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error('提交失败,请重试'); message.error('提交失败,请重试');
@@ -103,11 +161,7 @@ const VCCourseDetail = () => {
返回课程列表 返回课程列表
</Button> </Button>
<motion.div <div>
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Row gutter={[40, 40]}> <Row gutter={[40, 40]}>
<Col xs={24} md={16}> <Col xs={24} md={16}>
<div style={{ textAlign: 'left', marginBottom: 40 }}> <div style={{ textAlign: 'left', marginBottom: 40 }}>
@@ -168,6 +222,14 @@ const VCCourseDetail = () => {
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><BookOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 课时</span>}> <Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><BookOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 课时</span>}>
{course.lesson_count} 课时 {course.lesson_count} 课时
</Descriptions.Item> </Descriptions.Item>
{course.is_fixed_schedule && (course.start_time || course.end_time) && (
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 开课时间</span>}>
<div>
{course.start_time && <div>开始{new Date(course.start_time).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}</div>}
{course.end_time && <div>结束{new Date(course.end_time).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}</div>}
</div>
</Descriptions.Item>
)}
</Descriptions> </Descriptions>
{/* 讲师简介 */} {/* 讲师简介 */}
@@ -255,43 +317,75 @@ const VCCourseDetail = () => {
</div> </div>
</Col> </Col>
</Row> </Row>
</motion.div> </div>
{/* Enroll Modal */} {/* Enroll Modal */}
<Modal <Modal
title={`报名/咨询 - ${course.title}`} title={payMode ? '微信扫码支付' : `报名/咨询 - ${course.title}`}
open={isModalOpen} open={isModalOpen}
onCancel={() => setIsModalOpen(false)} onCancel={() => setIsModalOpen(false)}
footer={null} footer={null}
destroyOnHidden destroyOnHidden
width={payMode ? 400 : 520}
> >
<p style={{ marginBottom: 20, color: '#666' }}>请填写您的联系方式我们将为您安排课程顾问</p> {payMode ? (
<Form <div style={{ textAlign: 'center', padding: '20px 0' }}>
form={form} {paySuccess ? (
layout="vertical" <div style={{ color: '#52c41a' }}>
onFinish={handleEnroll} <div style={{ fontSize: 48, marginBottom: 16 }}>🎉</div>
> <h3>支付成功</h3>
<Form.Item label="您的姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}> <p>正在跳转...</p>
<Input placeholder="例如:李同学" /> </div>
</Form.Item> ) : (
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}> <>
<Input placeholder="13800000000" /> <div style={{ background: '#fff', padding: '10px', borderRadius: '4px', display: 'inline-block', border: '1px solid #eee' }}>
</Form.Item> {qrCodeUrl ? (
<Form.Item label="微信号" name="wechat_id"> <QRCodeSVG value={qrCodeUrl} size={200} />
<Input placeholder="选填,方便微信沟通" /> ) : (
</Form.Item> <Spin size="large" />
<Form.Item label="电子邮箱" name="email" rules={[{ type: 'email' }]}> )}
<Input placeholder="example@email.com" /> </div>
</Form.Item> <p style={{ marginTop: 20, fontSize: 16, fontWeight: 'bold' }}>¥{course.price}</p>
<Form.Item label="备注/留言" name="message"> <p style={{ color: '#666', marginTop: 10 }}>请使用微信扫一扫支付</p>
<Input.TextArea rows={4} placeholder="您想了解的任何问题..." /> <div style={{ marginTop: 20, fontSize: 12, color: '#999' }}>
</Form.Item> 支付完成后将自动完成报名
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}> </>
<Button onClick={() => setIsModalOpen(false)}>取消</Button> )}
<Button type="primary" htmlType="submit" loading={submitting}>提交报名</Button> </div>
</div> ) : (
</Form> <>
<p style={{ marginBottom: 20, color: '#666' }}>请填写您的联系方式我们将为您安排课程顾问</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> </Modal>
</div> </div>
); );

View File

@@ -185,6 +185,36 @@
} }
} }
.schedule-section {
.schedule-box {
background: #111;
padding: 30px;
border-radius: 16px;
border: 1px solid rgba(0, 240, 255, 0.2);
.time-row {
display: flex;
margin-bottom: 16px;
font-size: 28px;
&:last-child {
margin-bottom: 0;
}
.label {
color: #888;
width: 160px;
}
.value {
color: #00f0ff;
flex: 1;
font-weight: bold;
}
}
}
}
.desc-text { .desc-text {
color: #aaa; color: #aaa;
font-size: 28px; font-size: 28px;

View File

@@ -56,6 +56,17 @@ export default function CourseDetail() {
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View> if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View> if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
const formatDateTime = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}/${month}/${day} ${hour}:${minute}`
}
return ( return (
<View className='page-container'> <View className='page-container'>
<ScrollView scrollY className='scroll-content'> <ScrollView scrollY className='scroll-content'>
@@ -109,6 +120,27 @@ export default function CourseDetail() {
</View> </View>
</View> </View>
{/* 开课时间 */}
{detail.is_fixed_schedule && (detail.start_time || detail.end_time) && (
<View className='section schedule-section'>
<Text className='section-title'></Text>
<View className='schedule-box'>
{detail.start_time && (
<View className='time-row'>
<Text className='label'></Text>
<Text className='value'>{formatDateTime(detail.start_time)}</Text>
</View>
)}
{detail.end_time && (
<View className='time-row'>
<Text className='label'></Text>
<Text className='value'>{formatDateTime(detail.end_time)}</Text>
</View>
)}
</View>
</View>
)}
{/* 课程简介 */} {/* 课程简介 */}
<View className='section'> <View className='section'>
<Text className='section-title'></Text> <Text className='section-title'></Text>

View File

@@ -92,9 +92,31 @@ export default function Checkout() {
}, [items]) }, [items])
const submitOrder = async () => { const submitOrder = async () => {
if (!address) { // 免费课程不需要地址
Taro.showToast({ title: '请选择收货地址', icon: 'none' }) const isFreeCourse = params.type === 'course' && items.length > 0 && Number(items[0].price) === 0
return
if (!address && !isFreeCourse) {
// 尝试调用 chooseAddress
try {
await chooseAddress()
if (!address) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
} catch (e) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
}
// 如果是免费课程且没有地址,使用默认值
const orderAddress = address || {
userName: '免费课程学员',
telNumber: '13800000000',
provinceName: '',
cityName: '',
countyName: '',
detailInfo: '线上课程'
} }
Taro.showLoading({ title: '提交中...' }) Taro.showLoading({ title: '提交中...' })
@@ -102,11 +124,13 @@ export default function Checkout() {
try { try {
const orderPromises = items.map(item => { const orderPromises = items.map(item => {
const type = params.type || 'config' const type = params.type || 'config'
// 构造订单数据
const orderData: any = { const orderData: any = {
quantity: item.quantity, quantity: item.quantity,
customer_name: address.userName, customer_name: orderAddress.userName,
phone_number: address.telNumber, phone_number: orderAddress.telNumber,
shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`, shipping_address: `${orderAddress.provinceName}${orderAddress.cityName}${orderAddress.countyName}${orderAddress.detailInfo}`,
ref_code: Taro.getStorageSync('ref_code') || '' ref_code: Taro.getStorageSync('ref_code') || ''
} }

View File

@@ -23,7 +23,19 @@ export default function Payment() {
const handlePay = async () => { const handlePay = async () => {
if (!order) return if (!order) return
setLoading(true) setLoading(true)
// 如果是免费订单,直接显示成功并跳转
if (parseFloat(order.total_price) <= 0) {
Taro.showToast({ title: '报名成功', icon: 'success' })
setTimeout(() => {
Taro.redirectTo({ url: '/pages/order/list' })
}, 1500)
setLoading(false)
return
}
try { try {
const params = await prepayMiniprogram(order.id) const params = await prepayMiniprogram(order.id)
@@ -81,7 +93,9 @@ export default function Payment() {
</View> </View>
<View className='btn-area safe-area-bottom'> <View className='btn-area safe-area-bottom'>
<Button className='btn-pay' onClick={handlePay} loading={loading}></Button> <Button className='btn-pay' onClick={handlePay} loading={loading}>
{parseFloat(order.total_price) <= 0 ? '确认报名' : '微信支付'}
</Button>
</View> </View>
</View> </View>
) )

View File

@@ -373,3 +373,49 @@
} }
} }
} }
/* Signup Form Styles */
.signup-form {
.form-field-wrapper {
&.custom-field {
padding: 12px 24px;
position: relative;
background-color: #fff; /* Ensure white background inside modal */
&::after {
content: '';
position: absolute;
bottom: 0;
left: 24px;
right: 0;
height: 1px;
background-color: #f0f0f0;
transform: scaleY(0.5);
}
.field-label {
font-size: 28px;
color: #333;
margin-bottom: 16px;
.required {
color: #ff4949;
margin-right: 4px;
}
}
.at-radio::before, .at-radio::after,
.at-checkbox::before, .at-checkbox::after,
.at-textarea::after {
display: none;
}
.at-textarea {
padding: 0;
background: transparent;
border: none;
}
}
}
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro' import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro'
import { View, Text, Image, Button, RichText } from '@tarojs/components' import { View, Text, Image, Button, RichText, Picker } from '@tarojs/components'
import { AtIcon, AtProgress, AtModal, AtModalHeader, AtModalContent, AtModalAction, AtInput } from 'taro-ui' import { AtIcon, AtProgress, AtModal, AtModalHeader, AtModalContent, AtModalAction, AtInput, AtTextarea, AtRadio, AtCheckbox } from 'taro-ui'
import { getActivityDetail, signupActivity } from '../../../api' import { getActivityDetail, signupActivity } from '../../../api'
import { marked } from 'marked' import { marked } from 'marked'
import './detail.scss' import './detail.scss'
@@ -79,6 +79,12 @@ const ActivityDetail = () => {
setShowSignupModal(true) setShowSignupModal(true)
return return
} }
// Check if already unpaid (resume payment)
if (activity.my_signup_status === 'unpaid' && activity.my_order_id) {
Taro.navigateTo({ url: `/pages/order/payment?id=${activity.my_order_id}` })
return
}
// Direct signup if no config // Direct signup if no config
submitSignup({}) submitSignup({})
@@ -87,7 +93,18 @@ const ActivityDetail = () => {
const submitSignup = async (data: any) => { const submitSignup = async (data: any) => {
setSubmitting(true) setSubmitting(true)
try { try {
await signupActivity(Number(id), { signup_info: data }) const res = await signupActivity(Number(id), { signup_info: data })
// Handle payment if order_id is returned
if (res.order_id) {
Taro.showToast({ title: '即将跳转支付', icon: 'none' })
setShowSignupModal(false)
setTimeout(() => {
Taro.navigateTo({ url: `/pages/order/payment?id=${res.order_id}` })
}, 1500)
return
}
Taro.showToast({ title: '报名成功', icon: 'success' }) Taro.showToast({ title: '报名成功', icon: 'success' })
setShowSignupModal(false) setShowSignupModal(false)
fetchDetail() // Refresh status fetchDetail() // Refresh status
@@ -224,7 +241,9 @@ const ActivityDetail = () => {
{/* Footer Action Bar */} {/* Footer Action Bar */}
<View className='footer-action-bar'> <View className='footer-action-bar'>
<View className='left-info'> <View className='left-info'>
<Text className='price'></Text> <Text className='price'>
{Number(activity.price) > 0 ? `¥${activity.price}` : '免费'}
</Text>
<Text className='desc'></Text> <Text className='desc'></Text>
</View> </View>
<Button <Button
@@ -251,18 +270,100 @@ const ActivityDetail = () => {
<AtModalHeader></AtModalHeader> <AtModalHeader></AtModalHeader>
<AtModalContent> <AtModalContent>
<View className='signup-form'> <View className='signup-form'>
{activity.signup_form_config && activity.signup_form_config.map((field, idx) => ( {activity.signup_form_config && activity.signup_form_config.map((field, idx) => {
<AtInput if (field.type === 'select') {
key={idx} const currentOption = field.options?.find(opt => opt.value === formData[field.name])
name={field.name} return (
title={field.label} <View key={idx} className='form-field-wrapper'>
type={field.type || 'text'} <Picker
placeholder={`请输入${field.label}`} mode='selector'
value={formData[field.name]} range={field.options || []}
onChange={(val) => handleFormChange(field.name, val)} rangeKey='label'
required={field.required} onChange={(e) => {
/> const index = e.detail.value
))} const selected = field.options?.[index]
if (selected) {
handleFormChange(field.name, selected.value)
}
}}
>
<AtInput
name={field.name}
title={field.label}
type='text'
placeholder={field.placeholder || `请选择${field.label}`}
value={currentOption ? currentOption.label : ''}
editable={false}
required={field.required}
/>
</Picker>
</View>
)
}
if (field.type === 'radio') {
return (
<View key={idx} className='form-field-wrapper custom-field'>
<View className='field-label'>
{field.required && <Text className='required'>*</Text>}
{field.label}
</View>
<AtRadio
options={field.options || []}
value={formData[field.name]}
onClick={(val) => handleFormChange(field.name, val)}
/>
</View>
)
}
if (field.type === 'checkbox') {
return (
<View key={idx} className='form-field-wrapper custom-field'>
<View className='field-label'>
{field.required && <Text className='required'>*</Text>}
{field.label}
</View>
<AtCheckbox
options={field.options || []}
selectedList={formData[field.name] || []}
onChange={(val) => handleFormChange(field.name, val)}
/>
</View>
)
}
if (field.type === 'textarea') {
return (
<View key={idx} className='form-field-wrapper custom-field'>
<View className='field-label'>
{field.required && <Text className='required'>*</Text>}
{field.label}
</View>
<AtTextarea
value={formData[field.name] || ''}
onChange={(val) => handleFormChange(field.name, val)}
placeholder={field.placeholder || `请输入${field.label}`}
maxLength={500}
/>
</View>
)
}
return (
<View key={idx} className='form-field-wrapper'>
<AtInput
name={field.name}
title={field.label}
type={field.type === 'tel' ? 'phone' : (field.type === 'number' ? 'number' : 'text')}
placeholder={field.placeholder || `请输入${field.label}`}
value={formData[field.name]}
onChange={(val) => handleFormChange(field.name, val)}
required={field.required}
/>
</View>
)
})}
</View> </View>
</AtModalContent> </AtModalContent>
<AtModalAction> <AtModalAction>