报名表单

This commit is contained in:
jeremygan2021
2026-02-23 15:07:55 +08:00
parent 6a391c5eab
commit db7a3bd000
11 changed files with 289 additions and 22 deletions

View File

@@ -39,8 +39,8 @@ class ActivityAdmin(ModelAdmin):
('基本信息', { ('基本信息', {
'fields': ('title', 'description', 'banner', 'banner_url', 'is_active') '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',) 'classes': ('tab',)
}), }),
('报名设置', { ('报名设置', {
@@ -63,21 +63,34 @@ class ActivityAdmin(ModelAdmin):
@admin.register(ActivitySignup) @admin.register(ActivitySignup)
class ActivitySignupAdmin(ModelAdmin): 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') list_filter = ('status', 'signup_time', 'activity')
search_fields = ('user__nickname', 'activity__title') search_fields = ('user__nickname', 'activity__title')
autocomplete_fields = ['activity', 'user'] autocomplete_fields = ['activity', 'user']
fieldsets = ( fieldsets = (
('报名详情', { ('报名详情', {
'fields': ('activity', 'user', 'status') 'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
}), }),
('时间信息', { ('时间信息', {
'fields': ('signup_time',), 'fields': ('signup_time',),
'classes': ('collapse',) '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('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
except:
return str(obj.signup_info)
@display( @display(
description="状态", description="状态",
@@ -90,6 +103,12 @@ class ActivitySignupAdmin(ModelAdmin):
def status_label(self, obj): def status_label(self, obj):
return obj.status return obj.status
@display(description="关联订单")
def order_link(self, obj):
if obj.order:
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
return "-"
@admin.register(Topic) @admin.register(Topic)
class TopicAdmin(ModelAdmin): class TopicAdmin(ModelAdmin):
list_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at') list_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at')

View File

@@ -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='关联订单'),
),
]

View File

@@ -13,6 +13,11 @@ class Activity(models.Model):
end_time = models.DateTimeField(verbose_name="结束时间") end_time = models.DateTimeField(verbose_name="结束时间")
location = models.CharField(max_length=100, verbose_name="活动地点") location = models.CharField(max_length=100, verbose_name="活动地点")
max_participants = models.IntegerField(default=50, 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="是否启用") is_active = models.BooleanField(default=True, verbose_name="是否启用")
# 常用报名信息开关 # 常用报名信息开关
@@ -81,6 +86,9 @@ class ActivitySignup(models.Model):
blank=True blank=True
) )
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态") 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): def __str__(self):
return f"{self.user.nickname} - {self.activity.title}" return f"{self.user.nickname} - {self.activity.title}"

View File

@@ -8,18 +8,23 @@ class ActivitySerializer(serializers.ModelSerializer):
signup_form_config = serializers.SerializerMethodField() signup_form_config = serializers.SerializerMethodField()
current_signups = serializers.IntegerField(read_only=True) current_signups = serializers.IntegerField(read_only=True)
has_signed_up = serializers.SerializerMethodField() has_signed_up = serializers.SerializerMethodField()
is_signed_up = serializers.SerializerMethodField()
class Meta: class Meta:
model = Activity model = Activity
fields = '__all__' fields = '__all__'
def get_has_signed_up(self, obj): 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') request = self.context.get('request')
if not request: if not request:
return False return False
user = get_current_wechat_user(request) user = get_current_wechat_user(request)
if user: 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 return False
def get_signup_form_config(self, obj): def get_signup_form_config(self, obj):

View File

@@ -8,7 +8,8 @@ from django.utils import timezone
from django.db import models from django.db import models
from drf_spectacular.utils import extend_schema 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 .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer
from .utils import get_current_wechat_user from .utils import get_current_wechat_user
@@ -37,8 +38,9 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
activity = self.get_object() activity = self.get_object()
# Check if already signed up # Check if already signed up (and not cancelled)
if ActivitySignup.objects.filter(activity=activity, user=user).exists(): existing_signup = ActivitySignup.objects.filter(activity=activity, user=user).exclude(status='cancelled').first()
if existing_signup:
return Response({'error': '您已报名该活动'}, status=400) return Response({'error': '您已报名该活动'}, status=400)
# Check limit (exclude cancelled) # Check limit (exclude cancelled)
@@ -49,12 +51,7 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
# Get signup info # Get signup info
signup_info = request.data.get('signup_info', {}) signup_info = request.data.get('signup_info', {})
# Basic validation # Validate signup info
# 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.
effective_config = activity.signup_form_config effective_config = activity.signup_form_config
if not effective_config: if not effective_config:
effective_config = [] effective_config = []
@@ -70,17 +67,87 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
if effective_config: if effective_config:
required_fields = [f['name'] for f in effective_config if f.get('required')] required_fields = [f['name'] for f in effective_config if f.get('required')]
for field in required_fields: for field in required_fields:
# Simple check: field exists and is not empty string (if it's a string)
val = signup_info.get(field) val = signup_info.get(field)
if val is None or (isinstance(val, str) and not val.strip()): 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) label = next((f['label'] for f in effective_config if f['name'] == field), field)
return Response({'error': f'请填写: {label}'}, status=400) 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( signup = ActivitySignup.objects.create(
activity=activity, activity=activity,
user=user, user=user,
signup_info=signup_info signup_info=signup_info,
status='confirmed'
) )
serializer = ActivitySignupSerializer(signup) serializer = ActivitySignupSerializer(signup)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)

View File

@@ -109,7 +109,7 @@ 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', '6433'),
'PORT': os.environ.get('DB_PORT', '5432'), 'PORT': os.environ.get('DB_PORT', '5432'),
} }

View File

@@ -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='所选活动'),
),
]

View File

@@ -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') 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') 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="数量") quantity = models.IntegerField(default=1, verbose_name="数量")
total_price = models.DecimalField(max_digits=10, decimal_places=2, 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="订单状态") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")

View File

@@ -508,6 +508,19 @@ def payment_finish(request):
order.save() order.save()
print(f"订单 {order.id} 状态已更新") 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: try:
salesperson = order.salesperson salesperson = order.salesperson

View File

@@ -15,6 +15,8 @@ const MyOrders = () => {
const [activities, setActivities] = useState([]); const [activities, setActivities] = useState([]);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [currentOrder, setCurrentOrder] = useState(null); const [currentOrder, setCurrentOrder] = useState(null);
const [signupInfoModalVisible, setSignupInfoModalVisible] = useState(false);
const [currentSignupInfo, setCurrentSignupInfo] = useState(null);
const [loginVisible, setLoginVisible] = useState(false); const [loginVisible, setLoginVisible] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -31,6 +33,11 @@ const MyOrders = () => {
setModalVisible(true); setModalVisible(true);
}; };
const showSignupInfo = (info) => {
setCurrentSignupInfo(info);
setSignupInfoModalVisible(true);
};
const handleQueryData = async () => { const handleQueryData = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -251,7 +258,14 @@ const MyOrders = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
<Tag color="blue">{activity.status || '已报名'}</Tag> <Tag color="blue">{activity.status || '已报名'}</Tag>
<Button type="primary" size="small" ghost>查看详情</Button> <Space>
{item.signup_info && Object.keys(item.signup_info).length > 0 && (
<Button size="small" onClick={(e) => { e.stopPropagation(); showSignupInfo(item.signup_info); }}>
查看报名信息
</Button>
)}
<Button type="primary" size="small" ghost>查看详情</Button>
</Space>
</div> </div>
</div> </div>
</Card> </Card>
@@ -334,6 +348,27 @@ const MyOrders = () => {
} }
}} }}
/> />
<Modal
title="报名信息详情"
open={signupInfoModalVisible}
onCancel={() => setSignupInfoModalVisible(false)}
footer={[
<Button key="close" onClick={() => setSignupInfoModalVisible(false)}>
关闭
</Button>
]}
>
{currentSignupInfo && (
<Descriptions column={1} bordered>
{Object.entries(currentSignupInfo).map(([key, value]) => (
<Descriptions.Item label={key} key={key}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</Descriptions.Item>
))}
</Descriptions>
)}
</Modal>
</div> </div>
</div> </div>
); );

View File

@@ -6,7 +6,7 @@ import { motion, useScroll, useTransform } from 'framer-motion';
import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, UserOutlined, UploadOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, UserOutlined, UploadOutlined } from '@ant-design/icons';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { message, Spin, Button, Result, Modal, Form, Input, Select, Radio, Checkbox, Upload } from 'antd'; 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 styles from '../../components/activity/activity.module.less';
import { pageTransition, buttonTap } from '../../animation'; import { pageTransition, buttonTap } from '../../animation';
import LoginModal from '../../components/LoginModal'; import LoginModal from '../../components/LoginModal';
@@ -16,6 +16,7 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { QRCodeSVG } from 'qrcode.react';
const ActivityDetail = () => { const ActivityDetail = () => {
const { id } = useParams(); const { id } = useParams();
@@ -25,6 +26,8 @@ const ActivityDetail = () => {
const { login } = useAuth(); const { login } = useAuth();
const [loginVisible, setLoginVisible] = useState(false); const [loginVisible, setLoginVisible] = useState(false);
const [signupFormVisible, setSignupFormVisible] = useState(false); const [signupFormVisible, setSignupFormVisible] = useState(false);
const [paymentModalVisible, setPaymentModalVisible] = useState(false);
const [paymentInfo, setPaymentInfo] = useState(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
// Header animation: transparent to white with shadow // Header animation: transparent to white with shadow
@@ -67,7 +70,15 @@ const ActivityDetail = () => {
const signUpMutation = useMutation({ const signUpMutation = useMutation({
mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }), 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('报名成功!'); message.success('报名成功!');
setSignupFormVisible(false); setSignupFormVisible(false);
confetti({ 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 handleShare = async () => {
const url = window.location.href; const url = window.location.href;
if (navigator.share) { if (navigator.share) {
@@ -399,6 +442,32 @@ const ActivityDetail = () => {
})} })}
</Form> </Form>
</Modal> </Modal>
<Modal
title="微信支付"
open={paymentModalVisible}
onCancel={() => setPaymentModalVisible(false)}
footer={null}
destroyOnHidden
width={360}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
{paymentInfo?.code_url ? (
<>
<QRCodeSVG value={paymentInfo.code_url} size={200} />
<p style={{ marginTop: 20, fontSize: 16, 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> </motion.div>
); );
}; };