feat: init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# 接口基础地址,不配置时默认同源 /api
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 开发环境代理目标:/api 开头的请求会被转发到该地址(仅 vite dev 使用,不会打进前端包)
|
||||
DEV_PROXY_TARGET=http://localhost:8080
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件约定项目的目录结构与开发规范,新增代码前请先对照本文件决定放置位置与写法。
|
||||
|
||||
## 技术栈
|
||||
|
||||
React 19 + TypeScript + Vite · Tailwind CSS v4 · Ant Design · Zustand · TanStack Query + Axios · React Router
|
||||
|
||||
## 目录结构与职责
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # 静态资源(images/、icons/、styles/)
|
||||
├── components/ # 跨页面复用组件
|
||||
│ ├── common/ # 通用组件,无业务含义
|
||||
│ └── business/ # 跨页面复用的业务组件
|
||||
├── hooks/ # 全局通用 Hooks(不绑定具体页面)
|
||||
├── constants/ # 常量、枚举
|
||||
├── types/ # 全局共享 TypeScript 类型
|
||||
├── layouts/ # 页面布局(MainLayout 主框架、AuthLayout 登录类页面)
|
||||
├── lib/ # 基础设施与第三方库封装(axios 实例、QueryClient 等)
|
||||
├── router/ # 路由表(index.tsx)与路由守卫(guards/)
|
||||
├── services/ # API 服务层,按业务域一个文件
|
||||
├── stores/ # Zustand 全局客户端状态,一个 store 一个文件
|
||||
├── theme/ # Ant Design 主题配置
|
||||
├── utils/ # 通用工具函数(纯函数,无 React 依赖)
|
||||
├── pages/ # 页面模块,一个路由对应一个目录
|
||||
├── App.tsx # 全局 Provider 组装
|
||||
└── main.tsx # React 挂载入口
|
||||
```
|
||||
|
||||
页面模块结构:`pages/<name>/` 下为 `index.tsx`(页面入口)、`components/`(仅本页使用的组件)、`hooks/`(仅本页使用的 Hooks)、`types.ts`(仅本页使用的类型)。
|
||||
|
||||
## 分层与依赖方向
|
||||
|
||||
依赖只能自上而下,禁止反向引用:
|
||||
|
||||
`pages` → `components` / `hooks` / `stores` / `services` → `lib` / `utils` / `constants` / `types`
|
||||
|
||||
- `pages` 之间不得互相引用;需要共享时上移到全局目录。
|
||||
- `services`、`lib`、`utils` 不得依赖 React 组件或 `pages`。
|
||||
|
||||
## 页面与组件设计
|
||||
|
||||
核心原则:页面负责聚合,组件负责功能,按需复用,避免过度封装。
|
||||
|
||||
- **页面**:`pages/<name>/index.tsx` 负责布局与组件组合,允许少量必要的状态和协调逻辑,复杂逻辑抽到页面 `hooks/`。搜索条件、分页、弹窗状态等需要协同的页面级状态集中在页面入口或页面 Hook,通过 props 和回调向下传,子组件之间不直接互相依赖。
|
||||
- **组件**:职责单一,一个文件一个组件,props 用 `IXxxProps` 命名并加注释。页面专属组件放 `pages/<name>/components/`,被两个及以上页面使用时再提升到 `components/common`(无业务逻辑与接口请求)或 `components/business`(含业务语义)。
|
||||
- **Ant Design 优先**:已有组件直接使用,`components/common` 仅在确有统一封装需求时才二次封装。
|
||||
- **Hooks**:一个 Hook 对应一类逻辑,按功能命名(如 `useUsersQuery`、`useUserEditModal`),不写 `useXxxManagement` 这类万能 Hook;逻辑增多后再提取,不预先过度设计。
|
||||
|
||||
何时拆分组件:
|
||||
|
||||
| 场景 | 建议 |
|
||||
| ------------------------------ | -------------- |
|
||||
| 独立的搜索、表格、表单、弹窗 | 拆分 |
|
||||
| 具有复杂交互或独立业务逻辑 | 拆分 |
|
||||
| 多个页面重复使用的功能 | 抽取为公共组件 |
|
||||
| 仅有几行简单 JSX | 通常不必拆 |
|
||||
| 只被一个组件使用的简单 UI 片段 | 不强制拆分 |
|
||||
|
||||
## 请求与数据
|
||||
|
||||
- 所有 HTTP 请求通过 `lib/axios.ts` 的 `BaseClientService` 发出(统一处理 baseURL、登录 cookie、401 跳转与错误提示),页面和组件内禁止直接使用裸 `axios` / `fetch`。
|
||||
- `services/` 只负责「定义接口函数 + 入参出参类型」,不处理 UI 逻辑、不使用 Hooks。
|
||||
- 服务端数据用 TanStack Query 管理,封装为 `useXxxQuery` / `useXxxMutation`,放在页面 `hooks/`(跨页面复用则放全局 `hooks/`)。
|
||||
|
||||
## 状态管理(Zustand)
|
||||
|
||||
方案:公开 Store + Actions + 按需封装 Selector Hooks。简单状态直接读取,复杂或重复逻辑封装 Hook,所有状态修改统一通过 Action。
|
||||
|
||||
- `stores/` 仅存放全局客户端状态(侧边栏折叠、主题等 UI 偏好),按业务领域划分,文件命名 `xxx-store.ts`,导出 `useXxxStore`。
|
||||
- **服务端数据(含当前登录用户信息)交给 TanStack Query,不复制进 Zustand。** 页面局部状态用 `useState` / `useReducer`。
|
||||
- 统一使用 `create<IXxxStore>()(...)` 柯里化写法;State 与 Actions 写在同一接口中,用 `// State`、`// Actions` 分组注释。范例见 `stores/ui-store.ts`。
|
||||
|
||||
| 场景 | 约定 |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| 读取状态 | `useXxxStore((s) => s.xxx)`,**禁止不带 selector 的 `useXxxStore()`**(会订阅整个 store) |
|
||||
| 修改状态 | 只能通过 Actions,业务代码禁止直接调用 `setState()`,不直接修改 store 中的对象 / 数组 |
|
||||
| 返回对象 / 数组的 selector | **必须使用 `useShallow`**;Zustand v5 下 selector 每次返回新引用会触发无限重渲染报错 |
|
||||
| 组件外读取(路由守卫、axios 拦截器) | 允许 `useXxxStore.getState()` 读取;需要修改时调用 Action |
|
||||
|
||||
Selector Hook 仅在查询涉及业务逻辑且多处复用时才封装(如 `useIsAdmin`),与 store 放同一文件;单字段读取不必封装。不引入私有 Store,不额外创建 Actions 层。
|
||||
|
||||
## 路由
|
||||
|
||||
- 路由表统一在 `router/index.tsx` 维护,页面按需懒加载,布局作为路由层级的父级。
|
||||
- 登录校验、角色权限判断写在 `router/guards/`,以布局路由的方式包在路由外层:`RequireAuth`(需登录,可传 `roles` 限制角色)、`GuestOnly`(仅未登录可访问)。
|
||||
- 登录态以服务端为准,由 `useCurrentUserQuery` 判断,不存入 Zustand。登录成功后须使 `CURRENT_USER_QUERY_KEY` 失效或重新请求;退出登录须清除该缓存并跳转登录页。
|
||||
- 跳转 `redirect` 参数前必须经 `getSafeRedirect` 校验。
|
||||
|
||||
## 页面布局
|
||||
|
||||
核心原则:优先 Flexbox / Grid,减少不必要的定位布局,但不禁止合理使用 `position`。
|
||||
|
||||
- 主体结构、对齐、间距用 Flex / Grid + `gap`;二维布局用 Grid,一维排列用 Flex。
|
||||
- 吸顶用 `sticky`,悬浮按钮、固定导航用 `fixed`,角标与局部叠加用 `absolute`;避免大量用 `top`、`left`、`right` 微调位置,不依赖固定像素坐标实现页面结构。
|
||||
- 响应式使用 Tailwind 断点(`sm:`、`md:`、`lg:`)。
|
||||
- `z-index` 业务层级使用小值(如吸顶 `z-10`),并保持低于 Ant Design 浮层(从 1000 起)。
|
||||
- Flex / Grid 内容区(尤其含表格的)加 `min-w-0`,Grid 列使用 `minmax(0, 1fr)`,防止内容撑破布局。
|
||||
- 使用 Ant Design 时可结合 `Flex`、`Row`、`Col` 等组件。
|
||||
|
||||
## 样式与主题
|
||||
|
||||
- 优先使用 Tailwind 工具类;Ant Design 的视觉定制通过 `theme/index.ts` 的 token 统一配置,避免在业务代码里覆盖组件内部样式。
|
||||
- `assets/styles/` 仅放 Tailwind 不便表达的全局样式,全局样式入口是 `src/index.css`。
|
||||
|
||||
## 类型
|
||||
|
||||
- 全局共享类型放 `types/`,页面私有类型放 `pages/<name>/types.ts`。
|
||||
- 接口相关类型以 `I` 开头并按用途加后缀:请求参数 `IXxxRequest`、响应整体 `IXxxResponse`、响应中的业务数据 `IXxxData`;仅返回提示信息的响应使用 `IMsgOnlyResponse`。
|
||||
- 类型导入使用 `import type { ... }`。
|
||||
|
||||
## 代码约定
|
||||
|
||||
- **统一导出**:每一层目录(含页面内的 `components`、`hooks`)都有 `index.ts` 统一导出,外部通过目录路径引用(如 `import { queryClient } from "@/lib"`),不深入引用内部文件;新增文件后同步补充导出。页面入口使用 `export default`(便于路由懒加载),组件使用具名导出。
|
||||
- **注释**:每个文件顶部必须有一行 `//` 注释说明用途(页面文件直接写页面名,如 `// 登录页`);所有函数、常量、导出的类型都必须加 `/** ... */` 注释,一句话说明作用即可。
|
||||
- **命名**:组件文件 `PascalCase.tsx`,页面入口统一 `index.tsx`,Hooks 为 `useXxx.ts`,其余文件 `kebab-case.ts` 或与业务域同名的单词(如 `auth.ts`),目录名小写、多词用 `-` 连接。
|
||||
- **路径**:引用 `src` 内模块使用 `@/` 别名(`@` 指向 `src`),不使用多层 `../../../`;同目录或相邻层级可用相对路径。
|
||||
@@ -0,0 +1,33 @@
|
||||
# Frontend Template
|
||||
|
||||
React + TypeScript + Vite 前端通用模板。目录结构与开发规范见 [AGENTS.md](./AGENTS.md)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
React 19 · TypeScript · Vite · Tailwind CSS v4 · Ant Design · Zustand · TanStack Query · Axios · React Router
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local # 按需修改接口地址与代理目标
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 使用模板新建项目后需要修改
|
||||
|
||||
- `package.json` 的 `name`
|
||||
- `index.html` 的 `<title>`
|
||||
- `.env.local` 中的 `DEV_PROXY_TARGET`、`VITE_API_BASE_URL`
|
||||
- `src/constants/api.ts` 中的 `LOGIN_PATH`、`UNAUTHORIZED_IGNORE_URLS`
|
||||
- `src/theme/index.ts` 中的主题色
|
||||
- `src/pages/` 下的占位页面
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev # 开发
|
||||
pnpm build # 类型检查 + 构建
|
||||
pnpm lint # ESLint
|
||||
pnpm preview # 预览构建产物
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser
|
||||
}
|
||||
}
|
||||
]);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frontend Template</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend-template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"antd": "^6.6.5",
|
||||
"axios": "^1.20.0",
|
||||
"postcss": "^8.5.29",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.4.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.6",
|
||||
"globals": "^17.12.0",
|
||||
"prettier": "^3.9.9",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
Generated
+3226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
+19
@@ -0,0 +1,19 @@
|
||||
// 全局 Provider 与应用根组件
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
import { queryClient } from "@/lib";
|
||||
import { router } from "@/router";
|
||||
import { themeConfig } from "@/theme";
|
||||
|
||||
/** 应用根组件,按「数据 → 主题 → 路由」顺序组装全局 Provider */
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={themeConfig}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// 跨页面业务组件统一导出
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
// 通用组件统一导出
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,4 @@
|
||||
// 组件统一导出
|
||||
|
||||
export * from "./common";
|
||||
export * from "./business";
|
||||
@@ -0,0 +1,10 @@
|
||||
// 接口相关常量
|
||||
|
||||
/** 登录页路由路径 */
|
||||
export const LOGIN_PATH = "/login";
|
||||
|
||||
/** 获取当前登录用户的接口地址(用于路由守卫判断登录态) */
|
||||
export const CURRENT_USER_API = "/auth/me";
|
||||
|
||||
/** 401 时不跳转登录页的接口地址(如登录接口,由表单就地提示;当前用户接口由路由守卫处理) */
|
||||
export const UNAUTHORIZED_IGNORE_URLS: string[] = [CURRENT_USER_API];
|
||||
@@ -0,0 +1,3 @@
|
||||
// 常量统一导出
|
||||
|
||||
export * from "./api";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 全局通用 Hooks 统一导出
|
||||
|
||||
export * from "./useCurrentUserQuery";
|
||||
@@ -0,0 +1,16 @@
|
||||
// 当前登录用户查询
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUser } from "@/services";
|
||||
|
||||
/** 当前登录用户的 Query Key,登录或退出后需据此刷新或清除缓存 */
|
||||
export const CURRENT_USER_QUERY_KEY = ["auth", "current-user"] as const;
|
||||
|
||||
/** 查询当前登录用户,登录态与用户信息以服务端为准,由 TanStack Query 缓存 */
|
||||
export const useCurrentUserQuery = () =>
|
||||
useQuery({
|
||||
queryKey: CURRENT_USER_QUERY_KEY,
|
||||
queryFn: getCurrentUser,
|
||||
// 未登录(401)属于预期结果,不重试
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,11 @@
|
||||
// 登录类页面布局
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
/** 登录类页面布局,用于登录、注册等无需登录态的页面 */
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 主框架布局
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
/** 主框架布局,承载需要登录后访问的页面 */
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 布局统一导出
|
||||
|
||||
export { default as MainLayout } from "./MainLayout";
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
@@ -0,0 +1,43 @@
|
||||
// Axios 请求实例与拦截器
|
||||
import axios from "axios";
|
||||
import { LOGIN_PATH, UNAUTHORIZED_IGNORE_URLS } from "@/constants";
|
||||
import { message } from "./message";
|
||||
|
||||
/** 接口基础地址,优先读取环境变量,默认同源 /api */
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
/** 请求超时时间(毫秒) */
|
||||
const TIMEOUT = 300000;
|
||||
|
||||
/** 全局 Axios 实例,所有接口请求统一通过它发出 */
|
||||
const BaseClientService = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: TIMEOUT,
|
||||
// 同源请求自动携带 httpOnly 登录 cookie,无需手动注入 token
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
// 响应拦截器
|
||||
BaseClientService.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 登录态失效:整页跳回登录页以清空客户端缓存;忽略名单内的接口由调用方自行处理
|
||||
if (!UNAUTHORIZED_IGNORE_URLS.includes(error.config?.url)) {
|
||||
window.location.assign(LOGIN_PATH);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
message.error(error.response?.data?.msg ?? error.message);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/** 判断请求是否因登录态无效(401)失败 */
|
||||
export const isUnauthorized = (error: unknown) =>
|
||||
axios.isAxiosError(error) && error.response?.status === 401;
|
||||
|
||||
export default BaseClientService;
|
||||
@@ -0,0 +1,5 @@
|
||||
// 基础设施统一导出
|
||||
|
||||
export { default as BaseClientService, isUnauthorized } from "./axios";
|
||||
export { message } from "./message";
|
||||
export { queryClient } from "./query-client";
|
||||
@@ -0,0 +1,5 @@
|
||||
// 全局消息提示封装
|
||||
|
||||
import { message } from "antd";
|
||||
|
||||
export { message };
|
||||
@@ -0,0 +1,13 @@
|
||||
// TanStack Query 客户端实例
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** 全局 QueryClient,默认不在窗口聚焦时重新请求 */
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// React 挂载入口
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
// 仪表盘页
|
||||
|
||||
/** 仪表盘页 */
|
||||
export default function DashboardPage() {
|
||||
return <div>Dashboard</div>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 登录页
|
||||
|
||||
/** 登录页 */
|
||||
export default function LoginPage() {
|
||||
return <div>Login</div>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// 用户管理页组件统一导出
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
// 用户管理页 Hooks 统一导出
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,6 @@
|
||||
// 用户管理页
|
||||
|
||||
/** 用户管理页 */
|
||||
export default function UsersPage() {
|
||||
return <div>Users</div>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// 用户管理页类型
|
||||
@@ -0,0 +1,21 @@
|
||||
// 仅未登录可访问守卫
|
||||
import { Spin } from "antd";
|
||||
import { Navigate, Outlet, useSearchParams } from "react-router";
|
||||
import { useCurrentUserQuery } from "@/hooks";
|
||||
import { getSafeRedirect } from "@/utils";
|
||||
|
||||
/** 作为布局路由使用:已登录访问登录页时跳回来源页(或首页),否则渲染子路由 */
|
||||
export function GuestOnly() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: user, isPending } = useCurrentUserQuery();
|
||||
|
||||
if (isPending) return <Spin fullscreen />;
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<Navigate to={getSafeRedirect(searchParams.get("redirect"))} replace />
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 登录校验与角色校验守卫
|
||||
import { Button, Result, Spin } from "antd";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
import { LOGIN_PATH } from "@/constants";
|
||||
import { useCurrentUserQuery } from "@/hooks";
|
||||
import { isUnauthorized } from "@/lib";
|
||||
|
||||
/** 登录校验守卫的属性 */
|
||||
interface IRequireAuthProps {
|
||||
/** 允许访问的角色,满足其一即可;不传则只校验登录 */
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/** 作为布局路由使用:未登录跳转登录页并记录来源,无角色权限展示 403,通过则渲染子路由 */
|
||||
export function RequireAuth({ roles }: IRequireAuthProps) {
|
||||
const location = useLocation();
|
||||
const { data: user, isPending, error, refetch } = useCurrentUserQuery();
|
||||
|
||||
if (isPending) return <Spin fullscreen />;
|
||||
|
||||
if (error) {
|
||||
if (isUnauthorized(error)) {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`${LOGIN_PATH}?redirect=${redirect}`} replace />;
|
||||
}
|
||||
// 网络异常等非 401 错误:提示已由请求拦截器弹出,这里提供重试入口
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle="无法获取登录状态,请稍后重试"
|
||||
extra={<Button onClick={() => refetch()}>重试</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (roles?.length && !roles.some((role) => user.roles.includes(role))) {
|
||||
return (
|
||||
<Result status="403" title="403" subTitle="抱歉,您没有权限访问该页面" />
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 路由守卫统一导出
|
||||
|
||||
export * from "./RequireAuth";
|
||||
export * from "./GuestOnly";
|
||||
@@ -0,0 +1,47 @@
|
||||
// 路由配置
|
||||
import type { RouteObject } from "react-router";
|
||||
import { createBrowserRouter, Navigate } from "react-router";
|
||||
import { LOGIN_PATH } from "@/constants";
|
||||
import { AuthLayout, MainLayout } from "@/layouts";
|
||||
import { GuestOnly, RequireAuth } from "./guards";
|
||||
|
||||
/** 页面模块的动态导入函数类型 */
|
||||
type PageLoader = () => Promise<{ default: React.ComponentType }>;
|
||||
|
||||
/** 将页面的动态导入转换为路由懒加载配置,实现按需加载 */
|
||||
const lazyPage = (loader: PageLoader): Pick<RouteObject, "lazy"> => ({
|
||||
lazy: async () => ({ Component: (await loader()).default })
|
||||
});
|
||||
|
||||
/** 路由表 */
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
// 需要登录;限制角色时写成 <RequireAuth roles={["admin"]} />
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
Component: MainLayout,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: "dashboard", ...lazyPage(() => import("@/pages/dashboard")) },
|
||||
{ path: "users", ...lazyPage(() => import("@/pages/users")) }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// 仅未登录可访问
|
||||
element: <GuestOnly />,
|
||||
children: [
|
||||
{
|
||||
Component: AuthLayout,
|
||||
children: [
|
||||
{ path: LOGIN_PATH, ...lazyPage(() => import("@/pages/login")) }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** 全局路由实例 */
|
||||
export const router = createBrowserRouter(routes);
|
||||
@@ -0,0 +1,11 @@
|
||||
// 认证相关接口
|
||||
import { CURRENT_USER_API } from "@/constants";
|
||||
import { BaseClientService } from "@/lib";
|
||||
import type { IGetCurrentUserData } from "@/types";
|
||||
|
||||
/** 获取当前登录用户,未登录时接口返回 401 */
|
||||
export const getCurrentUser = async () => {
|
||||
const response =
|
||||
await BaseClientService.get<IGetCurrentUserData>(CURRENT_USER_API);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// API 服务层统一导出
|
||||
|
||||
export * from "./auth";
|
||||
export * from "./user";
|
||||
@@ -0,0 +1 @@
|
||||
// 用户相关接口
|
||||
@@ -0,0 +1 @@
|
||||
// 登录态全局状态
|
||||
@@ -0,0 +1,4 @@
|
||||
// 全局状态统一导出
|
||||
|
||||
export * from "./auth-store";
|
||||
export * from "./ui-store";
|
||||
@@ -0,0 +1,25 @@
|
||||
// 界面偏好全局状态
|
||||
import { create } from "zustand";
|
||||
|
||||
/** 界面偏好 Store 的状态与操作 */
|
||||
interface IUiStore {
|
||||
// State
|
||||
/** 侧边栏是否折叠 */
|
||||
sidebarCollapsed: boolean;
|
||||
|
||||
// Actions
|
||||
/** 设置侧边栏折叠状态 */
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
/** 切换侧边栏折叠状态 */
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
/** 界面偏好 Store */
|
||||
export const useUiStore = create<IUiStore>()((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
// Ant Design 主题配置
|
||||
import type { ThemeConfig } from "antd";
|
||||
|
||||
/** Ant Design 全局主题,视觉定制统一在此调整 token */
|
||||
export const themeConfig: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: "#1677ff"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// 接口通用类型
|
||||
|
||||
/** 仅包含提示信息的响应 */
|
||||
export interface IMsgOnlyResponse {
|
||||
msg: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 认证相关类型
|
||||
|
||||
/** 获取当前登录用户的响应数据 */
|
||||
export interface IGetCurrentUserData {
|
||||
/** 用户 ID */
|
||||
id: string;
|
||||
/** 用户名称 */
|
||||
name: string;
|
||||
/** 角色标识列表,用于路由守卫的权限判断 */
|
||||
roles: string[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 全局类型统一导出
|
||||
|
||||
export * from "./api";
|
||||
export * from "./auth";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 通用工具函数统一导出
|
||||
|
||||
export * from "./navigation";
|
||||
@@ -0,0 +1,11 @@
|
||||
// 路由跳转相关工具函数
|
||||
|
||||
/** 获取安全的跳转地址,仅允许站内路径,防止开放重定向 */
|
||||
export const getSafeRedirect = (redirect: string | null, fallback = "/") => {
|
||||
const isInternalPath =
|
||||
!!redirect &&
|
||||
redirect.startsWith("/") &&
|
||||
!redirect.startsWith("//") &&
|
||||
!redirect.startsWith("/\\");
|
||||
return isInternalPath ? redirect : fallback;
|
||||
};
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// 环境变量类型声明
|
||||
interface ImportMetaEnv {
|
||||
/** 接口基础地址 */
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path alias */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 第三个参数传空字符串,读取所有环境变量(含不带 VITE_ 前缀的 DEV_PROXY_TARGET)
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
// 开发环境将 /api 代理到后端,避免跨域;后端接口需带 /api 前缀,否则在此配置 rewrite
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: env.DEV_PROXY_TARGET ?? "http://localhost:8080",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user