解决报名支付
All checks were successful
Deploy to Server / deploy (push) Successful in 37s

This commit is contained in:
jeremygan2021
2026-02-23 16:02:09 +08:00
parent 0bf5f94483
commit 6bbbb49d90
4 changed files with 74 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer, OrderSerializer from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
from .utils import get_current_wechat_user from .utils import get_current_wechat_user
class ActivitySerializer(serializers.ModelSerializer): class ActivitySerializer(serializers.ModelSerializer):
@@ -47,11 +47,10 @@ class ActivitySerializer(serializers.ModelSerializer):
class ActivitySignupSerializer(serializers.ModelSerializer): class ActivitySignupSerializer(serializers.ModelSerializer):
activity_info = serializers.SerializerMethodField() activity_info = serializers.SerializerMethodField()
order = OrderSerializer(read_only=True)
class Meta: class Meta:
model = ActivitySignup model = ActivitySignup
fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info', 'order'] fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info']
read_only_fields = ['signup_time', 'status', 'user'] read_only_fields = ['signup_time', 'status', 'user']
def get_activity_info(self, obj): def get_activity_info(self, obj):

View File

@@ -207,7 +207,6 @@ class OrderSerializer(serializers.ModelSerializer):
""" """
config_name = serializers.CharField(source='config.name', read_only=True) config_name = serializers.CharField(source='config.name', read_only=True)
course_title = serializers.CharField(source='course.title', read_only=True) course_title = serializers.CharField(source='course.title', read_only=True)
activity_title = serializers.CharField(source='activity.title', read_only=True)
config_image = serializers.SerializerMethodField() config_image = serializers.SerializerMethodField()
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)
@@ -216,7 +215,7 @@ class OrderSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Order model = Order
fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'activity', 'activity_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no', fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no',
'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number'] 'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number']
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at'] read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at']
extra_kwargs = { extra_kwargs = {

View File

@@ -1,7 +1,8 @@
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 api, { getMySignups } from '../api'; import { queryMyOrders, 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';
@@ -40,6 +41,8 @@ const MyOrders = () => {
const handleQueryData = async () => { const handleQueryData = async () => {
setLoading(true); setLoading(true);
try { try {
const { default: api } = await import('../api');
// Parallel fetch // Parallel fetch
const [ordersRes, activitiesRes] = await Promise.allSettled([ const [ordersRes, activitiesRes] = await Promise.allSettled([
api.get('/orders/'), api.get('/orders/'),
@@ -341,7 +344,7 @@ const MyOrders = () => {
onLoginSuccess={(userData) => { onLoginSuccess={(userData) => {
login(userData); login(userData);
if (userData.phone_number) { if (userData.phone_number) {
handleQueryData(); handleQueryOrders(userData.phone_number);
} }
}} }}
/> />

View File

@@ -28,7 +28,7 @@ const ActivityDetail = () => {
const [signupFormVisible, setSignupFormVisible] = useState(false); const [signupFormVisible, setSignupFormVisible] = useState(false);
const [paymentModalVisible, setPaymentModalVisible] = useState(false); const [paymentModalVisible, setPaymentModalVisible] = useState(false);
const [paymentInfo, setPaymentInfo] = useState(null); const [paymentInfo, setPaymentInfo] = useState(null);
const [isPaidSuccess, setIsPaidSuccess] = useState(false); const [paymentStatus, setPaymentStatus] = useState('unpaid'); // 'unpaid' | 'paying' | 'paid'
const [form] = Form.useForm(); const [form] = Form.useForm();
// Header animation: transparent to white with shadow // Header animation: transparent to white with shadow
@@ -93,13 +93,17 @@ const ActivityDetail = () => {
const signUpMutation = useMutation({ const signUpMutation = useMutation({
mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }), mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }),
onSuccess: (data) => { onSuccess: (response) => {
// API returns axios response object, so we need to access .data
const data = response.data || response; // Fallback in case it was already unwrapped
console.log('Signup response:', data);
// 检查是否需要支付 // 检查是否需要支付
if (data.payment_required) { if (data.payment_required) {
setPaymentInfo(data); setPaymentInfo(data);
setIsPaidSuccess(false); setPaymentStatus('paying');
// 先关闭报名表单,确保层级正确
setSignupFormVisible(false); // 不关闭报名表单,直接打开支付弹窗
// 延迟一点点时间打开支付弹窗,避免状态更新冲突 // 延迟一点点时间打开支付弹窗,避免状态更新冲突
setTimeout(() => { setTimeout(() => {
setPaymentModalVisible(true); setPaymentModalVisible(true);
@@ -110,6 +114,7 @@ const ActivityDetail = () => {
message.success('报名成功!'); message.success('报名成功!');
setSignupFormVisible(false); setSignupFormVisible(false);
setPaymentStatus('paid'); // In case it was free but we track it
confetti({ confetti({
particleCount: 150, particleCount: 150,
spread: 70, spread: 70,
@@ -127,13 +132,15 @@ const ActivityDetail = () => {
// Polling for payment status // Polling for payment status
useEffect(() => { useEffect(() => {
let timer; let timer;
if (paymentModalVisible && paymentInfo?.order_id && !isPaidSuccess) { if (paymentModalVisible && paymentInfo?.order_id) {
timer = setInterval(async () => { timer = setInterval(async () => {
try { try {
const response = await queryOrderStatus(paymentInfo.order_id); const response = await queryOrderStatus(paymentInfo.order_id);
if (response.data.status === 'paid') { if (response.data.status === 'paid') {
setIsPaidSuccess(true); message.success('支付成功,请点击“完成报名”!');
message.success('支付成功,报名已确认!'); setPaymentModalVisible(false);
setPaymentInfo(null);
setPaymentStatus('paid');
// Trigger success effects // Trigger success effects
confetti({ confetti({
@@ -153,7 +160,7 @@ const ActivityDetail = () => {
}, 3000); }, 3000);
} }
return () => clearInterval(timer); return () => clearInterval(timer);
}, [paymentModalVisible, paymentInfo, id, queryClient, isPaidSuccess]); }, [paymentModalVisible, paymentInfo, id, queryClient]);
const handleShare = async () => { const handleShare = async () => {
const url = window.location.href; const url = window.location.href;
@@ -180,11 +187,15 @@ const ActivityDetail = () => {
return; return;
} }
// Check if we need to collect info setPaymentStatus('unpaid');
if (activity.signup_form_config && activity.signup_form_config.length > 0) { setPaymentInfo(null);
// Check if we need to collect info OR if it's a paid activity
// We want to use the modal for payment flow as well
if ((activity.signup_form_config && activity.signup_form_config.length > 0) || (activity.is_paid && activity.price > 0)) {
setSignupFormVisible(true); setSignupFormVisible(true);
} else { } else {
// Direct signup if no info needed // Direct signup if no info needed and free
signUpMutation.mutate({}); signUpMutation.mutate({});
} }
}; };
@@ -395,9 +406,36 @@ const ActivityDetail = () => {
title="填写报名信息" title="填写报名信息"
open={signupFormVisible} open={signupFormVisible}
onCancel={() => setSignupFormVisible(false)} onCancel={() => setSignupFormVisible(false)}
onOk={form.submit} footer={[
okText={activity?.is_paid && activity?.price > 0 ? `支付 ¥${activity.price}` : '提交报名'} <Button key="cancel" onClick={() => setSignupFormVisible(false)}>
confirmLoading={signUpMutation.isPending} 取消
</Button>,
(activity?.is_paid && activity?.price > 0) ? (
<React.Fragment key="paid-actions">
<Button
key="pay"
type="primary"
onClick={form.submit}
loading={signUpMutation.isPending && paymentStatus === 'unpaid'}
disabled={paymentStatus === 'paid'}
>
{paymentStatus === 'paid' ? '已支付' : `支付 ¥${activity.price}`}
</Button>
<Button
key="complete"
type="primary"
onClick={() => setSignupFormVisible(false)}
disabled={paymentStatus !== 'paid'}
>
完成报名
</Button>
</React.Fragment>
) : (
<Button key="submit" type="primary" onClick={form.submit} loading={signUpMutation.isPending}>
提交报名
</Button>
)
]}
destroyOnHidden destroyOnHidden
> >
<Form form={form} onFinish={handleFormSubmit} layout="vertical"> <Form form={form} onFinish={handleFormSubmit} layout="vertical">
@@ -476,27 +514,10 @@ const ActivityDetail = () => {
</Form> </Form>
</Modal> </Modal>
<Modal <Modal
title={isPaidSuccess ? "支付成功" : "微信支付"} title="微信支付"
open={paymentModalVisible} open={paymentModalVisible}
onCancel={() => { onCancel={() => setPaymentModalVisible(false)}
setPaymentModalVisible(false); footer={null}
if (isPaidSuccess) {
setPaymentInfo(null);
}
}}
footer={[
<Button
key="ok"
type="primary"
disabled={!isPaidSuccess}
onClick={() => {
setPaymentModalVisible(false);
setPaymentInfo(null);
}}
>
{isPaidSuccess ? '完成' : '等待支付...'}
</Button>
]}
destroyOnHidden destroyOnHidden
width={360} width={360}
zIndex={1001} // 确保层级高于其他弹窗 zIndex={1001} // 确保层级高于其他弹窗
@@ -504,26 +525,20 @@ const ActivityDetail = () => {
<div style={{ textAlign: 'center', padding: '20px 0' }}> <div style={{ textAlign: 'center', padding: '20px 0' }}>
{paymentInfo?.code_url ? ( {paymentInfo?.code_url ? (
<> <>
{!isPaidSuccess ? ( <div style={{ background: '#fff', padding: 10, display: 'inline-block' }}>
<> <QRCodeSVG value={paymentInfo.code_url} size={200} />
<div style={{ background: '#fff', padding: 10, display: 'inline-block' }}> </div>
<QRCodeSVG value={paymentInfo.code_url} size={200} /> <p style={{ marginTop: 20, fontSize: 18, fontWeight: 'bold' }}>¥{paymentInfo.price}</p>
</div> <p style={{ color: '#666' }}>请使用微信扫一扫支付</p>
<p style={{ marginTop: 20, fontSize: 18, fontWeight: 'bold' }}>¥{paymentInfo.price}</p>
<p style={{ color: '#666' }}>请使用微信扫一扫支付</p>
</>
) : (
<div style={{ padding: '20px 0' }}>
<div style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }}>
<i className="anticon anticon-check-circle"><svg viewBox="64 64 896 896" focusable="false" data-icon="check-circle" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path></svg></i>
</div>
<p style={{ fontSize: 16, fontWeight: 'bold' }}>支付成功</p>
</div>
)}
</> </>
) : ( ) : (
<Spin tip="正在生成二维码..." /> <Spin tip="正在生成二维码..." />
)} )}
<div style={{ marginTop: 20 }}>
<Button type="primary" onClick={() => window.location.reload()}>
我已支付
</Button>
</div>
</div> </div>
</Modal> </Modal>