报名表单
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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='关联订单'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
|
||||
20
backend/shop/migrations/0032_order_activity.py
Normal file
20
backend/shop/migrations/0032_order_activity.py
Normal 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='所选活动'),
|
||||
),
|
||||
]
|
||||
@@ -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="订单状态")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user