feat: 登录模块完成
This commit is contained in:
@@ -2,6 +2,26 @@ import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
// 不需要底部导航的页面路径
|
||||
const NO_BOTTOM_NAV_PATHS = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/devices',
|
||||
'/devices/',
|
||||
'/wechat-accounts',
|
||||
'/wechat-accounts/',
|
||||
'/scenarios/new',
|
||||
'/scenarios/',
|
||||
'/plans/',
|
||||
'/workspace/auto-group/',
|
||||
'/workspace/moments-sync/',
|
||||
'/workspace/traffic-distribution/',
|
||||
'/404',
|
||||
'/500'
|
||||
];
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -9,16 +29,23 @@ interface LayoutWrapperProps {
|
||||
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// 只在四个主页显示底部导航栏:首页、场景获客、工作台和我的
|
||||
const mainPages = ["/", "/scenarios", "/workspace", "/profile"];
|
||||
const showBottomNav = mainPages.includes(location.pathname);
|
||||
// 检查当前路径是否需要底部导航
|
||||
const shouldShowBottomNav = !NO_BOTTOM_NAV_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
// 如果是登录页面,直接渲染内容(不显示底部导航)
|
||||
if (location.pathname === '/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 其他页面显示底部导航
|
||||
return (
|
||||
<div className="mx-auto w-full">
|
||||
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
{showBottomNav && <BottomNav />}
|
||||
</main>
|
||||
</div>
|
||||
{shouldShowBottomNav && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
nkebao/src/components/ProtectedRoute.tsx
Normal file
71
nkebao/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// 不需要登录的公共页面路径
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/404',
|
||||
'/500'
|
||||
];
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否是公共页面
|
||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果正在加载,不进行任何跳转
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,重定向到登录页面
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
// 保存当前URL,登录后可以重定向回来
|
||||
const returnUrl = encodeURIComponent(window.location.href);
|
||||
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,重定向到首页
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,不渲染内容(等待重定向)
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 其他情况正常渲染
|
||||
return <>{children}</>;
|
||||
}
|
||||
11
nkebao/src/components/icons/AppleIcon.tsx
Normal file
11
nkebao/src/components/icons/AppleIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppleIcon;
|
||||
12
nkebao/src/components/icons/WeChatIcon.tsx
Normal file
12
nkebao/src/components/icons/WeChatIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
|
||||
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeChatIcon;
|
||||
88
nkebao/src/components/ui/toast.tsx
Normal file
88
nkebao/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (toast: Omit<Toast, 'id'>) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const toast = (newToast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const toastWithId = { ...newToast, id };
|
||||
|
||||
setToasts(prev => [...prev, toastWithId]);
|
||||
|
||||
// 自动移除toast
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`max-w-sm w-full bg-white rounded-lg shadow-lg border p-4 transform transition-all duration-300 ${
|
||||
toast.variant === 'destructive'
|
||||
? 'border-red-200 bg-red-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium ${
|
||||
toast.variant === 'destructive' ? 'text-red-800' : 'text-gray-900'
|
||||
}`}>
|
||||
{toast.title}
|
||||
</h4>
|
||||
{toast.description && (
|
||||
<p className={`text-sm mt-1 ${
|
||||
toast.variant === 'destructive' ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{toast.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-4 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user