Explorar o código

✨ feat(mobile): 实现移动端应用基础架构和登录功能

- 添加AuthProvider认证上下文,实现用户登录/登出和状态管理
- 创建登录页面组件,支持用户名密码登录和表单验证
- 实现ProtectedRoute路由保护组件,控制页面访问权限
- 添加错误页面和404页面,优化异常处理体验
- 创建移动端主页和路由配置,搭建应用基础框架
yourname hai 5 meses
pai
achega
5a538c24f9

+ 43 - 0
src/client/mobile/components/ErrorPage.tsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import { useRouteError, useNavigate } from 'react-router';
+import { Alert, Button } from 'antd';
+
+export const ErrorPage = () => {
+  const navigate = useNavigate();
+  const error = useRouteError() as any;
+  const errorMessage = error?.statusText || error?.message || '未知错误';
+  
+  return (
+    <div className="flex flex-col items-center justify-center flex-grow p-4"
+    >
+      <div className="max-w-3xl w-full">
+        <h1 className="text-2xl font-bold mb-4">发生错误</h1>
+        <Alert 
+          type="error"
+          message={error?.message || '未知错误'}
+          description={
+            error?.stack ? (
+              <pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
+                {error.stack}
+              </pre>
+            ) : null
+          }
+          className="mb-4"
+        />
+        <div className="flex gap-4">
+          <Button 
+            type="primary" 
+            onClick={() => navigate(0)}
+          >
+            重新加载
+          </Button>
+          <Button 
+            onClick={() => navigate('/admin')}
+          >
+            返回首页
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 26 - 0
src/client/mobile/components/NotFoundPage.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from 'antd';
+
+export const NotFoundPage = () => {
+  const navigate = useNavigate();
+  
+  return (
+    <div className="flex flex-col items-center justify-center flex-grow p-4">
+      <div className="max-w-3xl w-full">
+        <h1 className="text-2xl font-bold mb-4">404 - 页面未找到</h1>
+        <p className="mb-6 text-gray-600 dark:text-gray-300">
+          您访问的页面不存在或已被移除
+        </p>
+        <div className="flex gap-4">
+          <Button 
+            type="primary" 
+            onClick={() => navigate('/admin')}
+          >
+            返回首页
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 37 - 0
src/client/mobile/components/ProtectedRoute.tsx

@@ -0,0 +1,37 @@
+import React, { useEffect } from 'react';
+import { 
+  useNavigate,
+} from 'react-router';
+import { useAuth } from '../hooks/AuthProvider';
+
+
+
+
+
+export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
+  const { isAuthenticated, isLoading } = useAuth();
+  const navigate = useNavigate();
+  
+  useEffect(() => {
+    // 只有在加载完成且未认证时才重定向
+    if (!isLoading && !isAuthenticated) {
+      navigate('/admin/login', { replace: true });
+    }
+  }, [isAuthenticated, isLoading, navigate]);
+  
+  // 显示加载状态,直到认证检查完成
+  if (isLoading) {
+    return (
+      <div className="flex justify-center items-center h-screen">
+        <div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
+      </div>
+    );
+  }
+  
+  // 如果未认证且不再加载中,不显示任何内容(等待重定向)
+  if (!isAuthenticated) {
+    return null;
+  }
+  
+  return children;
+};

+ 140 - 0
src/client/mobile/hooks/AuthProvider.tsx

@@ -0,0 +1,140 @@
+import React, { useState, useEffect, createContext, useContext } from 'react';
+
+import {
+  useQuery,
+  useQueryClient,
+} from '@tanstack/react-query';
+import axios from 'axios';
+import 'dayjs/locale/zh-cn';
+import type {
+  AuthContextType
+} from '@/share/types';
+import { authClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+
+type User = InferResponseType<typeof authClient.me.$get, 200>;
+
+
+// 创建认证上下文
+const AuthContext = createContext<AuthContextType<User> | null>(null);
+
+// 认证提供器组件
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+  const [user, setUser] = useState<User | null>(null);
+  const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
+  const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
+  const queryClient = useQueryClient();
+
+  // 声明handleLogout函数
+  const handleLogout = async () => {
+    try {
+      // 如果已登录,调用登出API
+      if (token) {
+        await authClient.logout.$post();
+      }
+    } catch (error) {
+      console.error('登出请求失败:', error);
+    } finally {
+      // 清除本地状态
+      setToken(null);
+      setUser(null);
+      setIsAuthenticated(false);
+      localStorage.removeItem('token');
+      // 清除Authorization头
+      delete axios.defaults.headers.common['Authorization'];
+      console.log('登出时已删除全局Authorization头');
+      // 清除所有查询缓存
+      queryClient.clear();
+    }
+  };
+
+  // 使用useQuery检查登录状态
+  const { isLoading } = useQuery({
+    queryKey: ['auth', 'status', token],
+    queryFn: async () => {
+      if (!token) {
+        setIsAuthenticated(false);
+        setUser(null);
+        return null;
+      }
+
+      try {
+        // 设置全局默认请求头
+        axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+        // 使用API验证当前用户
+        const res = await authClient.me.$get();
+        if (res.status !== 200) {
+          const result = await res.json();
+          throw new Error(result.message)
+        }
+        const currentUser = await res.json();
+        setUser(currentUser);
+        setIsAuthenticated(true);
+        return { isValid: true, user: currentUser };
+      } catch (error) {
+        return { isValid: false };
+      }
+    },
+    enabled: !!token,
+    refetchOnWindowFocus: false,
+    retry: false
+  });
+
+  const handleLogin = async (username: string, password: string, latitude?: number, longitude?: number): Promise<User> => {
+    try {
+      // 使用AuthAPI登录
+      const response = await authClient.login.$post({
+        json: {
+          username,
+          password
+        }
+      })
+      if (response.status !== 200) {
+        const result = await response.json()
+        throw new Error(result.message);
+      }
+
+      const result = await response.json()
+
+      // 保存token和用户信息
+      const { token: newToken, user: newUser } = result;
+
+      // 设置全局默认请求头
+      axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
+
+      // 保存状态
+      setToken(newToken);
+      setUser(newUser);
+      setIsAuthenticated(true);
+      localStorage.setItem('token', newToken);
+      return newUser;
+    } catch (error) {
+      console.error('登录失败:', error);
+      throw error;
+    }
+  };
+
+  return (
+    <AuthContext.Provider
+      value={{
+        user,
+        token,
+        login: handleLogin,
+        logout: handleLogout,
+        isAuthenticated,
+        isLoading
+      }}
+    >
+      {children}
+    </AuthContext.Provider>
+  );
+};
+
+// 使用上下文的钩子
+export const useAuth = () => {
+  const context = useContext(AuthContext);
+  if (!context) {
+    throw new Error('useAuth必须在AuthProvider内部使用');
+  }
+  return context;
+};

+ 39 - 0
src/client/mobile/index.tsx

@@ -0,0 +1,39 @@
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from 'react-router';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import weekday from 'dayjs/plugin/weekday';
+import localeData from 'dayjs/plugin/localeData';
+import 'dayjs/locale/zh-cn';
+
+import { AuthProvider } from './hooks/AuthProvider';
+import { router } from './routes';
+
+// 配置 dayjs 插件
+dayjs.extend(weekday);
+dayjs.extend(localeData);
+
+// 设置 dayjs 语言
+dayjs.locale('zh-cn');
+
+// 创建QueryClient实例
+const queryClient = new QueryClient();
+
+// 应用入口组件
+const App = () => {
+  return (
+    <QueryClientProvider client={queryClient}>
+      <AuthProvider>
+        <RouterProvider router={router} />
+      </AuthProvider>
+    </QueryClientProvider>
+  )
+};
+
+const rootElement = document.getElementById('root')
+if (rootElement) {
+  const root = createRoot(rootElement)
+  root.render(
+    <App />
+  )
+}

+ 144 - 0
src/client/mobile/pages/Login.tsx

@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router';
+import { ArrowRightIcon, LockClosedIcon, UserIcon } from '@heroicons/react/24/outline';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { getGlobalConfig } from '@/client/utils/utils';
+
+
+// 登录页面组件
+export const LoginPage: React.FC = () => {
+  const { login } = useAuth();
+  const navigate = useNavigate();
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const handleLogin = async (e: React.FormEvent) => {
+    e.preventDefault();
+    
+    if (!username.trim() || !password.trim()) {
+      setError('用户名和密码不能为空');
+      return;
+    }
+    
+    setLoading(true);
+    setError(null);
+    
+    try {
+      
+      const user = await login(username, password);
+      navigate(user.roles?.some(role => role.name === 'admin') ? '/' : '/mobile/classroom');
+    } catch (err) {
+      setError( err instanceof Error ? err.message : '登录失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen flex flex-col bg-gradient-to-b from-blue-500 to-blue-700 p-6">
+      {/* 顶部Logo和标题 */}
+      <div className="flex flex-col items-center justify-center mt-10 mb-8">
+        <div className="w-20 h-20 bg-white rounded-2xl flex items-center justify-center shadow-lg mb-4">
+          <svg className="w-12 h-12 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" />
+            <path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
+            <path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </div>
+        <h1 className="text-3xl font-bold text-white">
+          {getGlobalConfig('APP_NAME') || '移动应用'}
+        </h1>
+        <p className="text-blue-100 mt-2">登录您的账户</p>
+      </div>
+
+      {/* 登录表单 */}
+      <div className="bg-white rounded-xl shadow-xl p-6 w-full">
+        {error && (
+          <div className="bg-red-50 text-red-700 p-3 rounded-lg mb-4 text-sm">
+            {error}
+          </div>
+        )}
+        
+        <form onSubmit={handleLogin}>
+          <div className="mb-4">
+            <label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="username">
+              用户名
+            </label>
+            <div className="relative">
+              <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+                <UserIcon className="h-5 w-5 text-gray-400" />
+              </div>
+              <input
+                id="username"
+                type="text"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                placeholder="请输入用户名"
+              />
+            </div>
+          </div>
+          
+          <div className="mb-6">
+            <label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="password">
+              密码
+            </label>
+            <div className="relative">
+              <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+                <LockClosedIcon className="h-5 w-5 text-gray-400" />
+              </div>
+              <input
+                id="password"
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                placeholder="请输入密码"
+              />
+            </div>
+          </div>
+          
+          <button
+            type="submit"
+            disabled={loading}
+            className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center"
+          >
+            {loading ? (
+              <svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
+                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+              </svg>
+            ) : (
+              <ArrowRightIcon className="h-5 w-5 mr-2" />
+            )}
+            {loading ? '登录中...' : '登录'}
+          </button>
+        </form>
+        
+        <div className="mt-6 flex items-center justify-between">
+          <button
+            type="button"
+            className="text-sm text-blue-600 hover:text-blue-700"
+            onClick={() => navigate('/mobile/register')}
+          >
+            注册账号
+          </button>
+          <button
+            type="button"
+            className="text-sm text-blue-600 hover:text-blue-700"
+          >
+            忘记密码?
+          </button>
+        </div>
+      </div>
+      
+      {/* 底部文本 */}
+      <div className="mt-auto pt-8 text-center text-blue-100 text-sm">
+        &copy; {new Date().getFullYear()} {getGlobalConfig('APP_NAME') || '移动应用'} 
+        <p className="mt-1">保留所有权利</p>
+      </div>
+    </div>
+  );
+};

+ 44 - 0
src/client/mobile/pages/StockHomePage.tsx

@@ -0,0 +1,44 @@
+import React from "react";
+import { useNavigate } from "react-router";
+import { useAuth } from "@/client/mobile/hooks/AuthProvider";
+
+export default function StockHomePage() {
+  const { user } = useAuth();
+  const navigate = useNavigate();
+
+  const handleClassroomClick = () => {
+    if (user?.roles?.some(role => role.name === 'admin')) {
+      navigate('/mobile/classroom?role=admin');
+    } else {
+      navigate('/mobile/classroom?role=student');
+    }
+  };
+
+  return (
+    <div className="min-h-screen bg-gray-50 p-4 md:p-8">
+      <h1 className="text-3xl font-bold text-center text-gray-800 mb-8 md:mb-12">
+        股票训练系统
+      </h1>
+      <div className="flex flex-col gap-4 max-w-md mx-auto">
+        <button
+          onClick={handleClassroomClick}
+          className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          解盘室
+        </button>
+        <button
+          onClick={() => navigate('/mobile/exam')}
+          className="bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          考试模式
+        </button>
+        <button
+          onClick={() => navigate('/mobile/xunlian')}
+          className="bg-purple-600 hover:bg-purple-700 text-white font-medium py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-center"
+        >
+          训练模式
+        </button>
+      </div>
+    </div>
+  );
+}

+ 69 - 0
src/client/mobile/routes.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import { createBrowserRouter, Navigate } from 'react-router';
+import { ProtectedRoute } from './components/ProtectedRoute';
+import { ErrorPage } from './components/ErrorPage';
+import { NotFoundPage } from './components/NotFoundPage';
+// import { MainLayout } from './layouts/MainLayout';
+// import { ClassroomDataPage } from './pages/ClassroomDataPage';
+// import { SubmissionRecordsPage } from './pages/SubmissionRecordsPage';
+// import { StockDataPage } from './pages/StockDataPage';
+// import { StockXunlianCodesPage } from './pages/StockXunlianCodesPage';
+// import { DateNotesPage } from './pages/DateNotesPage';
+import { LoginPage } from './pages/Login';
+import StockHomePage from './pages/StockHomePage';
+
+export const router = createBrowserRouter([
+  {
+    path: '/',
+    element: <Navigate to="/mobile" replace />
+  },
+  {
+    path: '/mobile/login',
+    element: <LoginPage />
+  },
+  {
+    path: '/mobile',
+    element: (
+      <ProtectedRoute>
+        <StockHomePage />
+      </ProtectedRoute>
+    ),
+    children: [
+      // {
+      //   path: 'classroom-data',
+      //   element: <ClassroomDataPage />,
+      //   errorElement: <ErrorPage />
+      // },
+      // {
+      //   path: 'submission-records',
+      //   element: <SubmissionRecordsPage />,
+      //   errorElement: <ErrorPage />
+      // },
+      // {
+      //   path: 'stock-data',
+      //   element: <StockDataPage />,
+      //   errorElement: <ErrorPage />
+      // },
+      // {
+      //   path: 'stock-xunlian-codes',
+      //   element: <StockXunlianCodesPage />,
+      //   errorElement: <ErrorPage />
+      // },
+      // {
+      //   path: 'date-notes',
+      //   element: <DateNotesPage />,
+      //   errorElement: <ErrorPage />
+      // },
+      {
+        path: '*',
+        element: <NotFoundPage />,
+        errorElement: <ErrorPage />
+      },
+    ],
+  },
+  {
+    path: '*',
+    element: <NotFoundPage />,
+    errorElement: <ErrorPage />
+  },
+]);

+ 1 - 1
src/share/types.ts

@@ -8,7 +8,7 @@ export interface GlobalConfig {
 export interface AuthContextType<T> {
   user: T | null;
   token: string | null;
-  login: (username: string, password: string, latitude?: number, longitude?: number) => Promise<void>;
+  login: (username: string, password: string, latitude?: number, longitude?: number) => Promise<T>;
   logout: () => Promise<void>;
   isAuthenticated: boolean;
   isLoading: boolean;