This commit is contained in:
jeremygan2021
2026-02-12 12:03:39 +08:00
parent 752b7caf71
commit ba78470052
18 changed files with 560 additions and 156 deletions

View File

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

View File

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

View File

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

View 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;

View 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);

View File

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

View File

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