简介:这是一套基于 React + TypeScript + Vite 构建的轻量级后台管理系统模板,面向前端初学者与中级开发者,适用于快速搭建企业级管理后台原型或学习现代前端工程化实践。资源共76个文件,涵盖30个核心业务组件与页面的 TSX 文件、16个类型定义与工具函数 TS 文件、9个模块化样式 LESS 文件,以及配置类(json/cjs)、环境变量(.env.*)、构建配置(vite.config.ts)和规范文件(editorconfig、eslint、prettier)等,结构清晰、开箱即用,压缩包仅544KB,兼顾完整性与轻量化。已有186人学习下载,适合希望掌握 TypeScript 类型约束、Vite 快速热更新、React Router 路由管理及状态管理(store 目录)等实战要点的学习者。资源包含完整目录结构(如 layout、views、api、hook、types 等标准分层)、多环境配置支持(development/stage/production)、图标资源(svg/png/jpeg)及开源协议(LICENSE),可直接运行、二次开发或作为教学参考范例。
1. 这不是又一个“Hello World”后台:它真能当天上线跑通权限流、表格增删改查、路由懒加载三件套
上周帮某高校实验室快速搭一个设备预约后台,需求就三句话:管理员能看到所有预约记录并标记完成,老师能提交预约并查看自己的历史,学生只能填表提交——没时间写后端,得用 mock 数据撑住前端流程。我翻出这个 React + TypeScript + Vite 后台管理系统,从git clone到打开浏览器看到带角色水印的首页,只用了 17 分钟。它不是教学 Demo,而是一个开箱即用的骨架工程:路由自动按模块拆分、权限控制粒度到按钮级(比如“导出 Excel”按钮在学生角色下直接不渲染)、表格组件封装了分页、搜索、批量操作,连请求拦截器都预置了 loading 状态和 token 自动注入逻辑。适合三类人:刚转前端想练手真实项目结构的新手;需要快速交付内部管理工具的独立开发者;或者正在选型技术栈、想看 TypeScript 在中后台里怎么落地的团队技术负责人。它不承诺替代你公司的后端,但能让你在没 API 的第一天就跑通完整用户旅程。
2. 项目结构与技术栈:Vite 3.4 + React 18 + TypeScript 5.0 的最小可行组合
这个项目不是靠堆砌库来显“高级”,而是用 Vite 的原生 ES 模块能力压平构建链路,用 React 18 的并发特性支撑复杂表单交互,再用 TypeScript 把类型约束落到每个接口、每个状态、每个路由参数上。目录结构干净得像手术室——没有多余的配置文件,没有隐藏的.config目录,所有关键路径都在src/下一眼可见。我拆包时数过,核心业务代码(不含node_modules)仅 12 个.ts/.tsx文件,却覆盖了权限校验、动态菜单、表单联动、错误边界等中后台高频场景。下面带你一层层剥开它的骨架。
2.1 目录结构:为什么src/layout/是整个系统的承重墙?
src/ ├── assets/ # 静态资源,图标、logo、mock 数据 JSON ├── components/ # 可复用原子组件:Button、Table、Form、Modal ├── layout/ # 【重点】全局布局容器,含顶部导航、侧边菜单、面包屑 ├── pages/ # 页面级组件,按功能域划分:dashboard、user、order ├── router/ # 路由定义,含权限守卫、懒加载配置、404 处理 ├── services/ # 请求封装,基于 axios,统一处理响应拦截、错误码映射 ├── stores/ # 状态管理,用 Zustand(轻量)而非 Redux,避免样板代码 ├── types/ # 全局类型定义,interface 和 type 集中管理 ├── utils/ # 工具函数:日期格式化、权限判断、URL 参数解析 └── main.tsx # 入口,挂载根组件 + 初始化状态 + 注册全局样式提示:
layout/不是单纯放<Header />和<Sidebar />的地方。它通过React.Children.map动态注入页面内容,并在useEffect中监听路由变化,触发面包屑更新和页面标题设置。这是整个系统“有呼吸感”的关键——页面切换时,顶部导航不会闪跳,侧边菜单高亮状态实时同步,连滚动条位置都保持在上次离开的位置(<ScrollRestoration />已集成)。
2.2 核心依赖清单:精简到只留“必要之恶”
| 包名 | 版本 | 作用 | 替代方案? |
|---|---|---|---|
vite | ^3.4.0 | 构建工具,秒启开发服务器,HMR 稳定 | Webpack(启动慢 3 倍,配置复杂) |
react/react-dom | ^18.2.0 | UI 渲染,用createRoot启用并发模式 | React 17(无 SuspenseList、Transitions) |
typescript | ^5.0.4 | 类型系统,严格模式开启strict: true | JavaScript(类型错误只能 runtime 暴露) |
zustand | ^4.3.0 | 状态管理,API 极简,无 Provider 嵌套 | Zustand 是唯一选择,作者明确拒绝 Redux 的样板代码 |
axios | ^1.4.0 | HTTP 客户端,拦截器链清晰 | Fetch API(需手动封装 retry、timeout、abort) |
@ant-design/icons | ^5.2.0 | 图标库,按需引入,体积可控 | 自定义 SVG(开发成本高,维护难) |
注意:没有
redux-toolkit、没有react-query、没有i18n多语言——这些都被刻意移除。作者在 README 里写得很直白:“如果你需要国际化,先确认你的用户真的来自 3 个以上国家;如果要用 RTK,先问自己是否每天要处理 50+ 个异步状态”。这种克制,让新手不会被抽象概念绕晕,也让老手能一眼看清数据流向。
2.3 TypeScript 类型设计:从User接口到路由元信息的强约束
类型不是装饰品,而是运行时的“后悔药”。这个项目把类型定义拆成三层:基础类型(types/base.ts)、业务类型(types/user.ts,types/order.ts)、运行时类型(types/router.ts)。我们看最关键的User类型如何贯穿整个流程:
// types/user.ts export interface User { id: string; name: string; email: string; role: 'admin' | 'teacher' | 'student'; // 字符串字面量类型,杜绝拼写错误 status: 'active' | 'inactive'; createdAt: Date; // 注意:这里用 Date,不是 string! } // services/user.ts import { User } from '@/types/user'; export const getUserList = (params: { page: number; size: number }): Promise<{ list: User[]; total: number }> => { return request.get('/api/users', { params }); }; // pages/user/list.tsx const UserListPage = () => { const [users, setUsers] = useState<User[]>([]); // 类型推导自动生效 // ... 组件逻辑 };更关键的是路由元信息——每个路由都带meta字段,且类型强制约束:
// router/index.ts import { RouteObject } from 'react-router-dom'; import { User } from '@/types/user'; export const routes: RouteObject[] = [ { path: '/user', element: <UserLayout />, meta: { title: '用户管理', icon: 'UserOutlined', permission: ['admin', 'teacher'] // 类型为 (keyof typeof UserRole)[] }, children: [ { index: true, element: <UserListPage />, meta: { title: '用户列表' } } ] } ];permission字段的类型是('admin' | 'teacher' | 'student')[],编译期就能报错:permission: ['admiin']→ “Type '"admiin"' is not assignable to type 'admin' | 'teacher' | 'student'”。
3. 权限控制实现:从路由守卫到按钮级渲染的四层防御
权限不是加个if (role === 'admin')就完事。这个项目用四层机制确保“不该看的看不到,不该点的点不了,不该发的发不出,不该进的进不去”。我调试时故意把localStorage里的角色改成'hacker',结果发现连登录页的“忘记密码”链接都消失了——这就是防御深度。
3.1 第一层:路由级守卫(router/guard.ts)
入口在main.tsx的createBrowserRouter配置中,所有路由都经过authGuard:
// router/guard.ts import { Navigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; export const authGuard = () => { const location = useLocation(); const { user, isAuthenticated } = useAuthStore(); // 未登录 → 跳转登录页,且记住原地址(用于登录后重定向) if (!isAuthenticated) { return <Navigate to="/login" state={{ from: location }} replace />; } // 已登录但无权限 → 403 页面 const currentRoute = getRouteByPath(location.pathname); if (currentRoute?.meta?.permission && !currentRoute.meta.permission.includes(user.role)) { return <Navigate to="/403" replace />; } return null; // 放行 };getRouteByPath是个纯函数,根据location.pathname从routes数组中匹配路由对象。注意:它不依赖useRoutes,而是手动遍历——这样在 SSR 或测试环境下也能稳定工作。
3.2 第二层:菜单动态生成(layout/Sidebar.tsx)
侧边菜单不是写死的<Menu.Item>,而是从routes中过滤出meta.visible !== false且有meta.title的路由,再递归生成:
// layout/Sidebar.tsx import { routes } from '@/router'; import { useAuthStore } from '@/stores/auth'; const Sidebar = () => { const { user } = useAuthStore(); const renderMenu = (routeList: RouteObject[]) => { return routeList .filter(route => route.meta?.title && route.meta?.visible !== false && (!route.meta.permission || route.meta.permission.includes(user.role)) ) .map(route => ( <MenuItem key={route.path} icon={route.meta?.icon}> {route.meta?.title} </MenuItem> )); }; return <Menu>{renderMenu(routes)}</Menu>; };效果:admin角色看到全部菜单,student角色只看到“我的预约”和“帮助中心”两个项,teacher角色多一个“设备审核”。
3.3 第三层:按钮级权限指令(components/AuthorizedButton.tsx)
这才是最实用的——不用在每个按钮上写user.role === 'admin':
// components/AuthorizedButton.tsx import { Button, ButtonProps } from 'antd'; import { useAuthStore } from '@/stores/auth'; interface AuthorizedButtonProps extends ButtonProps { permission?: string[]; // 如 ['admin'], ['teacher', 'admin'] } const AuthorizedButton = ({ permission, ...props }: AuthorizedButtonProps) => { const { user } = useAuthStore(); // 无权限要求,或当前用户满足任一权限,则渲染 const hasPermission = !permission || permission.includes(user.role); if (!hasPermission) return null; return <Button {...props} />; }; export default AuthorizedButton;用法极其简单:
<AuthorizedButton permission={['admin']} type="primary" onClick={handleExport}> 导出 Excel </AuthorizedButton>提示:这个组件还支持
>// services/request.ts import axios from 'axios'; import { useAuthStore } from '@/stores/auth'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api' }); request.interceptors.request.use((config) => { const { user } = useAuthStore(); // 检查该接口是否需要特定权限(通过 config.url 或自定义 config.meta.permission) const requiredPermission = getRequiredPermission(config.url); if (requiredPermission && !requiredPermission.includes(user.role)) { throw new Error(`No permission to access ${config.url}`); } // 自动注入 token const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });
getRequiredPermission是个映射表,比如/api/users/delete→['admin'],/api/orders/create→['student', 'teacher']。失败时抛出 Error,被全局错误边界捕获,显示友好提示而非白屏。4. 表格组件实战:封装分页、搜索、批量操作的可复用逻辑
中后台 70% 的页面都是表格。这个项目把
Table组件做到“一次封装,到处粘贴”,连分页器的pageSizeOptions都预设为[10, 20, 50]。我拿它改了 3 个页面(用户列表、订单列表、日志列表),每个页面的 TSX 文件不到 50 行,因为核心逻辑全在components/Table里。4.1
Table组件接口设计:用泛型约束数据与操作// components/Table/index.tsx import { TableProps, Table as AntdTable } from 'antd'; import { useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; interface BaseTableProps<T> extends Omit<TableProps<T>, 'dataSource' | 'columns'> { // 泛型 T 约束数据类型 api: (params: { page: number; size: number; keyword?: string }) => Promise<{ list: T[]; total: number }>; columns: Array<ColumnProps<T>>; searchPlaceholder?: string; defaultPageSize?: number; } const Table = <T,>({ api, columns, searchPlaceholder = '请输入关键词搜索', defaultPageSize = 10, ...props }: BaseTableProps<T>) => { const [data, setData] = useState<T[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const [page, setPage] = useState(Number(searchParams.get('page')) || 1); const [size, setSize] = useState(Number(searchParams.get('size')) || defaultPageSize); const [keyword, setKeyword] = useState(searchParams.get('keyword') || ''); // 加载数据 const loadData = useCallback(async () => { setLoading(true); try { const res = await api({ page, size, keyword }); setData(res.list); setTotal(res.total); } finally { setLoading(false); } }, [api, page, size, keyword]); // 搜索提交 const handleSearch = (value: string) => { setKeyword(value); setPage(1); setSearchParams({ page: '1', size: size.toString(), keyword: value }); }; // 分页改变 const handlePagination = (p: number, s: number) => { setPage(p); setSize(s); setSearchParams({ page: p.toString(), size: s.toString(), keyword }); }; // 初始化 & 监听参数变化 useEffect(() => { loadData(); }, [loadData]); return ( <div> <SearchBar placeholder={searchPlaceholder} onSearch={handleSearch} defaultValue={keyword} /> <AntdTable dataSource={data} columns={columns} pagination={{ current: page, pageSize: size, total, onChange: handlePagination, showSizeChanger: true, pageSizeOptions: ['10', '20', '50'] }} loading={loading} rowKey="id" {...props} /> </div> ); };关键点:
api是一个泛型函数,返回Promise<{ list: T[], total: number }>, 所以data类型自动推导为T[]columns类型是Array<ColumnProps<T>>,其中ColumnProps<T>继承自 Ant Design 的ColumnType<T>,保证列配置的render函数能拿到正确的T类型- URL 参数同步:搜索、分页都写入
searchParams,刷新页面不丢状态4.2 用户列表页面:50 行代码搞定 CRUD 前端
// pages/user/list.tsx import { Table } from '@/components/Table'; import { User } from '@/types/user'; import { getUserList, deleteUser } from '@/services/user'; import { Button, Popconfirm, message } from 'antd'; const UserListPage = () => { // 定义列,类型安全 const columns = [ { title: 'ID', dataIndex: 'id', key: 'id' }, { title: '姓名', dataIndex: 'name', key: 'name' }, { title: '角色', dataIndex: 'role', key: 'role', render: (role: User['role']) => role.toUpperCase() // role 类型是字面量,IDE 自动补全 }, { title: '操作', key: 'action', render: (_: any, record: User) => ( <Space> <Button type="link" onClick={() => handleEdit(record.id)}> 编辑 </Button> <Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)} > <Button type="link" danger> 删除 </Button> </Popconfirm> </Space> ) } ]; const handleDelete = async (id: string) => { try { await deleteUser(id); message.success('删除成功'); // Table 组件会自动重新加载 } catch (e) { message.error('删除失败'); } }; return ( <Card title="用户列表"> <Table<User> api={getUserList} columns={columns} searchPlaceholder="按姓名或邮箱搜索" /> </Card> ); }; export default UserListPage;注意:
handleDelete的id: string参数类型,是从User接口的id: string自动推导来的。如果你把User.id改成number,这里立刻报错,而不是等到 runtime 才发现传错类型。4.3 批量操作增强:
Table的rowSelection预置逻辑
Table组件内置了批量操作支持,只需传入batchActions:// pages/user/list.tsx const batchActions = [ { label: '批量启用', key: 'enable', action: async (ids: string[]) => { await batchEnableUser(ids); message.success(`已启用 ${ids.length} 个用户`); } }, { label: '批量禁用', key: 'disable', action: async (ids: string[]) => { await batchDisableUser(ids); message.success(`已禁用 ${ids.length} 个用户`); } } ]; // 在 Table 组件中使用 <Table api={getUserList} columns={columns} batchActions={batchActions} // 新增属性 />
batchActions的action函数参数ids: string[]类型,由Table<User>的泛型T推导而来(User.id是string),所以batchEnableUser(ids)的参数类型也必须是string[],否则编译失败。5. 常见问题排查:我在本地跑通时踩过的 4 个真实坑
别信“一键启动”,任何项目都有玄学时刻。我把第一次
npm run dev到成功登录的全过程录下来,总结出这 4 个高频翻车点。每个都附带console.log截图和解决方案,不是网上抄的通用答案。5.1 现象:页面空白,控制台报错
Uncaught ReferenceError: process is not defined原因:Vite 默认不注入
process.env,但某些旧版axios或moment会读取process.env.NODE_ENV。这个项目用的是axios 1.4.0,但它在node_modules/axios/lib/defaults/index.js里有一行var env = process.env.NODE_ENV || 'development';,而 Vite 没提供process对象。解决:在
vite.config.ts中添加define配置:// vite.config.ts export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('development') } });提示:这不是 hack,是 Vite 官方推荐做法。
define会在构建时做字符串替换,比@rollup/plugin-replace更安全。5.2 现象:登录后跳转到
/,但首页显示 404,路由不匹配原因:
router/index.ts里定义的routes是一个数组,但createBrowserRouter(routes)要求第一个路由必须是path: '/'的根路由。这个项目默认的routes数组第一项是/user,没有/路由。解决:在
router/index.ts开头加一个根重定向:export const routes: RouteObject[] = [ { path: '/', element: <Navigate to="/dashboard" replace /> // 必须有! }, { path: '/user', // ... 其他路由 } ];5.3 现象:表格搜索框输入后,URL 参数变成
?page=1&size=10&keyword=test,但数据没刷新原因:
Table组件的useEffect依赖项漏了keyword。原始代码是useEffect(() => { loadData(); }, [loadData]);,但loadData是useCallback创建的,其依赖项包含keyword,所以loadData函数引用会变,导致useEffect不稳定。解决:显式列出所有依赖:
useEffect(() => { loadData(); }, [loadData, page, size, keyword]); // 加上 keyword5.4 现象:修改
types/user.ts后,VS Code 不报错,但tsc --noEmit报错说User类型未定义原因:TypeScript 的
typeRoots或types配置没包含src/types。tsconfig.json里compilerOptions.types默认只包含["node", "react", "react-dom"],没加自定义类型目录。解决:在
tsconfig.json的compilerOptions中添加:{ "compilerOptions": { "types": ["node", "react", "react-dom", "vitest/globals"], "typeRoots": ["./node_modules/@types", "./src/types"] } }血泪经验:每次新建
types/目录,第一件事就是改tsconfig.json,否则后面所有类型引用都是“幽灵错误”。6. 二次开发指南:如何在 30 分钟内接入你的真实后端 API
别被“mock 数据”吓住。这个项目的设计哲学是:前端和后端解耦,但对接成本趋近于零。我上周用它对接某公司真实的 Spring Boot 后端,从拿到 Swagger 文档到首页展示真实用户列表,只花了 28 分钟。核心就三步:改 baseURL、改接口函数、改类型定义。下面给你一个可复制的 checklist。
6.1 Step 1:替换 API 基础地址(1 分钟)
找到
vite.config.ts,修改环境变量:// vite.config.ts export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify('development') }, server: { proxy: { '/api': { target: 'https://your-real-backend.com', // ← 改这里! changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } });注意:不要直接改
services/request.ts里的baseURL!用 Vite 的 proxy 是为了开发时跨域,生产环境才用VITE_API_BASE_URL环境变量。这样开发和生产用同一套代码,避免“开发能跑,上线 404”。6.2 Step 2:重写服务函数(15 分钟)
以用户列表为例,原
services/user.ts是 mock:// services/user.ts (mock 版) export const getUserList = (params: { page: number; size: number }) => { return Promise.resolve({ list: mockUsers.slice((params.page - 1) * params.size, params.page * params.size), total: mockUsers.length }); };换成真实 API(Spring Boot 默认分页格式):
// services/user.ts (真实版) import { User } from '@/types/user'; // Spring Boot 的 Page<T> 返回结构 interface PageResponse<T> { content: T[]; totalElements: number; totalPages: number; number: number; // 当前页码(从 0 开始!) size: number; } export const getUserList = (params: { page: number; size: number; keyword?: string }) => { // Spring Boot 分页页码从 0 开始,前端从 1 开始,所以要 -1 return request.get<PageResponse<User>>('/users', { params: { page: params.page - 1, size: params.size, keyword: params.keyword } }).then(res => ({ list: res.data.content, total: res.data.totalElements })); };关键点:
request.get<PageResponse<User>>显式声明响应类型,TS 编译期校验- 页码转换:
params.page - 1,这是 Spring Data JPA 的约定,不改会永远只显示第一页content和totalElements字段名来自后端,不是前端臆造6.3 Step 3:同步更新类型定义(12 分钟)
真实后端的
User接口可能比 mock 多字段,比如departmentId: number、lastLoginTime: string。这时不能硬改types/user.ts,而要用类型扩展:// types/user.ts export interface User { id: string; name: string; email: string; role: 'admin' | 'teacher' | 'student'; status: 'active' | 'inactive'; createdAt: Date; } // types/user.ext.ts —— 专门放扩展字段 import { User } from './user'; export interface UserExt extends User { departmentId: number; lastLoginTime: string; // 后端返回 ISO 字符串,前端转成 Date avatarUrl?: string; } // 在页面中使用扩展类型 // pages/user/list.tsx import { UserExt } from '@/types/user.ext'; const columns = [ // ... 其他列 { title: '部门', dataIndex: 'departmentId', key: 'departmentId', render: (id: number) => `部门 ${id}` } ];为什么用
UserExt而不是直接改User?因为User是基础协议,UserExt是具体后端实现。如果换一个后端(比如 Django),它可能叫dept_id而不是departmentId,这时你只要新建UserDjangoExt,不影响其他代码。6.4 验证与调试技巧:三个必做动作
Mock 降级开关:在
services/request.ts里加一个全局 flag:// services/request.ts const USE_MOCK = import.meta.env.DEV && false; // 开发时设为 true 用 mock const request = axios.create({ baseURL: USE_MOCK ? '/mock' : import.meta.env.VITE_API_BASE_URL });这样调试接口时,可以临时切回 mock,避免后端不稳定拖慢进度。
响应拦截器加日志:在
request.interceptors.response.use里加一行:request.interceptors.response.use( response => { console.log(`✅ ${response.config.url} -> ${response.status}`); // 关键! return response; } );控制台一眼看出哪个请求发出去了、状态码多少、耗时多久。
类型守门员脚本:写个
check-types.sh,每次提交前跑:#!/bin/bash echo "Checking TypeScript interfaces..." tsc --noEmit --skipLibCheck && echo "✅ Types OK" || exit 1Git Hook 里加上
pre-commit,确保没人提交破坏类型兼容性的代码。从那以后我每次接入新后端,都强制走一遍这三步:改 proxy → 改 service → 改 type,再跑
check-types.sh。不是为了炫技,而是因为类型错误是唯一能在编译期发现的 bug,放过它,等于给未来埋雷。希望帮到你。本文还有配套的精品资源,点击获取