feat: 登录模块完成

This commit is contained in:
许永平
2025-07-04 15:49:22 +08:00
parent aad2d055b7
commit 8de943d4a4
22 changed files with 1661 additions and 257 deletions

View File

@@ -1,6 +1,10 @@
import React from 'react';
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './components/ui/toast';
import ProtectedRoute from './components/ProtectedRoute';
import LayoutWrapper from './components/LayoutWrapper';
import { initInterceptors } from './api';
import Home from './pages/Home';
import Login from './pages/login/Login';
import Devices from './pages/devices/Devices';
@@ -25,35 +29,47 @@ import ContactImport from './pages/contact-import/ContactImport';
import Content from './pages/content/Content';
function App() {
// 初始化HTTP拦截器
useEffect(() => {
const cleanup = initInterceptors();
return cleanup;
}, []);
return (
<BrowserRouter>
<LayoutWrapper>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/devices" element={<Devices />} />
<Route path="/devices/:id" element={<DeviceDetail />} />
<Route path="/wechat-accounts" element={<WechatAccounts />} />
<Route path="/wechat-accounts/:id" element={<WechatAccountDetail />} />
<Route path="/workspace" element={<Workspace />} />
<Route path="/workspace/auto-group/:id" element={<AutoGroupDetail />} />
<Route path="/workspace/moments-sync" element={<MomentsSync />} />
<Route path="/workspace/moments-sync/:id" element={<MomentsSyncDetail />} />
<Route path="/workspace/traffic-distribution" element={<TrafficDistribution />} />
<Route path="/workspace/traffic-distribution/:id" element={<TrafficDistributionDetail />} />
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<NewPlan />} />
<Route path="/scenarios/:scenarioId" element={<ScenarioDetail />} />
<Route path="/profile" element={<Profile />} />
<Route path="/plans" element={<Plans />} />
<Route path="/plans/:planId" element={<PlanDetail />} />
<Route path="/orders" element={<Orders />} />
<Route path="/traffic-pool" element={<TrafficPool />} />
<Route path="/contact-import" element={<ContactImport />} />
<Route path="/content" element={<Content />} />
{/* 你可以继续添加更多路由 */}
</Routes>
</LayoutWrapper>
<AuthProvider>
<ToastProvider>
<ProtectedRoute>
<LayoutWrapper>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/devices" element={<Devices />} />
<Route path="/devices/:id" element={<DeviceDetail />} />
<Route path="/wechat-accounts" element={<WechatAccounts />} />
<Route path="/wechat-accounts/:id" element={<WechatAccountDetail />} />
<Route path="/workspace" element={<Workspace />} />
<Route path="/workspace/auto-group/:id" element={<AutoGroupDetail />} />
<Route path="/workspace/moments-sync" element={<MomentsSync />} />
<Route path="/workspace/moments-sync/:id" element={<MomentsSyncDetail />} />
<Route path="/workspace/traffic-distribution" element={<TrafficDistribution />} />
<Route path="/workspace/traffic-distribution/:id" element={<TrafficDistributionDetail />} />
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<NewPlan />} />
<Route path="/scenarios/:scenarioId" element={<ScenarioDetail />} />
<Route path="/profile" element={<Profile />} />
<Route path="/plans" element={<Plans />} />
<Route path="/plans/:planId" element={<PlanDetail />} />
<Route path="/orders" element={<Orders />} />
<Route path="/traffic-pool" element={<TrafficPool />} />
<Route path="/contact-import" element={<ContactImport />} />
<Route path="/content" element={<Content />} />
{/* 你可以继续添加更多路由 */}
</Routes>
</LayoutWrapper>
</ProtectedRoute>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
);
}

82
nkebao/src/api/auth.ts Normal file
View File

@@ -0,0 +1,82 @@
import { request } from './request';
import type { ApiResponse } from '@/types/common';
// 登录响应数据类型
export interface LoginResponse {
token: string;
token_expired: string;
member: {
id: number;
username: string;
account: string;
avatar?: string;
s2_accountId: string;
};
}
// 验证码响应类型
export interface VerificationCodeResponse {
code: string;
expire_time: string;
}
// 认证相关API
export const authApi = {
// 账号密码登录
login: async (account: string, password: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', {
account,
password,
typeId: 1 // 默认使用用户类型1
});
return response as unknown as ApiResponse<LoginResponse>;
},
// 验证码登录
loginWithCode: async (account: string, code: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login/code', {
account,
code,
typeId: 1
});
return response as unknown as ApiResponse<LoginResponse>;
},
// 发送验证码
sendVerificationCode: async (account: string) => {
const response = await request.post<ApiResponse<VerificationCodeResponse>>('/v1/auth/send-code', {
account,
type: 'login' // 登录验证码
});
return response as unknown as ApiResponse<VerificationCodeResponse>;
},
// 获取用户信息
getUserInfo: async () => {
const response = await request.get<ApiResponse<any>>('/v1/auth/info');
return response as unknown as ApiResponse<any>;
},
// 刷新Token
refreshToken: async () => {
const response = await request.post<ApiResponse<{ token: string; token_expired: string }>>('/v1/auth/refresh', {});
return response as unknown as ApiResponse<{ token: string; token_expired: string }>;
},
// 微信登录
wechatLogin: async (code: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/wechat', {
code
});
return response as unknown as ApiResponse<LoginResponse>;
},
// Apple登录
appleLogin: async (identityToken: string, authorizationCode: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/apple', {
identity_token: identityToken,
authorization_code: authorizationCode
});
return response as unknown as ApiResponse<LoginResponse>;
},
};

View File

@@ -8,7 +8,6 @@ import type {
CreateDeviceParams,
UpdateDeviceParams,
DeviceStatus,
ServerDevice,
ServerDevicesResponse
} from '@/types/device';

13
nkebao/src/api/index.ts Normal file
View File

@@ -0,0 +1,13 @@
// 导出所有API相关的内容
export * from './auth';
export * from './utils';
export * from './interceptors';
export * from './request';
// 导出现有的API模块
export * from './devices';
export * from './scenarios';
export * from './wechat-accounts';
// 默认导出request实例
export { default as request } from './request';

View File

@@ -0,0 +1,152 @@
import { refreshAuthToken, isTokenExpiringSoon, clearToken } from './utils';
// Token过期处理
export const handleTokenExpired = () => {
if (typeof window !== 'undefined') {
// 清除本地存储
clearToken();
// 跳转到登录页面
setTimeout(() => {
window.location.href = '/login';
}, 0);
}
};
// 显示API错误但不会重定向
export const showApiError = (error: any, defaultMessage: string = '请求失败') => {
if (typeof window === 'undefined') return; // 服务端不处理
let errorMessage = defaultMessage;
// 尝试从各种可能的错误格式中获取消息
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message || defaultMessage;
} else if (typeof error === 'object') {
// 尝试从API响应中获取错误消息
errorMessage = error.msg || error.message || error.error || defaultMessage;
}
}
// 显示错误消息
console.error('API错误:', errorMessage);
// 这里可以集成toast系统
// 由于toast context在组件层级这里暂时用console
// 实际项目中可以通过事件系统或其他方式集成
};
// 请求拦截器 - 检查token是否需要刷新
export const requestInterceptor = async (): Promise<boolean> => {
if (typeof window === 'undefined') {
return true;
}
// 检查token是否即将过期
if (isTokenExpiringSoon()) {
try {
console.log('Token即将过期尝试刷新...');
const success = await refreshAuthToken();
if (!success) {
console.log('Token刷新失败需要重新登录');
handleTokenExpired();
return false;
}
console.log('Token刷新成功');
} catch (error) {
console.error('Token刷新过程中出错:', error);
handleTokenExpired();
return false;
}
}
return true;
};
// 响应拦截器 - 处理常见错误
export const responseInterceptor = (response: any, result: any) => {
// 处理401未授权
if (response?.status === 401 || (result && result.code === 401)) {
handleTokenExpired();
throw new Error('登录已过期,请重新登录');
}
// 处理403禁止访问
if (response?.status === 403 || (result && result.code === 403)) {
throw new Error('没有权限访问此资源');
}
// 处理404未找到
if (response?.status === 404 || (result && result.code === 404)) {
throw new Error('请求的资源不存在');
}
// 处理500服务器错误
if (response?.status >= 500 || (result && result.code >= 500)) {
throw new Error('服务器内部错误,请稍后重试');
}
return result;
};
// 错误拦截器 - 统一错误处理
export const errorInterceptor = (error: any) => {
console.error('API请求错误:', error);
let errorMessage = '网络请求失败,请稍后重试';
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络设置';
} else if (error.name === 'AbortError') {
errorMessage = '请求已取消';
}
}
showApiError(error, errorMessage);
throw new Error(errorMessage);
};
// 网络状态监听
export const setupNetworkListener = () => {
if (typeof window === 'undefined') return;
const handleOnline = () => {
console.log('网络已连接');
// 可以在这里添加网络恢复后的处理逻辑
};
const handleOffline = () => {
console.log('网络已断开');
showApiError(null, '网络连接已断开,请检查网络设置');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 返回清理函数
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
};
// 初始化拦截器
export const initInterceptors = () => {
// 设置网络监听
const cleanupNetwork = setupNetworkListener();
// 返回清理函数
return () => {
if (cleanupNetwork) {
cleanupNetwork();
}
};
};

View File

@@ -1,8 +1,9 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors';
// 创建axios实例
const request: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_BASE || 'http://localhost:3000/api',
baseURL: process.env.REACT_APP_API_BASE_URL || 'http://www.yishi.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
@@ -11,12 +12,21 @@ const request: AxiosInstance = axios.create({
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 可以在这里添加token等认证信息
async (config) => {
// 检查token是否需要刷新
if (config.headers.Authorization) {
const shouldContinue = await requestInterceptor();
if (!shouldContinue) {
throw new Error('请求被拦截,需要重新登录');
}
}
// 添加token到请求头
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
@@ -27,12 +37,14 @@ request.interceptors.request.use(
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
// 处理响应数据
const result = response.data;
const processedResult = responseInterceptor(response, result);
return processedResult;
},
(error) => {
// 统一错误处理
console.error('API请求错误:', error);
return Promise.reject(error);
return errorInterceptor(error);
}
);

View File

@@ -12,13 +12,6 @@ export interface SceneItem {
deleteTime: number | null;
}
// 服务器返回的场景列表响应类型
export interface ScenesResponse {
code: number;
message: string;
data: SceneItem[];
}
// 前端使用的场景数据类型
export interface Channel {
id: string;
@@ -51,7 +44,7 @@ export const fetchScenes = async (params: {
page?: number;
limit?: number;
keyword?: string;
} = {}): Promise<ScenesResponse> => {
} = {}): Promise<ApiResponse<SceneItem[]>> => {
const { page = 1, limit = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
@@ -63,7 +56,7 @@ export const fetchScenes = async (params: {
}
try {
return await get<ScenesResponse>(`/v1/plan/scenes?${queryParams.toString()}`);
return await get<ApiResponse<SceneItem[]>>(`/v1/plan/scenes?${queryParams.toString()}`);
} catch (error) {
console.error("Error fetching scenes:", error);
// 返回一个错误响应

100
nkebao/src/api/utils.ts Normal file
View File

@@ -0,0 +1,100 @@
import { authApi } from './auth';
// 设置token到localStorage
export const setToken = (token: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem('token', token);
}
};
// 获取token
export const getToken = (): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem('token');
}
return null;
};
// 清除token
export const clearToken = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
localStorage.removeItem('token_expired');
localStorage.removeItem('s2_accountId');
}
};
// 验证token是否有效
export const validateToken = async (): Promise<boolean> => {
try {
const response = await authApi.getUserInfo();
return response.code === 200;
} catch (error) {
console.error('Token验证失败:', error);
return false;
}
};
// 刷新令牌
export const refreshAuthToken = async (): Promise<boolean> => {
if (typeof window === 'undefined') {
return false;
}
try {
const response = await authApi.refreshToken();
if (response.code === 200 && response.data?.token) {
setToken(response.data.token);
// 更新过期时间
if (response.data.token_expired) {
localStorage.setItem('token_expired', response.data.token_expired);
}
return true;
}
return false;
} catch (error) {
console.error('刷新Token失败:', error);
return false;
}
};
// 检查token是否即将过期
export const isTokenExpiringSoon = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
const tokenExpired = localStorage.getItem('token_expired');
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前10分钟认为即将过期
return currentTime >= (expiredTime - 10 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};
// 检查token是否已过期
export const isTokenExpired = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
const tokenExpired = localStorage.getItem('token_expired');
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前5分钟认为过期给刷新留出时间
return currentTime >= (expiredTime - 5 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};

View File

@@ -27,23 +27,6 @@ interface WechatAccountSummary {
}[];
}
interface WechatAccountSummaryResponse {
code: number;
message: string;
data: WechatAccountSummary;
}
interface ServerWechatAccountsResponse {
code: number;
message: string;
data: {
list: any[];
total: number;
page: number;
limit: number;
};
}
interface QueryWechatAccountParams {
page?: number;
limit?: number;
@@ -57,7 +40,12 @@ interface QueryWechatAccountParams {
* @param params 查询参数
* @returns 微信账号列表响应
*/
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ServerWechatAccountsResponse> => {
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>> => {
const queryParams = new URLSearchParams();
// 添加查询参数
@@ -68,15 +56,20 @@ export const fetchWechatAccountList = async (params: QueryWechatAccountParams =
if (params.order) queryParams.append('order', params.order);
// 发起API请求
return get<ServerWechatAccountsResponse>(`/v1/wechats?${queryParams.toString()}`);
return get<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>>(`/v1/wechats?${queryParams.toString()}`);
};
/**
* 刷新微信账号状态
* @returns 刷新结果
*/
export const refreshWechatAccounts = async (): Promise<{ code: number; message: string; data: any }> => {
return put<{ code: number; message: string; data: any }>('/v1/wechats/refresh', {});
export const refreshWechatAccounts = async (): Promise<ApiResponse<any>> => {
return put<ApiResponse<any>>('/v1/wechats/refresh', {});
};
/**
@@ -85,8 +78,8 @@ export const refreshWechatAccounts = async (): Promise<{ code: number; message:
* @param targetId 目标微信账号ID
* @returns 转移结果
*/
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<{ code: number; message: string; data: any }> => {
return post<{ code: number; message: string; data: any }>('/v1/wechats/transfer-friends', {
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>('/v1/wechats/transfer-friends', {
source_id: sourceId,
target_id: targetId
});
@@ -175,9 +168,9 @@ export const fetchWechatFriends = async (wechatId: string, page: number = 1, pag
* @param id 微信账号ID
* @returns 微信账号概览信息
*/
export const fetchWechatAccountSummary = async (wechatIdid: string): Promise<WechatAccountSummaryResponse> => {
export const fetchWechatAccountSummary = async (wechatId: string): Promise<ApiResponse<WechatAccountSummary>> => {
try {
return get<WechatAccountSummaryResponse>(`/v1/wechats/${wechatIdid}/summary`);
return get<ApiResponse<WechatAccountSummary>>(`/v1/wechats/${wechatId}/summary`);
} catch (error) {
console.error("获取账号概览失败:", error);
throw error;
@@ -198,20 +191,13 @@ export interface WechatFriendDetail {
wechatId: string;
addDate: string;
tags: string[];
playDate: string;
memo: string;
source: string;
}
interface WechatFriendDetailResponse {
code: number;
message: string;
data: WechatFriendDetail;
}
export const fetchWechatFriendDetail = async (wechatId: string): Promise<WechatFriendDetailResponse> => {
export const fetchWechatFriendDetail = async (wechatId: string): Promise<ApiResponse<WechatFriendDetail>> => {
try {
return get<WechatFriendDetailResponse>(`/v1/wechats/${wechatId}`);
return get<ApiResponse<WechatFriendDetail>>(`/v1/wechats/${wechatId}/friend-detail`);
} catch (error) {
console.error("获取好友详情失败:", error);
throw error;

View File

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

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

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

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

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

View File

@@ -0,0 +1,244 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { validateToken, refreshAuthToken } from '../api';
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key);
}
return null;
},
setItem: (key: string, value: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value);
}
},
removeItem: (key: string): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
}
};
interface User {
id: number;
username: string;
account?: string;
avatar?: string;
s2_accountId?: string;
}
interface AuthContextType {
isAuthenticated: boolean;
token: string | null;
user: User | null;
login: (token: string, userData: User) => void;
logout: () => void;
updateToken: (newToken: string) => void;
isLoading: boolean;
}
// 创建默认上下文
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
user: null,
login: () => {},
logout: () => {},
updateToken: () => {},
isLoading: true
});
export const useAuth = () => useContext(AuthContext);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const navigate = useNavigate();
// 检查token是否过期
const isTokenExpired = (): boolean => {
const tokenExpired = safeLocalStorage.getItem("token_expired");
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前5分钟认为过期给刷新留出时间
return currentTime >= (expiredTime - 5 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};
// 验证token有效性
const verifyToken = async (): Promise<boolean> => {
try {
const isValid = await validateToken();
return isValid;
} catch (error) {
console.error('Token验证失败:', error);
return false;
}
};
// 尝试刷新token
const tryRefreshToken = async (): Promise<boolean> => {
try {
const success = await refreshAuthToken();
if (success) {
const newToken = safeLocalStorage.getItem("token");
if (newToken) {
setToken(newToken);
return true;
}
}
return false;
} catch (error) {
console.error('刷新token失败:', error);
return false;
}
};
// 初始化认证状态
useEffect(() => {
setIsLoading(true);
const initAuth = async () => {
try {
const storedToken = safeLocalStorage.getItem("token");
if (storedToken) {
// 检查token是否过期
if (isTokenExpired()) {
console.log('Token已过期尝试刷新...');
const refreshSuccess = await tryRefreshToken();
if (!refreshSuccess) {
console.log('Token刷新失败需要重新登录');
handleLogout();
return;
}
}
// 验证token有效性
const isValid = await verifyToken();
if (!isValid) {
console.log('Token无效需要重新登录');
handleLogout();
return;
}
// 获取用户信息
const userDataStr = safeLocalStorage.getItem("userInfo");
if (userDataStr) {
try {
const userData = JSON.parse(userDataStr) as User;
setToken(storedToken);
setUser(userData);
setIsAuthenticated(true);
} catch (parseError) {
console.error('解析用户数据失败:', parseError);
handleLogout();
}
} else {
console.warn('找到token但没有用户信息尝试保持登录状态');
setToken(storedToken);
setIsAuthenticated(true);
}
}
} catch (error) {
console.error("初始化认证状态时出错:", error);
handleLogout();
} finally {
setIsLoading(false);
setIsInitialized(true);
}
};
initAuth();
}, []);
// 定期检查token状态
useEffect(() => {
if (!isAuthenticated) return;
const checkTokenInterval = setInterval(async () => {
if (isTokenExpired()) {
console.log('检测到token即将过期尝试刷新...');
const refreshSuccess = await tryRefreshToken();
if (!refreshSuccess) {
console.log('Token刷新失败登出用户');
handleLogout();
}
}
}, 60000); // 每分钟检查一次
return () => clearInterval(checkTokenInterval);
}, [isAuthenticated]);
const handleLogout = () => {
// 先清除所有认证相关的状态
safeLocalStorage.removeItem("token");
safeLocalStorage.removeItem("token_expired");
safeLocalStorage.removeItem("s2_accountId");
safeLocalStorage.removeItem("userInfo");
safeLocalStorage.removeItem("user");
setToken(null);
setUser(null);
setIsAuthenticated(false);
// 跳转到登录页面
navigate('/login');
};
const login = (newToken: string, userData: User) => {
safeLocalStorage.setItem("token", newToken);
safeLocalStorage.setItem("userInfo", JSON.stringify(userData));
if (userData.s2_accountId) {
safeLocalStorage.setItem("s2_accountId", userData.s2_accountId);
}
setToken(newToken);
setUser(userData);
setIsAuthenticated(true);
};
const logout = () => {
handleLogout();
};
// 用于刷新 token 的方法
const updateToken = (newToken: string) => {
safeLocalStorage.setItem("token", newToken);
setToken(newToken);
};
return (
<AuthContext.Provider value={{
isAuthenticated,
token,
user,
login,
logout,
updateToken,
isLoading
}}>
{isLoading && isInitialized ? (
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
) : (
children
)}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,80 @@
import { 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'
];
/**
* 认证守卫Hook
* 用于在组件中检查用户是否已登录
* @param requireAuth 是否需要认证默认为true
* @param redirectTo 未认证时重定向的路径,默认为'/login'
*/
export function useAuthGuard(requireAuth: boolean = true, redirectTo: string = '/login') {
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 (requireAuth && !isAuthenticated && !isPublicPath) {
// 保存当前URL登录后可以重定向回来
const returnUrl = encodeURIComponent(window.location.href);
navigate(`${redirectTo}?returnUrl=${returnUrl}`, { replace: true });
return;
}
// 如果已登录但在登录页面,重定向到首页
if (isAuthenticated && location.pathname === '/login') {
navigate('/', { replace: true });
return;
}
}, [isAuthenticated, isLoading, location.pathname, navigate, requireAuth, redirectTo, isPublicPath]);
return {
isAuthenticated,
isLoading,
isPublicPath,
// 是否应该显示内容
shouldRender: !isLoading && (isAuthenticated || isPublicPath || !requireAuth)
};
}
/**
* 简单的认证检查Hook
* 只返回认证状态,不进行自动重定向
*/
export function useAuthCheck() {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
const isPublicPath = PUBLIC_PATHS.some(path =>
location.pathname.startsWith(path)
);
return {
isAuthenticated,
isLoading,
isPublicPath,
// 是否需要认证
requiresAuth: !isPublicPath
};
}

View File

@@ -1,142 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import { Chart, registerables } from 'chart.js';
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Smartphone, Users, Activity, Bell } from 'lucide-react';
Chart.register(...registerables);
import { Bell, Smartphone, Users, Activity } from 'lucide-react';
// 模拟数据
const stats = {
totalDevices: 12,
totalWechatAccounts: 8,
onlineWechatAccounts: 6,
};
const scenarioFeatures = [
{
id: "douyin",
name: "抖音获客",
value: 156,
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
color: "bg-red-100",
},
{
id: "xiaohongshu",
name: "小红书获客",
value: 89,
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
color: "bg-pink-100",
},
{
id: "gongzhonghao",
name: "公众号获客",
value: 234,
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
color: "bg-green-100",
},
{
id: "haibao",
name: "海报获客",
value: 167,
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
color: "bg-blue-100",
},
];
export default function Home() {
const chartRef = useRef<HTMLCanvasElement>(null);
const chartInstance = useRef<Chart | null>(null);
const navigate = useNavigate();
// 统计数据状态
const [stats, setStats] = useState({
totalDevices: 0,
onlineDevices: 0,
totalWechatAccounts: 0,
onlineWechatAccounts: 0,
});
// 业务场景数据
const scenarioFeatures = [
{
id: "douyin",
name: "抖音获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
color: "bg-blue-100 text-blue-600",
value: 156,
growth: 12,
},
{
id: "xiaohongshu",
name: "小红书获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
color: "bg-red-100 text-red-600",
value: 89,
growth: 8,
},
{
id: "gongzhonghao",
name: "公众号获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
color: "bg-green-100 text-green-600",
value: 234,
growth: 15,
},
{
id: "haibao",
name: "海报获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
color: "bg-orange-100 text-orange-600",
value: 167,
growth: 10,
},
];
// 获取统计数据
useEffect(() => {
const fetchStats = async () => {
try {
// 这里可以调用实际的API
// const response = await fetch('/api/stats');
// const data = await response.json();
// 模拟数据
setStats({
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
});
} catch (error) {
console.error('获取统计数据失败:', error);
}
};
fetchStats();
}, []);
useEffect(() => {
if (chartRef.current) {
if (chartInstance.current) chartInstance.current.destroy();
chartInstance.current = new Chart(chartRef.current, {
type: 'line',
data: {
labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
datasets: [
{
label: '获客数量',
data: [120, 150, 180, 200, 230, 210, 190],
backgroundColor: 'rgba(59, 130, 246, 0.2)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 2,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: 'rgba(59, 130, 246, 1)',
pointHoverRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#333',
bodyColor: '#666',
borderColor: '#ddd',
borderWidth: 1,
padding: 10,
displayColors: false,
callbacks: {
label: (context) => `获客数量: ${context.parsed.y}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
},
},
});
}
return () => {
if (chartInstance.current) chartInstance.current.destroy();
};
}, []);
const chartRef = useRef<HTMLCanvasElement>(null);
const handleDevicesClick = () => {
navigate('/devices');
@@ -146,6 +52,44 @@ export default function Home() {
navigate('/wechat-accounts');
};
useEffect(() => {
if (chartRef.current) {
const ctx = chartRef.current.getContext('2d');
if (ctx) {
// 清除之前的图表
ctx.clearRect(0, 0, chartRef.current.width, chartRef.current.height);
// 绘制简单的柱状图
const data = [12, 19, 15, 25, 22, 30, 28];
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const barWidth = 30;
const barSpacing = 20;
const startX = 50;
const startY = 200;
ctx.fillStyle = '#3B82F6';
data.forEach((value, index) => {
const x = startX + index * (barWidth + barSpacing);
const height = (value / 30) * 150;
const y = startY - height;
ctx.fillRect(x, y, barWidth, height);
// 绘制数值
ctx.fillStyle = '#374151';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(value.toString(), x + barWidth / 2, y - 5);
// 绘制标签
ctx.fillText(labels[index], x + barWidth / 2, startY + 20);
ctx.fillStyle = '#3B82F6';
});
}
}
}, []);
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b">

View File

@@ -1,5 +1,432 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Eye, EyeOff, Phone } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../components/ui/toast';
import { authApi } from '../../api';
import WeChatIcon from '../../components/icons/WeChatIcon';
import AppleIcon from '../../components/icons/AppleIcon';
// 定义登录表单类型
interface LoginForm {
phone: string;
password: string;
verificationCode: string;
agreeToTerms: boolean;
}
export default function Login() {
return <div></div>;
const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState<'password' | 'verification'>('password');
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [form, setForm] = useState<LoginForm>({
phone: '',
password: '',
verificationCode: '',
agreeToTerms: false,
});
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { toast } = useToast();
const { login } = useAuth();
// 倒计时效果
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, agreeToTerms: e.target.checked }));
};
const validateForm = () => {
if (!form.phone) {
toast({
variant: 'destructive',
title: '请输入手机号',
description: '手机号不能为空',
});
return false;
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(form.phone)) {
toast({
variant: 'destructive',
title: '手机号格式错误',
description: '请输入正确的11位手机号',
});
return false;
}
if (!form.agreeToTerms) {
toast({
variant: 'destructive',
title: '请同意用户协议',
description: '需要同意用户协议和隐私政策才能继续',
});
return false;
}
if (activeTab === 'password' && !form.password) {
toast({
variant: 'destructive',
title: '请输入密码',
description: '密码不能为空',
});
return false;
}
if (activeTab === 'verification' && !form.verificationCode) {
toast({
variant: 'destructive',
title: '请输入验证码',
description: '验证码不能为空',
});
return false;
}
return true;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
if (activeTab === 'password') {
// 发送账号密码登录请求
const response = await authApi.login(form.phone, form.password);
if (response.code === 200 && response.data) {
// 保存登录信息
localStorage.setItem('token', response.data.token);
localStorage.setItem('token_expired', response.data.token_expired);
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
// 保存用户信息
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
// 调用认证上下文的登录方法
login(response.data.token, response.data.member);
// 显示成功提示
toast({
title: '登录成功',
description: '欢迎回来!',
});
// 跳转到首页或重定向URL
const returnUrl = searchParams.get('returnUrl');
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
navigate('/');
}
} else {
throw new Error(response.message || '登录失败');
}
} else {
// 验证码登录
const response = await authApi.loginWithCode(form.phone, form.verificationCode);
if (response.code === 200 && response.data) {
// 保存登录信息
localStorage.setItem('token', response.data.token);
localStorage.setItem('token_expired', response.data.token_expired);
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
// 保存用户信息
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
// 调用认证上下文的登录方法
login(response.data.token, response.data.member);
// 显示成功提示
toast({
title: '登录成功',
description: '欢迎回来!',
});
// 跳转到首页或重定向URL
const returnUrl = searchParams.get('returnUrl');
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
navigate('/');
}
} else {
throw new Error(response.message || '登录失败');
}
}
} catch (error) {
toast({
variant: 'destructive',
title: '登录失败',
description: error instanceof Error ? error.message : '请稍后重试',
});
} finally {
setIsLoading(false);
}
};
const handleSendVerificationCode = async () => {
if (!form.phone) {
toast({
variant: 'destructive',
title: '请输入手机号',
description: '发送验证码需要手机号',
});
return;
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(form.phone)) {
toast({
variant: 'destructive',
title: '手机号格式错误',
description: '请输入正确的11位手机号',
});
return;
}
try {
setIsLoading(true);
const response = await authApi.sendVerificationCode(form.phone);
if (response.code === 200) {
toast({
title: '验证码已发送',
description: '请查收短信验证码',
});
setCountdown(60); // 开始60秒倒计时
} else {
throw new Error(response.message || '发送失败');
}
} catch (error) {
toast({
variant: 'destructive',
title: '发送失败',
description: error instanceof Error ? error.message : '请稍后重试',
});
} finally {
setIsLoading(false);
}
};
const handleWechatLogin = () => {
// 微信登录逻辑
toast({
title: '功能开发中',
description: '微信登录功能正在开发中,请使用其他方式登录',
});
};
const handleAppleLogin = () => {
// Apple登录逻辑
toast({
title: '功能开发中',
description: 'Apple登录功能正在开发中请使用其他方式登录',
});
};
return (
<div className="min-h-screen bg-white flex items-center justify-center px-4 py-8">
<div className="max-w-md w-full">
{/* 标题 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600 text-sm"> / / Apple </p>
</div>
{/* 标签页切换 */}
<div className="flex border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('password')}
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
activeTab === 'password'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setActiveTab('verification')}
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
activeTab === 'verification'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
<form onSubmit={handleLogin} className="space-y-6">
{/* 手机号输入 */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="tel"
name="phone"
value={form.phone}
onChange={handleInputChange}
placeholder="请输入手机号"
className="w-full pl-16 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1 text-sm">
<Phone className="h-4 w-4" />
+86
</span>
</div>
</div>
{/* 密码输入 */}
{activeTab === 'password' && (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={form.password}
onChange={handleInputChange}
placeholder="请输入密码"
className="w-full pl-4 pr-12 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
)}
{/* 验证码输入 */}
{activeTab === 'verification' && (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="text"
name="verificationCode"
value={form.verificationCode}
onChange={handleInputChange}
placeholder="请输入验证码"
className="w-full pl-4 pr-32 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendVerificationCode}
disabled={isLoading || countdown > 0}
className={`absolute right-2 top-1/2 -translate-y-1/2 px-4 h-10 rounded-md text-sm font-medium transition-colors ${
countdown > 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-500 hover:bg-blue-100'
}`}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</button>
</div>
</div>
)}
{/* 用户协议 */}
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="terms"
checked={form.agreeToTerms}
onChange={handleCheckboxChange}
disabled={isLoading}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
/>
<label
htmlFor="terms"
className="text-sm text-gray-600 leading-relaxed cursor-pointer"
>
<a href="#" className="text-blue-500 hover:text-blue-600 mx-1"></a>
<a href="#" className="text-blue-500 hover:text-blue-600 mx-1"></a>
</label>
</div>
{/* 登录按钮 */}
<button
type="submit"
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</div>
) : (
'登录'
)}
</button>
{/* 分割线 */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-gray-500"></span>
</div>
</div>
{/* 第三方登录 */}
<div className="flex justify-center space-x-8">
<button
type="button"
onClick={handleWechatLogin}
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
>
<WeChatIcon className="h-8 w-8" />
<span className="text-xs"></span>
</button>
<button
type="button"
onClick={handleAppleLogin}
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
>
<AppleIcon className="h-8 w-8" />
<span className="text-xs">Apple</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Users, TrendingUp, Calendar } from 'lucide-react';
import { Plus, Calendar } from 'lucide-react';
interface Plan {
id: string;

View File

@@ -1,5 +1,153 @@
import React from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../components/ui/toast';
import { LogOut, User, Settings, Shield, Bell } from 'lucide-react';
export default function Profile() {
return <div></div>;
const { user, logout, isAuthenticated } = useAuth();
const { toast } = useToast();
const handleLogout = () => {
logout();
toast({
title: '已退出登录',
description: '感谢使用存客宝',
});
};
const menuItems = [
{
icon: <User className="h-5 w-5" />,
title: '个人信息',
description: '查看和编辑个人资料',
onClick: () => {
toast({
title: '功能开发中',
description: '个人信息编辑功能正在开发中',
});
}
},
{
icon: <Settings className="h-5 w-5" />,
title: '账户设置',
description: '密码、安全设置等',
onClick: () => {
toast({
title: '功能开发中',
description: '账户设置功能正在开发中',
});
}
},
{
icon: <Shield className="h-5 w-5" />,
title: '隐私安全',
description: '隐私设置和安全选项',
onClick: () => {
toast({
title: '功能开发中',
description: '隐私安全功能正在开发中',
});
}
},
{
icon: <Bell className="h-5 w-5" />,
title: '消息通知',
description: '通知设置和消息管理',
onClick: () => {
toast({
title: '功能开发中',
description: '消息通知功能正在开发中',
});
}
}
];
if (!isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-gray-500"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-md mx-auto space-y-6">
{/* 用户信息卡片 */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
{user?.avatar ? (
<img
src={user.avatar}
alt="头像"
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<User className="h-8 w-8 text-blue-600" />
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
{user?.username || '用户'}
</h2>
<p className="text-gray-500">
{user?.account || '暂无手机号'}
</p>
{user?.s2_accountId && (
<p className="text-sm text-gray-400">
ID: {user.s2_accountId}
</p>
)}
</div>
</div>
</div>
{/* 菜单列表 */}
<div className="bg-white rounded-lg shadow-sm">
{menuItems.map((item, index) => (
<div key={index}>
<button
onClick={item.onClick}
className="w-full flex items-center space-x-4 p-4 hover:bg-gray-50 transition-colors"
>
<div className="text-gray-400">
{item.icon}
</div>
<div className="flex-1 text-left">
<h3 className="font-medium text-gray-900">{item.title}</h3>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
<div className="text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
{index < menuItems.length - 1 && (
<div className="border-b border-gray-100 mx-4" />
)}
</div>
))}
</div>
{/* 退出登录按钮 */}
<div className="bg-white rounded-lg shadow-sm">
<button
onClick={handleLogout}
className="w-full flex items-center space-x-4 p-4 text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="h-5 w-5" />
<span className="font-medium">退</span>
</button>
</div>
{/* 版本信息 */}
<div className="text-center text-sm text-gray-400">
<p> v1.0.0</p>
<p className="mt-1">© 2024 . All rights reserved.</p>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Plus, Users, TrendingUp, Calendar, Settings } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Plus, Users, TrendingUp, Calendar } from 'lucide-react';
interface Plan {
id: string;
@@ -26,7 +26,6 @@ interface ScenarioData {
export default function ScenarioDetail() {
const { scenarioId } = useParams<{ scenarioId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [scenario, setScenario] = useState<ScenarioData | null>(null);
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -17,36 +17,36 @@ export default function Scenarios() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// AI智能获客用本地 mock 数据
const aiScenarios = [
{
id: "ai-friend",
name: "AI智能加友",
icon: "🤖",
count: 245,
growth: "+18.5%",
description: "智能分析目标用户画像,自动筛选优质客户",
path: "/scenarios/ai-friend",
},
{
id: "ai-group",
name: "AI群引流",
icon: "🤖",
count: 178,
growth: "+15.2%",
description: "智能群管理,提高群活跃度,增强获客效果",
path: "/scenarios/ai-group",
},
{
id: "ai-conversion",
name: "AI场景转化",
icon: "🤖",
count: 134,
growth: "+12.8%",
description: "多场景智能营销,提升获客转化率",
path: "/scenarios/ai-conversion",
},
];
// AI智能获客用本地 mock 数据(暂时注释掉,可以后续启用)
// const aiScenarios = [
// {
// id: "ai-friend",
// name: "AI智能加友",
// icon: "🤖",
// count: 245,
// growth: "+18.5%",
// description: "智能分析目标用户画像,自动筛选优质客户",
// path: "/scenarios/ai-friend",
// },
// {
// id: "ai-group",
// name: "AI群引流",
// icon: "🤖",
// count: 178,
// growth: "+15.2%",
// description: "智能群管理,提高群活跃度,增强获客效果",
// path: "/scenarios/ai-group",
// },
// {
// id: "ai-conversion",
// name: "AI场景转化",
// icon: "🤖",
// count: 134,
// growth: "+12.8%",
// description: "多场景智能营销,提升获客转化率",
// path: "/scenarios/ai-conversion",
// },
// ];
useEffect(() => {
const fetchScenarios = async () => {