diff --git a/backend/community/admin.py b/backend/community/admin.py index 0c24fbf..9b858b3 100644 --- a/backend/community/admin.py +++ b/backend/community/admin.py @@ -39,8 +39,8 @@ class ActivityAdmin(ModelAdmin): ('基本信息', { 'fields': ('title', 'description', 'banner', 'banner_url', 'is_active') }), - ('时间与地点', { - 'fields': ('start_time', 'end_time', 'location'), + ('费用与时间', { + 'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'), 'classes': ('tab',) }), ('报名设置', { @@ -63,21 +63,34 @@ class ActivityAdmin(ModelAdmin): @admin.register(ActivitySignup) class ActivitySignupAdmin(ModelAdmin): - list_display = ('activity', 'user', 'signup_time', 'status_label') + list_display = ('activity', 'user', 'signup_time', 'status_label', 'order_link') list_filter = ('status', 'signup_time', 'activity') search_fields = ('user__nickname', 'activity__title') autocomplete_fields = ['activity', 'user'] fieldsets = ( ('报名详情', { - 'fields': ('activity', 'user', 'status') + 'fields': ('activity', 'user', 'status', 'order', 'signup_info_display') }), ('时间信息', { 'fields': ('signup_time',), 'classes': ('collapse',) }), ) - readonly_fields = ('signup_time',) + readonly_fields = ('signup_time', 'signup_info_display') + + @display(description="报名信息") + def signup_info_display(self, obj): + import json + if not obj.signup_info: + return "无" + + try: + # Format JSON nicely + formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False) + return format_html('
{}
', formatted_json) + except: + return str(obj.signup_info) @display( description="状态", @@ -90,6 +103,12 @@ class ActivitySignupAdmin(ModelAdmin): def status_label(self, obj): return obj.status + @display(description="关联订单") + def order_link(self, obj): + if obj.order: + return format_html('Order #{}', obj.order.id, obj.order.id) + return "-" + @admin.register(Topic) class TopicAdmin(ModelAdmin): list_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at') diff --git a/backend/community/migrations/0010_activity_is_paid_activity_price_activitysignup_order.py b/backend/community/migrations/0010_activity_is_paid_activity_price_activitysignup_order.py new file mode 100644 index 0000000..ec6b871 --- /dev/null +++ b/backend/community/migrations/0010_activity_is_paid_activity_price_activitysignup_order.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-23 07:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0009_activity_ask_company_activity_ask_name_and_more'), + ('shop', '0031_adminphonenumber'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='is_paid', + field=models.BooleanField(default=False, verbose_name='是否收费'), + ), + migrations.AddField( + model_name='activity', + name='price', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用'), + ), + migrations.AddField( + model_name='activitysignup', + name='order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', verbose_name='关联订单'), + ), + ] diff --git a/backend/community/models.py b/backend/community/models.py index af2b612..71fa95f 100644 --- a/backend/community/models.py +++ b/backend/community/models.py @@ -13,6 +13,11 @@ class Activity(models.Model): end_time = models.DateTimeField(verbose_name="结束时间") location = models.CharField(max_length=100, verbose_name="活动地点") max_participants = models.IntegerField(default=50, verbose_name="最大报名人数") + + # 费用设置 + is_paid = models.BooleanField(default=False, verbose_name="是否收费") + price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用") + is_active = models.BooleanField(default=True, verbose_name="是否启用") # 常用报名信息开关 @@ -81,6 +86,9 @@ class ActivitySignup(models.Model): blank=True ) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态") + + # 关联订单(针对付费活动) + order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups') def __str__(self): return f"{self.user.nickname} - {self.activity.title}" diff --git a/backend/community/serializers.py b/backend/community/serializers.py index 647f34c..ef3abfd 100644 --- a/backend/community/serializers.py +++ b/backend/community/serializers.py @@ -8,18 +8,23 @@ class ActivitySerializer(serializers.ModelSerializer): signup_form_config = serializers.SerializerMethodField() current_signups = serializers.IntegerField(read_only=True) has_signed_up = serializers.SerializerMethodField() + is_signed_up = serializers.SerializerMethodField() class Meta: model = Activity fields = '__all__' def get_has_signed_up(self, obj): + return self.get_is_signed_up(obj) + + def get_is_signed_up(self, obj): request = self.context.get('request') if not request: return False user = get_current_wechat_user(request) if user: - return obj.signups.filter(user=user).exists() + # Check if there is a valid signup (not cancelled) + return obj.signups.filter(user=user).exclude(status='cancelled').exists() return False def get_signup_form_config(self, obj): diff --git a/backend/community/views.py b/backend/community/views.py index 10186c7..93ad050 100644 --- a/backend/community/views.py +++ b/backend/community/views.py @@ -8,7 +8,8 @@ from django.utils import timezone from django.db import models from drf_spectacular.utils import extend_schema -from shop.models import WeChatUser +from shop.models import WeChatUser, Order +from shop.views import get_wechat_pay_client from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer from .utils import get_current_wechat_user @@ -37,8 +38,9 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet): activity = self.get_object() - # Check if already signed up - if ActivitySignup.objects.filter(activity=activity, user=user).exists(): + # Check if already signed up (and not cancelled) + existing_signup = ActivitySignup.objects.filter(activity=activity, user=user).exclude(status='cancelled').first() + if existing_signup: return Response({'error': '您已报名该活动'}, status=400) # Check limit (exclude cancelled) @@ -49,12 +51,7 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet): # Get signup info signup_info = request.data.get('signup_info', {}) - # Basic validation - # Re-fetch the config from the object method or serializer logic if needed, - # but here we can just use the serializer's method to get the effective config. - # However, accessing serializer method from view is tricky without instantiating. - # Let's replicate the logic or rely on the fact that we can construct it. - + # Validate signup info effective_config = activity.signup_form_config if not effective_config: effective_config = [] @@ -70,17 +67,87 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet): if effective_config: required_fields = [f['name'] for f in effective_config if f.get('required')] for field in required_fields: - # Simple check: field exists and is not empty string (if it's a string) val = signup_info.get(field) if val is None or (isinstance(val, str) and not val.strip()): - # Try to find label for better error message label = next((f['label'] for f in effective_config if f['name'] == field), field) return Response({'error': f'请填写: {label}'}, status=400) + + # Handle Payment Logic + if activity.is_paid and activity.price > 0: + import time + from wechatpayv3 import WeChatPayType + # Create Order + # Check if there is a pending order + pending_order = Order.objects.filter( + wechat_user=user, + activity=activity, + status='pending' + ).first() + + if pending_order: + order = pending_order + # Update info if needed? Maybe not. + else: + order = Order.objects.create( + wechat_user=user, + activity=activity, + total_price=activity.price, + status='pending', + quantity=1, + customer_name=signup_info.get('name') or user.nickname or 'Activity User', + phone_number=signup_info.get('phone') or user.phone_number or '', + ) + + # Generate Pay Code + out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}" + order.out_trade_no = out_trade_no + order.save() + + wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE) + if not wxpay: + return Response({'error': f'支付配置错误: {error_msg}'}, status=500) + + code, message = wxpay.pay( + description=f"报名: {activity.title}", + out_trade_no=out_trade_no, + amount={ + 'total': int(activity.price * 100), + 'currency': 'CNY' + }, + notify_url=wxpay._notify_url + ) + + import json + result = json.loads(message) + if code in range(200, 300): + code_url = result.get('code_url') + + # Create a pending signup record so we can update it later + ActivitySignup.objects.create( + activity=activity, + user=user, + signup_info=signup_info, + status='pending', + order=order + ) + + return Response({ + 'payment_required': True, + 'code_url': code_url, + 'order_id': order.id, + 'price': activity.price, + 'message': '请完成支付' + }, status=200) + else: + return Response({'error': '支付接口调用失败', 'detail': result}, status=500) + + # Free Activity Signup signup = ActivitySignup.objects.create( activity=activity, user=user, - signup_info=signup_info + signup_info=signup_info, + status='confirmed' ) serializer = ActivitySignupSerializer(signup) return Response(serializer.data, status=201) diff --git a/backend/config/settings.py b/backend/config/settings.py index b44719b..1bb69af 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -109,7 +109,7 @@ if DB_HOST: 'USER': os.environ.get('DB_USER', 'market'), 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'), 'HOST': DB_HOST, - # 'PORT': os.environ.get('DB_PORT', '6433'), + #'PORT': os.environ.get('DB_PORT', '6433'), 'PORT': os.environ.get('DB_PORT', '5432'), } diff --git a/backend/shop/migrations/0032_order_activity.py b/backend/shop/migrations/0032_order_activity.py new file mode 100644 index 0000000..48893e1 --- /dev/null +++ b/backend/shop/migrations/0032_order_activity.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-02-23 07:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0010_activity_is_paid_activity_price_activitysignup_order'), + ('shop', '0031_adminphonenumber'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='community.activity', verbose_name='所选活动'), + ), + ] diff --git a/backend/shop/models.py b/backend/shop/models.py index 42346c7..3447f8c 100644 --- a/backend/shop/models.py +++ b/backend/shop/models.py @@ -230,6 +230,7 @@ class Order(models.Model): config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True, related_name='orders') course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders') + activity = models.ForeignKey('community.Activity', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选活动", related_name='orders') quantity = models.IntegerField(default=1, verbose_name="数量") total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态") diff --git a/backend/shop/views.py b/backend/shop/views.py index aca8d2d..8e95ab9 100644 --- a/backend/shop/views.py +++ b/backend/shop/views.py @@ -508,6 +508,19 @@ def payment_finish(request): order.save() print(f"订单 {order.id} 状态已更新") + # Handle Activity Signup + if hasattr(order, 'activity') and order.activity: + try: + # Use string import to avoid circular dependency at module level + from community.models import ActivitySignup + signup = ActivitySignup.objects.filter(order=order).first() + if signup: + signup.status = 'confirmed' + signup.save() + print(f"活动报名状态已更新: {signup.id}") + except Exception as e: + print(f"更新活动报名状态失败: {str(e)}") + # 计算佣金 (旧版销售员系统) try: salesperson = order.salesperson diff --git a/frontend/src/pages/MyOrders.jsx b/frontend/src/pages/MyOrders.jsx index 24318e5..90722a4 100644 --- a/frontend/src/pages/MyOrders.jsx +++ b/frontend/src/pages/MyOrders.jsx @@ -15,6 +15,8 @@ const MyOrders = () => { const [activities, setActivities] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [currentOrder, setCurrentOrder] = useState(null); + const [signupInfoModalVisible, setSignupInfoModalVisible] = useState(false); + const [currentSignupInfo, setCurrentSignupInfo] = useState(null); const [loginVisible, setLoginVisible] = useState(false); const navigate = useNavigate(); @@ -31,6 +33,11 @@ const MyOrders = () => { setModalVisible(true); }; + const showSignupInfo = (info) => { + setCurrentSignupInfo(info); + setSignupInfoModalVisible(true); + }; + const handleQueryData = async () => { setLoading(true); try { @@ -251,7 +258,14 @@ const MyOrders = () => {
{activity.status || '已报名'} - + + {item.signup_info && Object.keys(item.signup_info).length > 0 && ( + + )} + +
@@ -334,6 +348,27 @@ const MyOrders = () => { } }} /> + + setSignupInfoModalVisible(false)} + footer={[ + + ]} + > + {currentSignupInfo && ( + + {Object.entries(currentSignupInfo).map(([key, value]) => ( + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + + ))} + + )} + ); diff --git a/frontend/src/pages/activity/Detail.jsx b/frontend/src/pages/activity/Detail.jsx index aa08c0b..848fbf9 100644 --- a/frontend/src/pages/activity/Detail.jsx +++ b/frontend/src/pages/activity/Detail.jsx @@ -6,7 +6,7 @@ import { motion, useScroll, useTransform } from 'framer-motion'; import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, UserOutlined, UploadOutlined } 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 } from '../../api'; +import { getActivityDetail, signUpActivity, queryOrderStatus } from '../../api'; import styles from '../../components/activity/activity.module.less'; import { pageTransition, buttonTap } from '../../animation'; import LoginModal from '../../components/LoginModal'; @@ -16,6 +16,7 @@ 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(); @@ -25,6 +26,8 @@ const ActivityDetail = () => { const { login } = 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 @@ -67,7 +70,15 @@ const ActivityDetail = () => { const signUpMutation = useMutation({ mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }), - onSuccess: () => { + onSuccess: (data) => { + if (data.payment_required) { + setPaymentInfo(data); + setPaymentModalVisible(true); + setSignupFormVisible(false); + message.info(data.message || '请扫码支付'); + return; + } + message.success('报名成功!'); setSignupFormVisible(false); confetti({ @@ -84,6 +95,38 @@ const ActivityDetail = () => { } }); + // 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) { @@ -399,6 +442,32 @@ const ActivityDetail = () => { })} + setPaymentModalVisible(false)} + footer={null} + destroyOnHidden + width={360} + > +
+ {paymentInfo?.code_url ? ( + <> + +

¥{paymentInfo.price}

+

请使用微信扫一扫支付

+ + ) : ( + + )} +
+ +
+
+
+ ); };