Sfoglia il codice sorgente

✨ feat(admin): 新增基于 Ant Design 的管理后台系统

- 创建完整的 React + TypeScript 管理后台架构
- 集成 React Router、React Query、Axios 等核心依赖
- 实现用户认证系统(登录/登出/权限验证)
- 添加错误处理页面(404页面和通用错误页)
- 构建响应式主布局(侧边栏、顶部导航、内容区域)
- 开发用户管理模块(列表、创建、编辑、删除功能)
- 实现仪表盘页面展示核心数据统计
- 配置国际化支持(中文界面)
- 添加菜单搜索和权限控制功能
yourname 4 mesi fa
parent
commit
8daa6c6d1a

+ 43 - 0
src/client/admin-shadcn/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/admin-shadcn/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/admin-shadcn/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/admin-shadcn/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<void> => {
+    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);
+
+    } 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;
+};

+ 60 - 0
src/client/admin-shadcn/index.tsx

@@ -0,0 +1,60 @@
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from 'react-router';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { App as AntdApp , ConfigProvider} from 'antd'
+import dayjs from 'dayjs';
+import weekday from 'dayjs/plugin/weekday';
+import localeData from 'dayjs/plugin/localeData';
+import 'dayjs/locale/zh-cn';
+import zhCN from 'antd/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}>
+      <ConfigProvider locale={zhCN} theme={{
+        token: {
+          colorPrimary: '#1890ff',
+          borderRadius: 4,
+          colorBgContainer: '#f5f5f5',
+        },
+        components: {
+          Button: {
+            borderRadius: 4,
+          },
+          Card: {
+            borderRadius: 6,
+            boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
+          }
+        }
+      }}>
+        <AntdApp>
+          <AuthProvider>
+            <RouterProvider router={router} />
+          </AuthProvider>
+        </AntdApp>
+      </ConfigProvider>
+    </QueryClientProvider>
+  )
+};
+
+const rootElement = document.getElementById('root')
+if (rootElement) {
+  const root = createRoot(rootElement)
+  root.render(
+    <App />
+  )
+}

+ 228 - 0
src/client/admin-shadcn/layouts/MainLayout.tsx

@@ -0,0 +1,228 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+  Outlet,
+  useLocation,
+} from 'react-router';
+import {
+  Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
+} from 'antd';
+import {
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+  BellOutlined,
+  VerticalAlignTopOutlined,
+  UserOutlined
+} from '@ant-design/icons';
+import { useAuth } from '../hooks/AuthProvider';
+import { useMenu, useMenuSearch, type MenuItem } from '../menu';
+import { getGlobalConfig } from '@/client/utils/utils';
+
+const { Header, Sider, Content } = Layout;
+
+/**
+ * 主布局组件
+ * 包含侧边栏、顶部导航和内容区域
+ */
+export const MainLayout = () => {
+  const { user } = useAuth();
+  const [showBackTop, setShowBackTop] = useState(false);
+  const location = useLocation();
+  
+  // 使用菜单hook
+  const {
+    menuItems,
+    userMenuItems,
+    openKeys,
+    collapsed,
+    setCollapsed,
+    handleMenuClick: handleRawMenuClick,
+    onOpenChange
+  } = useMenu();
+  
+  // 处理菜单点击
+  const handleMenuClick = (key: string) => {
+    const item = findMenuItem(menuItems, key);
+    if (item && 'label' in item) {
+      handleRawMenuClick(item);
+    }
+  };
+  
+  // 查找菜单项
+  const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
+    for (const item of items) {
+      if (!item) continue;
+      if (item.key === key) return item;
+      if (item.children) {
+        const found = findMenuItem(item.children, key);
+        if (found) return found;
+      }
+    }
+    return null;
+  };
+  
+  // 使用菜单搜索hook
+  const {
+    searchText,
+    setSearchText,
+    filteredMenuItems
+  } = useMenuSearch(menuItems);
+  
+  // 获取当前选中的菜单项
+  const selectedKey = useMemo(() => {
+    const findSelectedKey = (items: MenuItem[]): string | null => {
+      for (const item of items) {
+        if (!item) continue;
+        if (item.path === location.pathname) return item.key || null;
+        if (item.children) {
+          const childKey = findSelectedKey(item.children);
+          if (childKey) return childKey;
+        }
+      }
+      return null;
+    };
+    
+    return findSelectedKey(menuItems) || '';
+  }, [location.pathname, menuItems]);
+  
+  // 检测滚动位置,控制回到顶部按钮显示
+  useEffect(() => {
+    const handleScroll = () => {
+      setShowBackTop(window.pageYOffset > 300);
+    };
+    
+    window.addEventListener('scroll', handleScroll);
+    return () => window.removeEventListener('scroll', handleScroll);
+  }, []);
+  
+  // 回到顶部
+  const scrollToTop = () => {
+    window.scrollTo({
+      top: 0,
+      behavior: 'smooth'
+    });
+  };
+
+  
+  // 应用名称 - 从CONFIG中获取或使用默认值
+  const appName = getGlobalConfig('APP_NAME') || '应用Starter';
+  
+  return (
+    <Layout style={{ minHeight: '100vh' }}>
+      <Sider
+        trigger={null}
+        collapsible
+        collapsed={collapsed}
+        width={240}
+        className="custom-sider"
+        theme='light'
+        style={{
+          overflow: 'auto',
+          height: '100vh',
+          position: 'fixed',
+          left: 0,
+          top: 0,
+          bottom: 0,
+          zIndex: 100,
+          transition: 'all 0.2s ease',
+          boxShadow: '2px 0 8px 0 rgba(29, 35, 41, 0.05)',
+          background: 'linear-gradient(180deg, #001529 0%, #003a6c 100%)',
+        }}
+      >
+        <div className="p-4">
+          <Typography.Title level={2} className="text-xl font-bold truncate">
+            <span className="text-white">{collapsed ? '应用' : appName}</span>
+          </Typography.Title>
+          
+          {/* 菜单搜索框 */}
+          {!collapsed && (
+            <div className="mb-4">
+              <Input.Search
+                placeholder="搜索菜单..."
+                allowClear
+                value={searchText}
+                onChange={(e) => setSearchText(e.target.value)}
+              />
+            </div>
+          )}
+        </div>
+        
+        {/* 菜单列表 */}
+        <Menu
+          theme='dark'
+          mode="inline"
+          items={filteredMenuItems}
+          openKeys={openKeys}
+          selectedKeys={[selectedKey]}
+          onOpenChange={onOpenChange}
+          onClick={({ key }) => handleMenuClick(key)}
+          inlineCollapsed={collapsed}
+          style={{
+            backgroundColor: 'transparent',
+            borderRight: 'none'
+          }}
+        />
+      </Sider>
+      
+      <Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
+        <div className="sticky top-0 z-50 bg-white shadow-sm transition-all duration-200 h-16 flex items-center justify-between pl-2"
+          style={{
+            boxShadow: '0 1px 8px rgba(0,21,41,0.12)',
+            borderBottom: '1px solid #f0f0f0'
+          }}
+        >
+          <Button
+            type="text"
+            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
+            onClick={() => setCollapsed(!collapsed)}
+            className="w-16 h-16"
+          />
+          
+          <Space size="middle" className="mr-4">
+            <Badge count={5} offset={[0, 5]}>
+              <Button 
+                type="text" 
+                icon={<BellOutlined />}
+              />
+            </Badge>
+            
+            <Dropdown menu={{ items: userMenuItems }}>
+              <Space className="cursor-pointer">
+                <Avatar 
+                  src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
+                  icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
+                />
+                <span>
+                  {user?.nickname || user?.username}
+                </span>
+              </Space>
+            </Dropdown>
+          </Space>
+        </div>
+        
+        <Content className="m-6" style={{ overflow: 'initial', transition: 'all 0.2s ease' }}>
+          <div className="site-layout-content p-6 rounded-lg bg-white shadow-sm transition-all duration-300 hover:shadow-md">
+            <Outlet />
+          </div>
+          
+          {/* 回到顶部按钮 */}
+          {showBackTop && (
+            <Button
+              type="primary"
+              shape="circle"
+              icon={<VerticalAlignTopOutlined />}
+              size="large"
+              onClick={scrollToTop}
+              style={{
+                position: 'fixed',
+                right: 30,
+                bottom: 30,
+                zIndex: 1000,
+                boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
+              }}
+            />
+          )}
+        </Content>
+      </Layout>
+    </Layout>
+  );
+};

+ 128 - 0
src/client/admin-shadcn/menu.tsx

@@ -0,0 +1,128 @@
+import React from 'react';
+import { useNavigate } from 'react-router';
+import { useAuth } from './hooks/AuthProvider';
+import type { MenuProps } from 'antd';
+import {
+  UserOutlined,
+  DashboardOutlined,
+  TeamOutlined,
+  InfoCircleOutlined,
+} from '@ant-design/icons';
+
+export interface MenuItem {
+  key: string;
+  label: string;
+  icon?: React.ReactNode;
+  children?: MenuItem[];
+  path?: string;
+  permission?: string;
+}
+
+/**
+ * 菜单搜索 Hook
+ * 封装菜单搜索相关逻辑
+ */
+export const useMenuSearch = (menuItems: MenuItem[]) => {
+  const [searchText, setSearchText] = React.useState('');
+
+  // 过滤菜单项
+  const filteredMenuItems = React.useMemo(() => {
+    if (!searchText) return menuItems;
+    
+    const filterItems = (items: MenuItem[]): MenuItem[] => {
+      return items
+        .map(item => {
+          // 克隆对象避免修改原数据
+          const newItem = { ...item };
+          if (newItem.children) {
+            newItem.children = filterItems(newItem.children);
+          }
+          return newItem;
+        })
+        .filter(item => {
+          // 保留匹配项或其子项匹配的项
+          const match = item.label.toLowerCase().includes(searchText.toLowerCase());
+          if (match) return true;
+          if (item.children?.length) return true;
+          return false;
+        });
+    };
+    
+    return filterItems(menuItems);
+  }, [menuItems, searchText]);
+
+  // 清除搜索
+  const clearSearch = () => {
+    setSearchText('');
+  };
+
+  return {
+    searchText,
+    setSearchText,
+    filteredMenuItems,
+    clearSearch
+  };
+};
+
+export const useMenu = () => {
+  const navigate = useNavigate();
+  const { logout: handleLogout } = useAuth();
+  const [collapsed, setCollapsed] = React.useState(false);
+  const [openKeys, setOpenKeys] = React.useState<string[]>([]);
+
+  // 基础菜单项配置
+  const menuItems: MenuItem[] = [
+    {
+      key: 'dashboard',
+      label: '控制台',
+      icon: <DashboardOutlined />,
+      path: '/admin/dashboard'
+    },
+    {
+      key: 'users',
+      label: '用户管理',
+      icon: <TeamOutlined />,
+      path: '/admin/users',
+      permission: 'user:manage'
+    },
+  ];
+
+  // 用户菜单项
+  const userMenuItems: MenuProps['items'] = [
+    {
+      key: 'profile',
+      label: '个人资料',
+      icon: <UserOutlined />
+    },
+    {
+      key: 'logout',
+      label: '退出登录',
+      icon: <InfoCircleOutlined />,
+      danger: true,
+      onClick: () => handleLogout()
+    }
+  ];
+
+  // 处理菜单点击
+  const handleMenuClick = (item: MenuItem) => {
+    if (item.path) {
+      navigate(item.path);
+    }
+  };
+
+  // 处理菜单展开变化
+  const onOpenChange = (keys: string[]) => {
+    const latestOpenKey = keys.find(key => openKeys.indexOf(key) === -1);
+    setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
+  };
+
+  return {
+    menuItems,
+    userMenuItems,
+    openKeys,
+    collapsed,
+    setCollapsed,
+    handleMenuClick,
+    onOpenChange
+  };
+};

+ 75 - 0
src/client/admin-shadcn/pages/Dashboard.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+import {
+  Card, Row, Col, Typography, Statistic, Space
+} from 'antd';
+import {
+  UserOutlined, BellOutlined, EyeOutlined
+} from '@ant-design/icons';
+
+const { Title } = Typography;
+
+// 仪表盘页面
+export const DashboardPage = () => {
+  return (
+    <div>
+      <div className="mb-6 flex justify-between items-center">
+        <Title level={2}>仪表盘</Title>
+      </div>
+      <Row gutter={[16, 16]}>
+        <Col xs={24} sm={12} lg={8}>
+          <Card className="shadow-sm transition-all duration-300 hover:shadow-md">
+            <div className="flex items-center justify-between mb-2">
+              <Typography.Title level={5}>活跃用户</Typography.Title>
+              <UserOutlined style={{ fontSize: 24, color: '#1890ff' }} />
+            </div>
+            <Statistic
+              value={112893}
+              loading={false}
+              valueStyle={{ fontSize: 28 }}
+              prefix={<span style={{ color: '#52c41a' }}>↑</span>}
+              suffix="人"
+            />
+            <div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
+              较昨日增长 12.5%
+            </div>
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} lg={8}>
+          <Card className="shadow-sm transition-all duration-300 hover:shadow-md">
+            <div className="flex items-center justify-between mb-2">
+              <Typography.Title level={5}>系统消息</Typography.Title>
+              <BellOutlined style={{ fontSize: 24, color: '#faad14' }} />
+            </div>
+            <Statistic
+              value={93}
+              loading={false}
+              valueStyle={{ fontSize: 28 }}
+              prefix={<span style={{ color: '#faad14' }}>●</span>}
+              suffix="条"
+            />
+            <div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
+              其中 5 条未读
+            </div>
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} lg={8}>
+          <Card className="shadow-sm transition-all duration-300 hover:shadow-md">
+            <div className="flex items-center justify-between mb-2">
+              <Typography.Title level={5}>在线用户</Typography.Title>
+              <EyeOutlined style={{ fontSize: 24, color: '#722ed1' }} />
+            </div>
+            <Statistic
+              value={1128}
+              loading={false}
+              valueStyle={{ fontSize: 28 }}
+              suffix="人"
+            />
+            <div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
+              当前在线率 32.1%
+            </div>
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  );
+};

+ 129 - 0
src/client/admin-shadcn/pages/Login.tsx

@@ -0,0 +1,129 @@
+import React, { useState } from 'react';
+import {
+  Form,
+  Input,
+  Button,
+  Card,
+  App,
+} from 'antd';
+import {
+  UserOutlined,
+  LockOutlined,
+  EyeOutlined,
+  EyeInvisibleOutlined
+} from '@ant-design/icons';
+import { useNavigate } from 'react-router';
+import {
+  useAuth,
+} from '../hooks/AuthProvider';
+
+
+// 登录页面
+export const LoginPage = () => {
+  const { message } = App.useApp();
+  const { login } = useAuth();
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+  
+  const handleSubmit = async (values: { username: string; password: string }) => {
+    try {
+      setLoading(true);
+      
+      // 获取地理位置
+      let latitude: number | undefined;
+      let longitude: number | undefined;
+      
+      try {
+        if (navigator.geolocation) {
+          const position = await new Promise<GeolocationPosition>((resolve, reject) => {
+            navigator.geolocation.getCurrentPosition(resolve, reject);
+          });
+          latitude = position.coords.latitude;
+          longitude = position.coords.longitude;
+        }
+      } catch (geoError) {
+        console.warn('获取地理位置失败:', geoError);
+      }
+      
+      await login(values.username, values.password, latitude, longitude);
+      // 登录成功后跳转到管理后台首页
+      navigate('/admin/dashboard');
+    } catch (error: any) {
+      message.error(error instanceof Error ? error.message : '登录失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+  
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
+      <div className="max-w-md w-full space-y-8">
+        <div className="text-center">
+          <div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
+            <UserOutlined style={{ fontSize: 32, color: '#1890ff' }} />
+          </div>
+          <h2 className="mt-2 text-center text-3xl font-extrabold text-gray-900">
+            管理后台登录
+          </h2>
+          <p className="mt-2 text-gray-500">请输入您的账号和密码</p>
+        </div>
+        
+        <Card className="shadow-lg border-none transition-all duration-300 hover:shadow-xl">
+          <Form
+            form={form}
+            name="login"
+            onFinish={handleSubmit}
+            autoComplete="off"
+            layout="vertical"
+          >
+            <Form.Item
+              name="username"
+              rules={[{ required: true, message: '请输入用户名' }]}
+              label="用户名"
+            >
+              <Input
+                prefix={<UserOutlined className="text-primary" />}
+                placeholder="请输入用户名"
+                size="large"
+                className="transition-all duration-200 focus:border-primary focus:ring-1 focus:ring-primary"
+              />
+            </Form.Item>
+            
+            <Form.Item
+              name="password"
+              rules={[{ required: true, message: '请输入密码' }]}
+              label="密码"
+            >
+              <Input.Password
+                prefix={<LockOutlined className="text-primary" />}
+                placeholder="请输入密码"
+                size="large"
+                iconRender={(visible) => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)}
+                className="transition-all duration-200 focus:border-primary focus:ring-1 focus:ring-primary"
+              />
+            </Form.Item>
+            
+            <Form.Item>
+              <Button
+                type="primary"
+                htmlType="submit"
+                size="large"
+                block
+                loading={loading}
+                className="h-12 text-lg transition-all duration-200 hover:shadow-lg"
+              >
+                登录
+              </Button>
+            </Form.Item>
+          </Form>
+          
+          <div className="mt-6 text-center text-gray-500 text-sm">
+            <p>测试账号: <span className="font-medium">admin</span> / <span className="font-medium">admin123</span></p>
+            <p className="mt-1">© {new Date().getFullYear()} 管理系统. 保留所有权利.</p>
+          </div>
+        </Card>
+      </div>
+    </div>
+  );
+};

+ 342 - 0
src/client/admin-shadcn/pages/Users.tsx

@@ -0,0 +1,342 @@
+import React, { useState } from 'react';
+import {
+  Button, Table, Space, Form, Input, Select, Modal, Card, Typography, Tag, Popconfirm,
+  App
+} from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import { roleClient, userClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+
+type UserListResponse = InferResponseType<typeof userClient.$get, 200>;
+type RoleListResponse = InferResponseType<typeof roleClient.$get, 200>;
+type CreateRoleRequest = InferRequestType<typeof roleClient.$post>['json'];
+type UserDetailResponse = InferResponseType<typeof userClient[':id']['$get'], 200>;
+type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
+type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];
+
+const { Title } = Typography;
+
+// 用户管理页面
+export const UsersPage = () => {
+  const { message } = App.useApp();
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: ''
+  });
+  const [modalVisible, setModalVisible] = useState(false);
+  const [modalTitle, setModalTitle] = useState('');
+  const [editingUser, setEditingUser] = useState<any>(null);
+  const [form] = Form.useForm();
+
+  const { data: usersData, isLoading, refetch } = useQuery({
+    queryKey: ['users', searchParams],
+    queryFn: async () => {
+      const res = await userClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取用户列表失败');
+      }
+      return await res.json();
+    }
+  });
+
+  const users = usersData?.data || [];
+  const pagination = {
+    current: searchParams.page,
+    pageSize: searchParams.limit,
+    total: usersData?.pagination?.total || 0
+  };
+
+  // 处理搜索
+  const handleSearch = (values: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      search: values.search || '',
+      page: 1
+    }));
+  };
+
+  // 处理分页变化
+  const handleTableChange = (newPagination: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      page: newPagination.current,
+      limit: newPagination.pageSize
+    }));
+  };
+
+  // 打开创建用户模态框
+  const showCreateModal = () => {
+    setModalTitle('创建用户');
+    setEditingUser(null);
+    form.resetFields();
+    setModalVisible(true);
+  };
+
+  // 打开编辑用户模态框
+  const showEditModal = (user: any) => {
+    setModalTitle('编辑用户');
+    setEditingUser(user);
+    form.setFieldsValue(user);
+    setModalVisible(true);
+  };
+
+  // 处理模态框确认
+  const handleModalOk = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (editingUser) {
+        // 编辑用户
+        const res = await userClient[':id']['$put']({
+          param: { id: editingUser.id },
+          json: values
+        });
+        if (res.status !== 200) {
+          throw new Error('更新用户失败');
+        }
+        message.success('用户更新成功');
+      } else {
+        // 创建用户
+        const res = await userClient.$post({
+          json: values
+        });
+        if (res.status !== 201) {
+          throw new Error('创建用户失败');
+        }
+        message.success('用户创建成功');
+      }
+      
+      setModalVisible(false);
+      form.resetFields();
+      refetch(); // 刷新用户列表
+    } catch (error) {
+      console.error('表单提交失败:', error);
+      message.error('操作失败,请重试');
+    }
+  };
+
+  // 处理删除用户
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await userClient[':id']['$delete']({
+        param: { id }
+      });
+      if (res.status !== 204) {
+        throw new Error('删除用户失败');
+      }
+      message.success('用户删除成功');
+      refetch(); // 刷新用户列表
+    } catch (error) {
+      console.error('删除用户失败:', error);
+      message.error('删除失败,请重试');
+    }
+  };
+  
+  const columns = [
+    {
+      title: '用户名',
+      dataIndex: 'username',
+      key: 'username',
+    },
+    {
+      title: '昵称',
+      dataIndex: 'nickname',
+      key: 'nickname',
+    },
+    {
+      title: '邮箱',
+      dataIndex: 'email',
+      key: 'email',
+    },
+    {
+      title: '真实姓名',
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: '角色',
+      dataIndex: 'role',
+      key: 'role',
+      render: (role: string) => (
+        <Tag color={role === 'admin' ? 'red' : 'blue'}>
+          {role === 'admin' ? '管理员' : '普通用户'}
+        </Tag>
+      ),
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: any) => (
+        <Space size="middle">
+          <Button type="link" onClick={() => showEditModal(record)}>
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定要删除此用户吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger>
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div>
+      <div className="mb-6 flex justify-between items-center">
+        <Title level={2}>用户管理</Title>
+      </div>
+      <Card className="shadow-md transition-all duration-300 hover:shadow-lg">
+        <Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16, padding: '16px 0' }}>
+          <Form.Item name="search" label="搜索">
+            <Input placeholder="用户名/昵称/邮箱" allowClear />
+          </Form.Item>
+          <Form.Item>
+            <Space>
+              <Button type="primary" htmlType="submit">
+                搜索
+              </Button>
+              <Button type="primary" onClick={showCreateModal}>
+                创建用户
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+
+        <Table
+          columns={columns}
+          dataSource={users}
+          loading={isLoading}
+          rowKey="id"
+          pagination={{
+            ...pagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total) => `共 ${total} 条记录`
+          }}
+          onChange={handleTableChange}
+          bordered
+          scroll={{ x: 'max-content' }}
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </Card>
+
+      {/* 创建/编辑用户模态框 */}
+      <Modal
+        title={modalTitle}
+        open={modalVisible}
+        onOk={handleModalOk}
+        onCancel={() => {
+          setModalVisible(false);
+          form.resetFields();
+        }}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          labelCol={{ span: 5 }}
+          wrapperCol={{ span: 19 }}
+        >
+          <Form.Item
+            name="username"
+            label="用户名"
+            required
+            rules={[
+              { required: true, message: '请输入用户名' },
+              { min: 3, message: '用户名至少3个字符' }
+            ]}
+          >
+            <Input placeholder="请输入用户名" />
+          </Form.Item>
+
+          <Form.Item
+            name="nickname"
+            label="昵称"
+            rules={[{ required: false, message: '请输入昵称' }]}
+          >
+            <Input placeholder="请输入昵称" />
+          </Form.Item>
+
+          <Form.Item
+            name="email"
+            label="邮箱"
+            rules={[
+              { required: false, message: '请输入邮箱' },
+              { type: 'email', message: '请输入有效的邮箱地址' }
+            ]}
+          >
+            <Input placeholder="请输入邮箱" />
+          </Form.Item>
+
+          <Form.Item
+            name="phone"
+            label="手机号"
+            rules={[
+              { required: false, message: '请输入手机号' },
+              { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }
+            ]}
+          >
+            <Input placeholder="请输入手机号" />
+          </Form.Item>
+
+          <Form.Item
+            name="name"
+            label="真实姓名"
+            rules={[{ required: false, message: '请输入真实姓名' }]}
+          >
+            <Input placeholder="请输入真实姓名" />
+          </Form.Item>
+
+          {!editingUser && (
+            <Form.Item
+              name="password"
+              label="密码"
+              required
+              rules={[
+                { required: true, message: '请输入密码' },
+                { min: 6, message: '密码至少6个字符' }
+              ]}
+            >
+              <Input.Password placeholder="请输入密码" />
+            </Form.Item>
+          )}
+
+          <Form.Item
+            name="isDisabled"
+            label="状态"
+            required
+            rules={[{ required: true, message: '请选择状态' }]}
+          >
+            <Select placeholder="请选择状态">
+              <Select.Option value={0}>启用</Select.Option>
+              <Select.Option value={1}>禁用</Select.Option>
+            </Select>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};

+ 54 - 0
src/client/admin-shadcn/routes.tsx

@@ -0,0 +1,54 @@
+import React from 'react';
+import { createBrowserRouter, Navigate } from 'react-router';
+import { ProtectedRoute } from './components/ProtectedRoute';
+import { MainLayout } from './layouts/MainLayout';
+import { ErrorPage } from './components/ErrorPage';
+import { NotFoundPage } from './components/NotFoundPage';
+import { DashboardPage } from './pages/Dashboard';
+import { UsersPage } from './pages/Users';
+import { LoginPage } from './pages/Login';
+
+export const router = createBrowserRouter([
+  {
+    path: '/',
+    element: <Navigate to="/admin" replace />
+  },
+  {
+    path: '/admin/login',
+    element: <LoginPage />
+  },
+  {
+    path: '/admin',
+    element: (
+      <ProtectedRoute>
+        <MainLayout />
+      </ProtectedRoute>
+    ),
+    children: [
+      {
+        index: true,
+        element: <Navigate to="/admin/dashboard" />
+      },
+      {
+        path: 'dashboard',
+        element: <DashboardPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'users',
+        element: <UsersPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: '*',
+        element: <NotFoundPage />,
+        errorElement: <ErrorPage />
+      },
+    ],
+  },
+  {
+    path: '*',
+    element: <NotFoundPage />,
+    errorElement: <ErrorPage />
+  },
+]);