feat: 登录模块完成
This commit is contained in:
@@ -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
82
nkebao/src/api/auth.ts
Normal 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>;
|
||||
},
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
CreateDeviceParams,
|
||||
UpdateDeviceParams,
|
||||
DeviceStatus,
|
||||
ServerDevice,
|
||||
ServerDevicesResponse
|
||||
} from '@/types/device';
|
||||
|
||||
|
||||
13
nkebao/src/api/index.ts
Normal file
13
nkebao/src/api/index.ts
Normal 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';
|
||||
152
nkebao/src/api/interceptors.ts
Normal file
152
nkebao/src/api/interceptors.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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
100
nkebao/src/api/utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
244
nkebao/src/contexts/AuthContext.tsx
Normal file
244
nkebao/src/contexts/AuthContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
nkebao/src/hooks/useAuthGuard.ts
Normal file
80
nkebao/src/hooks/useAuthGuard.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user