feat: init

This commit is contained in:
2026-10-09 00:16:37 -07:00
commit 3b4e377395
58 changed files with 3995 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 接口基础地址,不配置时默认同源 /api
VITE_API_BASE_URL=/api
# 开发环境代理目标:/api 开头的请求会被转发到该地址(仅 vite dev 使用,不会打进前端包)
DEV_PROXY_TARGET=http://localhost:8080
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
pnpm-lock.yaml
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "none"
}
+119
View File
@@ -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`),不使用多层 `../../../`;同目录或相邻层级可用相对路径。
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+33
View File
@@ -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.
+22
View File
@@ -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
View File
@@ -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>
+41
View File
@@ -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"
}
}
+3226
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
// 跨页面业务组件统一导出
export {};
+3
View File
@@ -0,0 +1,3 @@
// 通用组件统一导出
export {};
+4
View File
@@ -0,0 +1,4 @@
// 组件统一导出
export * from "./common";
export * from "./business";
+10
View File
@@ -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];
+3
View File
@@ -0,0 +1,3 @@
// 常量统一导出
export * from "./api";
+3
View File
@@ -0,0 +1,3 @@
// 全局通用 Hooks 统一导出
export * from "./useCurrentUserQuery";
+16
View File
@@ -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
});
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+11
View File
@@ -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>
);
}
+11
View File
@@ -0,0 +1,11 @@
// 主框架布局
import { Outlet } from "react-router";
/** 主框架布局,承载需要登录后访问的页面 */
export default function MainLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
// 布局统一导出
export { default as MainLayout } from "./MainLayout";
export { default as AuthLayout } from "./AuthLayout";
+43
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
// 基础设施统一导出
export { default as BaseClientService, isUnauthorized } from "./axios";
export { message } from "./message";
export { queryClient } from "./query-client";
+5
View File
@@ -0,0 +1,5 @@
// 全局消息提示封装
import { message } from "antd";
export { message };
+13
View File
@@ -0,0 +1,13 @@
// TanStack Query 客户端实例
import { QueryClient } from "@tanstack/react-query";
/** 全局 QueryClient,默认不在窗口聚焦时重新请求 */
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1
}
}
});
+11
View File
@@ -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>
);
+6
View File
@@ -0,0 +1,6 @@
// 仪表盘页
/** 仪表盘页 */
export default function DashboardPage() {
return <div>Dashboard</div>;
}
+6
View File
@@ -0,0 +1,6 @@
// 登录页
/** 登录页 */
export default function LoginPage() {
return <div>Login</div>;
}
+3
View File
@@ -0,0 +1,3 @@
// 用户管理页组件统一导出
export {};
+3
View File
@@ -0,0 +1,3 @@
// 用户管理页 Hooks 统一导出
export {};
+6
View File
@@ -0,0 +1,6 @@
// 用户管理页
/** 用户管理页 */
export default function UsersPage() {
return <div>Users</div>;
}
+1
View File
@@ -0,0 +1 @@
// 用户管理页类型
+21
View File
@@ -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 />;
}
+44
View File
@@ -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 />;
}
+4
View File
@@ -0,0 +1,4 @@
// 路由守卫统一导出
export * from "./RequireAuth";
export * from "./GuestOnly";
+47
View File
@@ -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);
+11
View File
@@ -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;
};
+4
View File
@@ -0,0 +1,4 @@
// API 服务层统一导出
export * from "./auth";
export * from "./user";
+1
View File
@@ -0,0 +1 @@
// 用户相关接口
+1
View File
@@ -0,0 +1 @@
// 登录态全局状态
+4
View File
@@ -0,0 +1,4 @@
// 全局状态统一导出
export * from "./auth-store";
export * from "./ui-store";
+25
View File
@@ -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 }))
}));
+9
View File
@@ -0,0 +1,9 @@
// Ant Design 主题配置
import type { ThemeConfig } from "antd";
/** Ant Design 全局主题,视觉定制统一在此调整 token */
export const themeConfig: ThemeConfig = {
token: {
colorPrimary: "#1677ff"
}
};
+6
View File
@@ -0,0 +1,6 @@
// 接口通用类型
/** 仅包含提示信息的响应 */
export interface IMsgOnlyResponse {
msg: string;
}
+11
View File
@@ -0,0 +1,11 @@
// 认证相关类型
/** 获取当前登录用户的响应数据 */
export interface IGetCurrentUserData {
/** 用户 ID */
id: string;
/** 用户名称 */
name: string;
/** 角色标识列表,用于路由守卫的权限判断 */
roles: string[];
}
+4
View File
@@ -0,0 +1,4 @@
// 全局类型统一导出
export * from "./api";
export * from "./auth";
+3
View File
@@ -0,0 +1,3 @@
// 通用工具函数统一导出
export * from "./navigation";
+11
View File
@@ -0,0 +1,11 @@
// 路由跳转相关工具函数
/** 获取安全的跳转地址,仅允许站内路径,防止开放重定向 */
export const getSafeRedirect = (redirect: string | null, fallback = "/") => {
const isInternalPath =
!!redirect &&
redirect.startsWith("/") &&
!redirect.startsWith("//") &&
!redirect.startsWith("/\\");
return isInternalPath ? redirect : fallback;
};
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
// 环境变量类型声明
interface ImportMetaEnv {
/** 接口基础地址 */
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+31
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+27
View File
@@ -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
}
}
}
};
});