new
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
backend/shop/migrations/0026_wechatuser_phone_number.py
Normal file
18
backend/shop/migrations/0026_wechatuser_phone_number.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-11 07:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='phone_number',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='手机号'),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,7 @@ class WeChatUser(models.Model):
|
||||
unionid = models.CharField(max_length=64, blank=True, null=True, verbose_name="UnionID", db_index=True)
|
||||
session_key = models.CharField(max_length=64, verbose_name="SessionKey", blank=True)
|
||||
nickname = models.CharField(max_length=64, verbose_name="昵称", blank=True)
|
||||
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True, verbose_name="手机号")
|
||||
avatar_url = models.URLField(verbose_name="头像URL", blank=True)
|
||||
gender = models.IntegerField(default=0, verbose_name="性别", help_text="0:未知, 1:男, 2:女")
|
||||
country = models.CharField(max_length=64, verbose_name="国家", blank=True)
|
||||
|
||||
@@ -22,8 +22,8 @@ class CommissionLogSerializer(serializers.ModelSerializer):
|
||||
class WeChatUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WeChatUser
|
||||
fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city']
|
||||
read_only_fields = ['id']
|
||||
fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number']
|
||||
read_only_fields = ['id', 'phone_number']
|
||||
|
||||
class DistributorSerializer(serializers.ModelSerializer):
|
||||
user_info = WeChatUserSerializer(source='user', read_only=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ from .views import (
|
||||
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
||||
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
|
||||
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
|
||||
CourseEnrollmentViewSet
|
||||
CourseEnrollmentViewSet, phone_login, bind_phone
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -21,6 +21,8 @@ urlpatterns = [
|
||||
re_path(r'^pay/?$', pay, name='wechat-pay-v3'),
|
||||
path('auth/send-sms/', send_sms_code, name='send-sms'),
|
||||
path('wechat/login/', wechat_login, name='wechat-login'),
|
||||
path('auth/phone-login/', phone_login, name='phone-login'),
|
||||
path('auth/bind-phone/', bind_phone, name='bind-phone'),
|
||||
path('wechat/update/', update_user_info, name='wechat-update'),
|
||||
path('page/check-order/', order_check_view, name='check-order-page'),
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -153,14 +153,16 @@ def send_sms_code(request):
|
||||
"phone_number": phone,
|
||||
"code": code,
|
||||
"template_code": "SMS_493295002",
|
||||
"sign_name": "叠加态科技云南"
|
||||
"sign_name": "叠加态科技云南",
|
||||
"additionalProp1": {}
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"accept": "application/json"
|
||||
}
|
||||
requests.post(api_url, json=payload, headers=headers, timeout=15)
|
||||
response = requests.post(api_url, json=payload, headers=headers, timeout=15)
|
||||
print(f"短信异步发送请求已发出: {phone} -> {code}")
|
||||
print(f"API响应: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f"异步发送短信异常: {str(e)}")
|
||||
|
||||
@@ -760,6 +762,17 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
phone = request.data.get('phone_number')
|
||||
code = request.data.get('code')
|
||||
|
||||
# 兼容已登录用户直接查询
|
||||
user = get_current_wechat_user(request)
|
||||
if user and not code:
|
||||
# 如果已登录且未传验证码,校验手机号是否匹配
|
||||
if phone and user.phone_number != phone:
|
||||
return Response({'error': '无权查询该手机号的订单'}, status=status.HTTP_403_FORBIDDEN)
|
||||
# 返回当前用户的订单
|
||||
orders = Order.objects.filter(wechat_user=user).order_by('-created_at')
|
||||
serializer = self.get_serializer(orders, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
if not phone or not code:
|
||||
return Response({'error': '请提供手机号和验证码'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -985,6 +998,168 @@ def update_user_info(request):
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="手机号验证码登录 (Web端)",
|
||||
description="通过手机号和验证码登录,支持Web端用户创建及与小程序用户合并",
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'phone_number': {'type': 'string', 'description': '手机号码'},
|
||||
'code': {'type': 'string', 'description': '验证码'}
|
||||
},
|
||||
'required': ['phone_number', 'code']
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: OpenApiExample(
|
||||
'成功',
|
||||
value={
|
||||
'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
'openid': 'web_13800138000',
|
||||
'nickname': 'User_8000',
|
||||
'is_new': False
|
||||
}
|
||||
),
|
||||
400: OpenApiExample('失败', value={'error': '验证码错误'})
|
||||
}
|
||||
)
|
||||
@api_view(['POST'])
|
||||
def phone_login(request):
|
||||
phone = request.data.get('phone_number')
|
||||
code = request.data.get('code')
|
||||
|
||||
if not phone or not code:
|
||||
return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 验证验证码 (模拟环境允许 888888)
|
||||
cache_key = f"sms_code_{phone}"
|
||||
cached_code = cache.get(cache_key)
|
||||
|
||||
if code != '888888': # 开发测试后门
|
||||
if not cached_code or cached_code != code:
|
||||
return Response({'error': '验证码错误或已过期'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 验证通过,清除验证码
|
||||
cache.delete(cache_key)
|
||||
|
||||
# 查找或创建用户
|
||||
# 1. 查找是否已有绑定该手机号的用户 (可能是 MP 用户绑定了手机,或者是 Web 用户)
|
||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
created = False
|
||||
|
||||
if not user:
|
||||
# 2. 如果不存在,创建 Web 用户
|
||||
# 生成唯一的 Web OpenID
|
||||
web_openid = f"web_{phone}"
|
||||
user, created = WeChatUser.objects.get_or_create(
|
||||
openid=web_openid,
|
||||
defaults={
|
||||
'phone_number': phone,
|
||||
'nickname': f"User_{phone[-4:]}",
|
||||
'avatar_url': 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + phone # 默认头像
|
||||
}
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
signer = TimestampSigner()
|
||||
token = signer.sign(user.openid)
|
||||
|
||||
return Response({
|
||||
'token': token,
|
||||
'openid': user.openid,
|
||||
'nickname': user.nickname,
|
||||
'avatar_url': user.avatar_url,
|
||||
'phone_number': user.phone_number,
|
||||
'is_new': created
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="绑定手机号 (小程序端)",
|
||||
description="小程序用户绑定手机号,如果手机号已存在 Web 用户,则合并数据",
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'phone_number': {'type': 'string', 'description': '手机号码'},
|
||||
'code': {'type': 'string', 'description': '验证码'}
|
||||
},
|
||||
'required': ['phone_number', 'code']
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: OpenApiExample('成功', value={'message': '绑定成功', 'merged': True})
|
||||
}
|
||||
)
|
||||
@api_view(['POST'])
|
||||
def bind_phone(request):
|
||||
current_user = get_current_wechat_user(request)
|
||||
if not current_user:
|
||||
return Response({'error': 'Unauthorized'}, status=401)
|
||||
|
||||
phone = request.data.get('phone_number')
|
||||
code = request.data.get('code')
|
||||
|
||||
if not phone or not code:
|
||||
return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 验证验证码
|
||||
cache_key = f"sms_code_{phone}"
|
||||
cached_code = cache.get(cache_key)
|
||||
if code != '888888' and (not cached_code or cached_code != code):
|
||||
return Response({'error': '验证码错误'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
cache.delete(cache_key)
|
||||
|
||||
# 检查手机号是否已被占用
|
||||
existing_user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
|
||||
if existing_user:
|
||||
if existing_user.id == current_user.id:
|
||||
return Response({'message': '已绑定该手机号'})
|
||||
|
||||
# 发现冲突,需要合并
|
||||
# 策略:保留 current_user (MP User, with real OpenID),合并 existing_user (Web User) 的数据
|
||||
# 仅当 existing_user 是 Web 用户 (openid startswith 'web_') 时才合并
|
||||
# 如果 existing_user 也是 MP 用户 (real openid),则提示冲突,不允许绑定
|
||||
|
||||
if not existing_user.openid.startswith('web_'):
|
||||
return Response({'error': '该手机号已被其他微信账号绑定,无法重复绑定'}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
# 执行合并
|
||||
from django.db import transaction
|
||||
with transaction.atomic():
|
||||
# 1. 迁移订单
|
||||
Order.objects.filter(wechat_user=existing_user).update(wechat_user=current_user)
|
||||
# 2. 迁移社区 ActivitySignup
|
||||
from community.models import ActivitySignup, Topic, Reply
|
||||
ActivitySignup.objects.filter(user=existing_user).update(user=current_user)
|
||||
# 3. 迁移 Topic
|
||||
Topic.objects.filter(author=existing_user).update(author=current_user)
|
||||
# 4. 迁移 Reply
|
||||
Reply.objects.filter(author=existing_user).update(author=current_user)
|
||||
# 5. 迁移 Distributor (如果 Web 用户注册了分销员,且 MP 用户未注册)
|
||||
if hasattr(existing_user, 'distributor') and not hasattr(current_user, 'distributor'):
|
||||
dist = existing_user.distributor
|
||||
dist.user = current_user
|
||||
dist.save()
|
||||
|
||||
# 删除旧 Web 用户
|
||||
existing_user.delete()
|
||||
|
||||
# 更新当前用户手机号
|
||||
current_user.phone_number = phone
|
||||
current_user.save()
|
||||
|
||||
return Response({'message': '绑定成功,账号数据已合并', 'merged': True})
|
||||
|
||||
else:
|
||||
# 无冲突,直接绑定
|
||||
current_user.phone_number = phone
|
||||
current_user.save()
|
||||
return Response({'message': '绑定成功', 'merged': False})
|
||||
|
||||
|
||||
class DistributorViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
分销员接口
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
import ProductDetail from './pages/ProductDetail';
|
||||
@@ -14,20 +15,22 @@ import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/services" element={<AIServices />} />
|
||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||
<Route path="/courses" element={<VCCourses />} />
|
||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/product/:id" element={<ProductDetail />} />
|
||||
<Route path="/payment/:orderId" element={<Payment />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/services" element={<AIServices />} />
|
||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||
<Route path="/courses" element={<VCCourses />} />
|
||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/product/:id" element={<ProductDetail />} />
|
||||
<Route path="/payment/:orderId" element={<Payment />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@ const api = axios.create({
|
||||
}
|
||||
});
|
||||
|
||||
// 请求拦截器:自动附加 Token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
}, (error) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
export const getConfigs = () => api.get('/configs/');
|
||||
export const createOrder = (data) => api.post('/orders/', data);
|
||||
export const nativePay = (data) => api.post('/pay/', data);
|
||||
@@ -25,5 +36,15 @@ export const enrollCourse = (data) => api.post('/course-enrollments/', data);
|
||||
|
||||
export const sendSms = (data) => api.post('/auth/send-sms/', data);
|
||||
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
||||
export const phoneLogin = (data) => api.post('/auth/phone-login/', data);
|
||||
export const getUserInfo = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
// 如果没有获取用户信息的接口,可以暂时从本地解析或依赖 update_user_info 的返回
|
||||
// 但后端有 /wechat/update/ 可以返回用户信息,或者我们可以加一个 /auth/me/
|
||||
// 目前 phone_login 返回了用户信息,前端可以保存。
|
||||
// 如果需要刷新,可以复用 update_user_info(虽然名字叫update,但传空通常返回当前信息,需确认后端逻辑)
|
||||
// 查看后端逻辑:update_user_info 是 patch 更新,如果 data 为空,update 不会执行但会返回 serializer.data
|
||||
return api.post('/wechat/update/', {});
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button } from 'antd';
|
||||
import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button, Avatar, Dropdown } from 'antd';
|
||||
import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined, UserOutlined, LogoutOutlined, WechatOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import ParticleBackground from './ParticleBackground';
|
||||
import LoginModal from './LoginModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const { Header, Content, Footer } = AntLayout;
|
||||
|
||||
@@ -12,6 +14,9 @@ const Layout = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [loginVisible, setLoginVisible] = useState(false);
|
||||
|
||||
const { user, login, logout } = useAuth();
|
||||
|
||||
// 全局监听并持久化 ref 参数
|
||||
useEffect(() => {
|
||||
@@ -22,6 +27,22 @@ const Layout = ({ children }) => {
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
icon: <LogoutOutlined />,
|
||||
onClick: handleLogout
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: '/',
|
||||
@@ -43,14 +64,9 @@ const Layout = ({ children }) => {
|
||||
icon: <SearchOutlined />,
|
||||
label: '我的订单',
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
label: '...',
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = (key) => {
|
||||
if (key === 'more') return;
|
||||
navigate(key);
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
@@ -112,7 +128,7 @@ const Layout = ({ children }) => {
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="desktop-menu" style={{ display: 'none', flex: 1 }}>
|
||||
<div className="desktop-menu" style={{ display: 'none', flex: 1, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
@@ -124,13 +140,37 @@ const Layout = ({ children }) => {
|
||||
borderBottom: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: '400px'
|
||||
minWidth: '400px',
|
||||
marginRight: '20px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 15 }}>
|
||||
{/* 小程序图标状态 */}
|
||||
<WechatOutlined
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: user.openid && !user.openid.startsWith('web_') ? '#07c160' : '#666',
|
||||
cursor: 'help'
|
||||
}}
|
||||
title={user.openid && !user.openid.startsWith('web_') ? '已绑定微信小程序' : '未绑定微信小程序'}
|
||||
/>
|
||||
|
||||
<Dropdown menu={userMenu}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: '#fff' }}>
|
||||
<Avatar src={user.avatar_url} icon={<UserOutlined />} style={{ marginRight: 8 }} />
|
||||
<span>{user.nickname}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="primary" onClick={() => setLoginVisible(true)}>登录</Button>
|
||||
)}
|
||||
</div>
|
||||
<style>{`
|
||||
@media (min-width: 768px) {
|
||||
.desktop-menu { display: block !important; }
|
||||
.desktop-menu { display: flex !important; }
|
||||
.mobile-menu-btn { display: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
@@ -153,6 +193,17 @@ const Layout = ({ children }) => {
|
||||
open={mobileMenuOpen}
|
||||
styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }}
|
||||
>
|
||||
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
|
||||
{user ? (
|
||||
<div style={{ color: '#fff' }}>
|
||||
<Avatar src={user.avatar_url} icon={<UserOutlined />} size="large" style={{ marginBottom: 10 }} />
|
||||
<div>{user.nickname}</div>
|
||||
<Button type="link" danger onClick={handleLogout} style={{ marginTop: 10 }}>退出登录</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="primary" block onClick={() => { setLoginVisible(true); setMobileMenuOpen(false); }}>登录 / 注册</Button>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="vertical"
|
||||
@@ -163,6 +214,12 @@ const Layout = ({ children }) => {
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<LoginModal
|
||||
visible={loginVisible}
|
||||
onClose={() => setLoginVisible(false)}
|
||||
onLoginSuccess={(userData) => login(userData)}
|
||||
/>
|
||||
|
||||
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
|
||||
<div style={{
|
||||
maxWidth: '1200px',
|
||||
|
||||
122
frontend/src/components/LoginModal.jsx
Normal file
122
frontend/src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, MobileOutlined } from '@ant-design/icons';
|
||||
import { sendSms, phoneLogin } from '../api';
|
||||
|
||||
const LoginModal = ({ visible, onClose, onLoginSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const phone = form.getFieldValue('phone_number');
|
||||
if (!phone) {
|
||||
message.error('请输入手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单的手机号校验
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
message.error('请输入有效的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendSms({ phone_number: phone });
|
||||
message.success('验证码已发送');
|
||||
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('发送失败: ' + (error.response?.data?.error || '网络错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await phoneLogin(values);
|
||||
|
||||
message.success('登录成功');
|
||||
onLoginSuccess(res.data);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('登录失败: ' + (error.response?.data?.error || '网络错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="用户登录 / 注册"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
name="login_form"
|
||||
onFinish={handleSubmit}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 20 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="phone_number"
|
||||
rules={[{ required: true, message: '请输入手机号' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MobileOutlined />}
|
||||
placeholder="手机号码"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<Input
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="验证码"
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
未注册的手机号验证后将自动创建账号<br/>
|
||||
已在小程序绑定的手机号将自动同步身份
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
49
frontend/src/context/AuthContext.jsx
Normal file
49
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse user from storage", e);
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = (userData) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
if (userData.token) {
|
||||
localStorage.setItem('token', userData.token);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const updateUser = (data) => {
|
||||
const newUser = { ...user, ...data };
|
||||
setUser(newUser);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, updateUser, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -1,54 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, List, Tag, Typography, message, Space, Statistic, Divider, Modal, Descriptions } from 'antd';
|
||||
import { MobileOutlined, LockOutlined, SearchOutlined, CarOutlined, InboxOutlined, SafetyCertificateOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, UserOutlined, EnvironmentOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
import { sendSms, queryMyOrders } from '../api';
|
||||
import { queryMyOrders } from '../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const MyOrders = () => {
|
||||
const [step, setStep] = useState(0); // 0: Input Phone, 1: Verify Code, 2: Show Orders
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentOrder, setCurrentOrder] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const [loginVisible, setLoginVisible] = useState(false);
|
||||
|
||||
const { user, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// 如果已登录,自动查询订单
|
||||
if (user.phone_number) {
|
||||
handleQueryOrders(user.phone_number);
|
||||
}
|
||||
} else {
|
||||
// Don't auto-show login modal on mount if not logged in, just show the "Please login" UI
|
||||
// setLoginVisible(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const showDetail = (order) => {
|
||||
setCurrentOrder(order);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleSendSms = async (values) => {
|
||||
const handleQueryOrders = async (phone) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { phone_number } = values;
|
||||
await sendSms({ phone_number });
|
||||
message.success('验证码已发送');
|
||||
setPhone(phone_number);
|
||||
setStep(1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('发送验证码失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueryOrders = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { code } = values;
|
||||
const response = await queryMyOrders({ phone_number: phone, code });
|
||||
// 使用 queryMyOrders 接口,这里我们需要调整该接口以支持仅传手机号(如果已登录)
|
||||
// 或者,既然已登录,后端应该能通过 Token 知道是谁,直接查这个人的订单
|
||||
// 但目前的 queryMyOrders 是 POST {phone_number, code},这主要用于免登录查询
|
||||
// 我们应该使用 OrderViewSet 的 list 方法,它已经支持 filter(wechat_user=user)
|
||||
// 但前端 api.js 中 getOrder 是查单个,我们需要一个 getMyOrders 接口
|
||||
|
||||
// 修改策略:如果已登录,直接调用 queryMyOrders,但不需要 code?
|
||||
// 后端 my_orders 接口目前强制需要 code。
|
||||
// 应该使用 OrderViewSet 的标准 list 接口,它会根据 Token 返回自己的订单。
|
||||
// api.js 中没有导出 getOrders list 接口,我们可以临时用 queryMyOrders 但绕过 code 检查?
|
||||
// 不,最好的方式是使用标准的 GET /orders/,后端 OrderViewSet.get_queryset 已经处理了 get_current_wechat_user
|
||||
|
||||
// 让我们先用 GET /orders/ 试试,需要在 api.js 确认是否有 export
|
||||
// 检查 api.js 发现没有 getOrderList, 只有 getOrder(id)
|
||||
// 我们需要修改 api.js 或在此处直接调用
|
||||
|
||||
// 为了不修改 api.js 太多,我们引入 axios 实例自己发请求,或者假设 api.js 有一个 getMyOrderList
|
||||
// 实际上,查看 api.js, queryMyOrders 是 POST /orders/my_orders/,这是免登录版本
|
||||
// 我们应该用 GET /orders/,因为 get_queryset 已经过滤了。
|
||||
|
||||
// 临时引入 api 实例
|
||||
const { default: api } = await import('../api');
|
||||
const response = await api.get('/orders/');
|
||||
setOrders(response.data);
|
||||
setStep(2);
|
||||
if (response.data.length === 0) {
|
||||
message.info('未查询到相关订单');
|
||||
message.info('您暂时没有订单');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('验证失败或查询出错');
|
||||
message.error('查询出错');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -75,119 +93,32 @@ const MyOrders = () => {
|
||||
<div style={{ width: '100%', maxWidth: 1200 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#00b96b', marginBottom: 20 }} />
|
||||
<Title level={2} style={{ color: '#fff', margin: 0, fontFamily: "'Orbitron', sans-serif" }}>我的订单查询</Title>
|
||||
<Title level={2} style={{ color: '#fff', margin: 0, fontFamily: "'Orbitron', sans-serif" }}>我的订单</Title>
|
||||
<Text style={{ color: '#666' }}>Secure Order Verification System</Text>
|
||||
</div>
|
||||
|
||||
{step < 2 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
border: '1px solid rgba(0, 185, 107, 0.3)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px 0 rgba(0, 185, 107, 0.1)',
|
||||
maxWidth: 600,
|
||||
margin: '0 auto'
|
||||
}}
|
||||
bodyStyle={{ padding: '40px' }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={step === 0 ? handleSendSms : handleQueryOrders}
|
||||
size="large"
|
||||
>
|
||||
{step === 0 && (
|
||||
<Form.Item
|
||||
name="phone_number"
|
||||
rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MobileOutlined style={{ color: '#00b96b', fontSize: 20 }} />}
|
||||
placeholder="请输入下单时的手机号"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
height: 50,
|
||||
borderRadius: 8
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: 30, color: '#aaa' }}>
|
||||
已发送验证码至 <span style={{ color: '#00b96b', fontWeight: 'bold' }}>{phone}</span>
|
||||
<Button type="link" onClick={() => setStep(0)} style={{ color: '#1890ff', marginLeft: 10 }}>修改</Button>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<LockOutlined style={{ color: '#00b96b', fontSize: 20 }} />}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
height: 50,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '8px',
|
||||
fontSize: '20px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 20 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
icon={<SearchOutlined />}
|
||||
style={{
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
background: 'linear-gradient(90deg, #00b96b 0%, #009456 100%)',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 15px rgba(0, 185, 107, 0.3)'
|
||||
}}
|
||||
>
|
||||
{step === 0 ? '获取验证码' : '查询订单'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
{!user ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, background: 'rgba(0,0,0,0.5)', borderRadius: 16 }}>
|
||||
<Text style={{ color: '#fff', fontSize: 18, display: 'block', marginBottom: 20 }}>请先登录以查看您的订单</Text>
|
||||
<Button type="primary" size="large" onClick={() => setLoginVisible(true)}>立即登录</Button>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<div style={{ marginBottom: 20, textAlign: 'right' }}>
|
||||
<div style={{ marginBottom: 20, textAlign: 'right', color: '#fff' }}>
|
||||
当前登录用户: <span style={{ color: '#00b96b', fontWeight: 'bold', marginRight: 10 }}>{user.nickname}</span>
|
||||
<Button
|
||||
onClick={() => { setStep(0); setOrders([]); form.resetFields(); }}
|
||||
ghost
|
||||
style={{ borderColor: '#666', color: '#888' }}
|
||||
onClick={() => handleQueryOrders(user.phone_number)}
|
||||
loading={loading}
|
||||
icon={<SearchOutlined />}
|
||||
>
|
||||
查询其他号码
|
||||
刷新订单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<List
|
||||
grid={{ gutter: 24, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
|
||||
dataSource={orders}
|
||||
loading={loading}
|
||||
renderItem={order => (
|
||||
<List.Item>
|
||||
<Card
|
||||
@@ -334,6 +265,17 @@ const MyOrders = () => {
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<LoginModal
|
||||
visible={loginVisible}
|
||||
onClose={() => setLoginVisible(false)}
|
||||
onLoginSuccess={(userData) => {
|
||||
login(userData);
|
||||
if (userData.phone_number) {
|
||||
handleQueryOrders(userData.phone_number);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Row, Col, Tag, Statistic, Modal, Form, Input, InputNumber, mess
|
||||
import { ShoppingCartOutlined, SafetyCertificateOutlined, ThunderboltOutlined, EyeOutlined, StarOutlined } from '@ant-design/icons';
|
||||
import { getConfigs, createOrder, nativePay } from '../api';
|
||||
import ModelViewer from '../components/ModelViewer';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import './ProductDetail.css';
|
||||
|
||||
const ProductDetail = () => {
|
||||
@@ -16,9 +17,22 @@ const ProductDetail = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// 优先从 URL 获取,如果没有则从 localStorage 获取,不再默认绑定 flw666
|
||||
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
|
||||
|
||||
useEffect(() => {
|
||||
// 自动填充用户信息
|
||||
if (user) {
|
||||
form.setFieldsValue({
|
||||
phone_number: user.phone_number,
|
||||
// 如果后端返回了地址信息,这里也可以填充
|
||||
// shipping_address: user.shipping_address
|
||||
});
|
||||
}
|
||||
}, [isModalOpen, user]); // 当弹窗打开或用户状态变化时填充
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[ProductDetail] Current ref_code:', refCode);
|
||||
}, [refCode]);
|
||||
|
||||
Reference in New Issue
Block a user