报名表单

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': ('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('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', 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('<a href="/admin/shop/order/{}/change/">Order #{}</a>', 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')

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="结束时间")
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}"

View File

@@ -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):

View File

@@ -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)

View File

@@ -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'),
}

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')
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="订单状态")

View File

@@ -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

View File

@@ -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 = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
<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>
</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>
);

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 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 = () => {
})}
</Form>
</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>
);
};