Compare commits
8 Commits
edc367898f
...
5d8ddd2bd7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8ddd2bd7 | |||
| 311ce55fb7 | |||
| 790b6905ef | |||
| f31c459610 | |||
| 0ffa0e1ddf | |||
| 7925c92478 | |||
| 3ff0642706 | |||
| 5bdd8603a1 |
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产环境后端接口地址
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,6 +3,11 @@
|
||||
*.local
|
||||
*.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
83
AGENTS.md
83
AGENTS.md
@@ -23,27 +23,46 @@
|
||||
```
|
||||
src/
|
||||
index.tsx # 应用入口,将 React 根节点挂载到 #root
|
||||
App.tsx # 根组件,渲染 <RouterProvider>
|
||||
App.css # 根组件样式
|
||||
App.tsx # 根组件,渲染 <RouterProvider>,包含 ConfigProvider / AntdApp 全局配置
|
||||
App.css # 全局样式(reset)
|
||||
router.tsx # createBrowserRouter,由路由树自动生成
|
||||
env.d.ts # Rsbuild 环境类型声明
|
||||
env.d.ts # Rsbuild 环境变量类型声明(ImportMetaEnv)
|
||||
routes/
|
||||
types.ts # RouteItem 类型定义
|
||||
index.tsx # 路由树数据(唯一数据源),导出 routes / RouteItem
|
||||
utils.tsx # toRouteObjects():将路由树转为 React Router RouteObject[]
|
||||
layouts/
|
||||
RootLayout.tsx # 根布局,包含导航链接和 <Outlet />
|
||||
RootLayout.tsx # 根布局(Header + Sider + Content)
|
||||
pages/
|
||||
Home.tsx # "/" 首页
|
||||
About.tsx # "/about" 关于页
|
||||
NotFound.tsx # "*" 兜底 404 页
|
||||
types/
|
||||
http.d.ts # 全局 API 命名空间(无需 import 直接使用 API.Response<T>)
|
||||
utils/
|
||||
request.ts # axios 实例封装,导出 get / post / put / del
|
||||
.env # 本地环境变量(已 gitignore,勿提交)
|
||||
.env.example # 环境变量模板(提交到仓库供参考)
|
||||
public/
|
||||
favicon.png
|
||||
rsbuild.config.ts # 构建配置
|
||||
eslint.config.mjs # ESLint 扁平配置(仅作用于 TS/TSX,忽略 dist/)
|
||||
rsbuild.config.ts # 构建配置
|
||||
eslint.config.mjs # ESLint 扁平配置(仅作用于 TS/TSX,忽略 dist/)
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
- 变量文件:`.env`(本地,已 gitignore)
|
||||
- 模板文件:`.env.example`(提交到仓库)
|
||||
- 新成员初始化:`cp .env.example .env`
|
||||
- Rsbuild 规则:**以 `PUBLIC_` 为前缀**的变量会暴露给客户端,通过 `import.meta.env.PUBLIC_XXX` 读取
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `PUBLIC_BASE_URL` | 后端接口 baseURL |
|
||||
|
||||
不同环境可创建 `.env.development` / `.env.production` 覆盖默认值,`.env.example` 中同步维护所有变量。
|
||||
|
||||
## 路由
|
||||
|
||||
使用 **React Router v7**(`react-router`)的 `createBrowserRouter`。
|
||||
@@ -65,6 +84,30 @@ tsconfig.json
|
||||
- **单引号**(`.prettierrc` 中 `singleQuote: true`)
|
||||
- ESLint 仅作用于 `**/*.{ts,tsx}`,启用了 `react-hooks` 和 `react-refresh` 插件
|
||||
|
||||
## 注释规范
|
||||
|
||||
**所有代码都应附带必要的注释,说明意图而非重复代码本身。**
|
||||
|
||||
- 函数 / Hook:用 JSDoc 说明用途、参数含义
|
||||
- 复杂逻辑、非直觉的实现:行内注释解释原因
|
||||
- 模拟数据 / 临时代码:标注 `// TODO:` 或 `// FIXME:` 方便后续替换
|
||||
- 类型字段:用 JSDoc 注释说明每个字段的含义
|
||||
|
||||
```ts
|
||||
// ✅ 说明意图
|
||||
// 用 cancelled 标志位防止组件卸载后的竞态更新
|
||||
let cancelled = false;
|
||||
|
||||
/**
|
||||
* 根据部门 ID 获取用户列表
|
||||
* @param deptKey 部门节点 key
|
||||
*/
|
||||
const fetchUsersByDept = (deptKey: string): Promise<UserRecord[]> => { ... };
|
||||
|
||||
// TODO: 替换为真实接口
|
||||
const mockData = [...];
|
||||
```
|
||||
|
||||
## UI 组件规范(antd)
|
||||
|
||||
**页面 UI 优先使用 antd 组件,不自行实现已有组件的功能。**
|
||||
@@ -87,6 +130,34 @@ import { ConfigProvider } from 'antd';
|
||||
</ConfigProvider>
|
||||
```
|
||||
|
||||
## HTTP 请求
|
||||
|
||||
- 所有请求通过 `src/utils/request.ts` 封装的方法发出,不直接使用 `axios`
|
||||
- **只使用 `get` 和 `post` 两种方法**,不使用 `put` / `delete` 等其他方法
|
||||
- 编辑接口用 `post`,路径加 `/edit` 后缀;删除接口用 `post`,路径加 `/del` 后缀
|
||||
- 全局响应结构 `API.Response<T>` 定义在 `src/types/http.d.ts`,无需 import 直接使用
|
||||
- 请求拦截器(添加 token)和响应拦截器(处理错误码)统一在 `request.ts` 中维护
|
||||
- 接口函数统一放在 `src/api/` 下,按模块分文件管理(如 `src/api/system/user.ts`)
|
||||
|
||||
**接口函数命名规范:**
|
||||
|
||||
| 操作 | 前缀 | 示例 |
|
||||
|------|------|------|
|
||||
| 新增 | `add` | `addDept` |
|
||||
| 编辑 | `edit` | `editDept` |
|
||||
| 删除 | `del` | `delDept` |
|
||||
| 获取列表 | `list` | `listUser` |
|
||||
| 获取详情 | `detail` | `detailUser` |
|
||||
| 特殊查询(树等) | 语义命名 | `deptTree` |
|
||||
|
||||
```ts
|
||||
// 使用示例
|
||||
import { get, post } from '../utils/request';
|
||||
|
||||
const res = await get<User[]>('/api/users');
|
||||
// res.code / res.msg / res.data / res.ok / res.time
|
||||
```
|
||||
|
||||
## 参考文档
|
||||
|
||||
- Rsbuild: https://rsbuild.rs/llms.txt
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"antd": "^6.4.2",
|
||||
"axios": "^1.16.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.1"
|
||||
@@ -27,8 +29,14 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"msw": "^2.14.6",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
618
pnpm-lock.yaml
generated
618
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
349
public/mockServiceWorker.js
Normal file
349
public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
27
src/App.css
27
src/App.css
@@ -1,26 +1,9 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
background-image: linear-gradient(to bottom, #020917, #101725);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 3.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@@ -1,6 +1,19 @@
|
||||
import { App as AntdApp, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import router from './router';
|
||||
import router from '@/router';
|
||||
import './App.css';
|
||||
|
||||
const App = () => <RouterProvider router={router} />;
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const App = () => (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
55
src/api/system/user.ts
Normal file
55
src/api/system/user.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { get, post } from '@/utils/request';
|
||||
|
||||
/** 部门节点数据结构 */
|
||||
export interface DeptNode {
|
||||
key: string;
|
||||
title: string;
|
||||
parentKey: string | null;
|
||||
children?: DeptNode[];
|
||||
}
|
||||
|
||||
/** 新增/编辑部门的请求参数 */
|
||||
export interface DeptParams {
|
||||
title: string;
|
||||
parentKey?: string | null;
|
||||
}
|
||||
|
||||
/** 用户记录数据结构 */
|
||||
export interface UserRecord {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门树
|
||||
*/
|
||||
export const deptTree = () => get<DeptNode[]>('/api/system/dept/tree');
|
||||
|
||||
/**
|
||||
* 新增部门
|
||||
* @param params 部门参数
|
||||
*/
|
||||
export const addDept = (params: DeptParams) => post<DeptNode>('/api/system/dept/add', params);
|
||||
|
||||
/**
|
||||
* 编辑部门
|
||||
* @param key 部门 key
|
||||
* @param params 部门参数
|
||||
*/
|
||||
export const editDept = (key: string, params: DeptParams) =>
|
||||
post<DeptNode>('/api/system/dept/edit', { key, ...params });
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @param key 部门 key
|
||||
*/
|
||||
export const delDept = (key: string) => post('/api/system/dept/del', { key });
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @param deptKey 部门 key
|
||||
*/
|
||||
export const listUser = (deptKey: string) =>
|
||||
get<UserRecord[]>('/api/system/user/list', { deptKey });
|
||||
4
src/env.d.ts
vendored
4
src/env.d.ts
vendored
@@ -9,3 +9,7 @@ declare module '*.svg?react' {
|
||||
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export default ReactComponent;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_BASE_URL: string;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import App from '@/App';
|
||||
|
||||
async function prepare() {
|
||||
// 仅开发环境启动 mock service worker
|
||||
if (import.meta.env.DEV) {
|
||||
const { default: worker } = await import('@/mock');
|
||||
await worker.start({ onUnhandledRequest: 'bypass' });
|
||||
}
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (rootEl) {
|
||||
const root = ReactDOM.createRoot(rootEl);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
prepare().then(() => {
|
||||
const root = ReactDOM.createRoot(rootEl);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,82 @@
|
||||
import { Link, Outlet } from 'react-router';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { routes } from '@/routes';
|
||||
import { toMenuItems } from '@/routes/utils';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const menuItems = toMenuItems(routes);
|
||||
|
||||
const RootLayout = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav>
|
||||
<Link to="/">首页</Link>
|
||||
{' | '}
|
||||
<Link to="/about">关于</Link>
|
||||
</nav>
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 'bold', fontSize: 18 }}>TaoTie</span>
|
||||
</Header>
|
||||
<Layout style={{ overflow: 'hidden' }}>
|
||||
<Sider
|
||||
theme="light"
|
||||
width={200}
|
||||
collapsed={collapsed}
|
||||
style={{ borderRight: '1px solid #f0f0f0', position: 'relative' }}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={menuItems.map((item) => item.key)}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ height: '100%', borderRight: 0, paddingBottom: 48 }}
|
||||
/>
|
||||
<div
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-end',
|
||||
padding: collapsed ? 0 : '0 24px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
background: '#fff',
|
||||
transition: 'all 0.2s',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<MenuUnfoldOutlined />
|
||||
) : (
|
||||
<>
|
||||
<MenuFoldOutlined style={{ marginRight: 8 }} />
|
||||
<span style={{ fontSize: 13 }}>收起</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sider>
|
||||
<Content style={{ padding: 0, overflow: 'auto' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
5
src/layouts/SystemLayout.tsx
Normal file
5
src/layouts/SystemLayout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
const SystemLayout = () => <Outlet />;
|
||||
|
||||
export default SystemLayout;
|
||||
7
src/mock/index.ts
Normal file
7
src/mock/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
import { systemHandlers } from './system';
|
||||
|
||||
/** 汇总所有模块的 mock handlers */
|
||||
const worker = setupWorker(...systemHandlers);
|
||||
|
||||
export default worker;
|
||||
71
src/mock/system.ts
Normal file
71
src/mock/system.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
/** 部门树 mock 数据 */
|
||||
const deptList = [
|
||||
{
|
||||
key: '1',
|
||||
title: '总公司',
|
||||
parentKey: null,
|
||||
children: [
|
||||
{
|
||||
key: '1-1',
|
||||
title: '技术部',
|
||||
parentKey: '1',
|
||||
children: [
|
||||
{ key: '1-1-1', title: '前端组', parentKey: '1-1', children: [] },
|
||||
{ key: '1-1-2', title: '后端组', parentKey: '1-1', children: [] },
|
||||
],
|
||||
},
|
||||
{ key: '1-2', title: '产品部', parentKey: '1', children: [] },
|
||||
{ key: '1-3', title: '运营部', parentKey: '1', children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 用户列表 mock 数据,按部门 key 分组 */
|
||||
const userMap: Record<string, object[]> = {
|
||||
'1': [{ username: 'admin', name: '管理员', email: 'admin@example.com', status: '启用' }],
|
||||
'1-1': [
|
||||
{ username: 'zhangsan', name: '张三', email: 'zhangsan@example.com', status: '启用' },
|
||||
{ username: 'lisi', name: '李四', email: 'lisi@example.com', status: '启用' },
|
||||
],
|
||||
'1-1-1': [{ username: 'wangwu', name: '王五', email: 'wangwu@example.com', status: '启用' }],
|
||||
'1-1-2': [{ username: 'zhaoliu', name: '赵六', email: 'zhaoliu@example.com', status: '禁用' }],
|
||||
'1-2': [{ username: 'sunqi', name: '孙七', email: 'sunqi@example.com', status: '启用' }],
|
||||
'1-3': [],
|
||||
};
|
||||
|
||||
/** 包装为统一响应格式 */
|
||||
const ok = (data: unknown) => ({ code: '0', msg: 'ok', data, time: Date.now(), ok: true });
|
||||
|
||||
export const systemHandlers = [
|
||||
// 获取部门树
|
||||
http.get('/api/system/dept/tree', () => {
|
||||
return HttpResponse.json(ok(deptList));
|
||||
}),
|
||||
|
||||
// 新增部门
|
||||
http.post('/api/system/dept/add', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json(ok({ key: `dept_${Date.now()}`, ...body }));
|
||||
}),
|
||||
|
||||
// 编辑部门
|
||||
http.post('/api/system/dept/edit', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json(ok(body));
|
||||
}),
|
||||
|
||||
// 删除部门
|
||||
http.post('/api/system/dept/del', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json(ok({ key: body.key }));
|
||||
}),
|
||||
|
||||
// 根据部门获取用户列表
|
||||
http.get('/api/system/user/list', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const deptKey = url.searchParams.get('deptKey') ?? '';
|
||||
return HttpResponse.json(ok(userMap[deptKey] ?? []));
|
||||
}),
|
||||
];
|
||||
52
src/pages/system/user/DeptModal.tsx
Normal file
52
src/pages/system/user/DeptModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Form, Input, Modal } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface DeptFormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DeptModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 弹窗标题 */
|
||||
title: string;
|
||||
/** 编辑时的初始值,新增时为 undefined */
|
||||
initialValues?: DeptFormValues;
|
||||
/** 确认回调,返回表单数据 */
|
||||
onOk: (values: DeptFormValues) => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DeptModal = ({ open, title, initialValues, onOk, onCancel }: DeptModalProps) => {
|
||||
const [form] = Form.useForm<DeptFormValues>();
|
||||
|
||||
// 每次打开时重置并填充表单
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
if (initialValues) form.setFieldsValue(initialValues);
|
||||
}
|
||||
}, [open, initialValues, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields();
|
||||
onOk(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={title} open={open} onOk={handleOk} onCancel={onCancel} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="部门名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入部门名称" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeptModal;
|
||||
214
src/pages/system/user/DeptTree.tsx
Normal file
214
src/pages/system/user/DeptTree.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, Popconfirm, Space, Spin, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
addDept,
|
||||
delDept,
|
||||
deptTree,
|
||||
editDept,
|
||||
type DeptNode,
|
||||
} from '@/api/system/user';
|
||||
import DeptModal from './DeptModal';
|
||||
import type { DeptFormValues } from './DeptModal';
|
||||
|
||||
/** 单个树节点标题,含悬浮操作按钮 */
|
||||
const DeptTreeNode = ({
|
||||
node,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
node: DeptNode;
|
||||
onAdd: (key: string) => void;
|
||||
onEdit: (node: DeptNode) => void;
|
||||
onDelete: (key: string) => Promise<void>;
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<span>{node.title}</span>
|
||||
{/* 仅悬浮时显示操作按钮 */}
|
||||
<Space
|
||||
size={0}
|
||||
style={{ visibility: hovered ? 'visible' : 'hidden' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
title="新增子部门"
|
||||
onClick={() => onAdd(node.key)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
title="编辑"
|
||||
onClick={() => onEdit(node)}
|
||||
/>
|
||||
{/* 删除使用气泡卡片二次确认,不打开 Modal */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description={`确定要删除「${node.title}」及其所有子部门吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => onDelete(node.key)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 递归将 DeptNode 转为 antd Tree 的 DataNode */
|
||||
const toTreeData = (
|
||||
nodes: DeptNode[],
|
||||
onAdd: (key: string) => void,
|
||||
onEdit: (node: DeptNode) => void,
|
||||
onDelete: (key: string) => Promise<void>,
|
||||
): DataNode[] =>
|
||||
nodes.map((node) => ({
|
||||
key: node.key,
|
||||
title: <DeptTreeNode node={node} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />,
|
||||
children: node.children ? toTreeData(node.children, onAdd, onEdit, onDelete) : undefined,
|
||||
}));
|
||||
|
||||
interface DeptTreeProps {
|
||||
/** 当前选中的部门 key */
|
||||
selectedKey: string;
|
||||
/** 选中部门回调 */
|
||||
onSelect: (key: string) => void;
|
||||
}
|
||||
|
||||
/** 弹窗模式:新增根部门 | 新增子部门 | 编辑 */
|
||||
type ModalMode = 'addRoot' | 'addChild' | 'edit';
|
||||
|
||||
const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deptData, setDeptData] = useState<DeptNode[]>([]);
|
||||
|
||||
// tick 变化时触发部门树重新加载
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
/** 触发部门树刷新 */
|
||||
const loadDeptTree = () => setTick((n) => n + 1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deptTree();
|
||||
// 用 cancelled 标志位防止卸载后的竞态更新
|
||||
if (!cancelled) setDeptData(res.data ?? []);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [tick]);
|
||||
|
||||
// 弹窗状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<ModalMode>('addRoot');
|
||||
const [modalTitle, setModalTitle] = useState('新增部门');
|
||||
const [editingNode, setEditingNode] = useState<DeptNode | null>(null);
|
||||
const [parentKey, setParentKey] = useState<string>('');
|
||||
|
||||
/** 打开新增根部门弹窗 */
|
||||
const handleAddRoot = () => {
|
||||
setModalMode('addRoot');
|
||||
setModalTitle('新增根部门');
|
||||
setEditingNode(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/** 打开新增子部门弹窗 */
|
||||
const handleAddChild = (pKey: string) => {
|
||||
setModalMode('addChild');
|
||||
setModalTitle('新增子部门');
|
||||
setParentKey(pKey);
|
||||
setEditingNode(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const handleEdit = (node: DeptNode) => {
|
||||
setModalMode('edit');
|
||||
setModalTitle('编辑部门');
|
||||
setEditingNode(node);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/** 删除部门,由 Popconfirm 确认后调用 */
|
||||
const handleDelete = async (key: string) => {
|
||||
await delDept(key);
|
||||
// 删除成功后重新加载部门树
|
||||
loadDeptTree();
|
||||
};
|
||||
|
||||
/** Modal 确认,根据模式调用对应接口后刷新部门树 */
|
||||
const handleModalOk = async (values: DeptFormValues) => {
|
||||
if (modalMode === 'addRoot') {
|
||||
await addDept({ title: values.name, parentKey: null });
|
||||
} else if (modalMode === 'addChild') {
|
||||
await addDept({ title: values.name, parentKey: parentKey });
|
||||
} else if (modalMode === 'edit' && editingNode) {
|
||||
await editDept(editingNode.key, { title: values.name });
|
||||
}
|
||||
setModalOpen(false);
|
||||
// 操作成功后重新加载部门树
|
||||
loadDeptTree();
|
||||
};
|
||||
|
||||
const treeData = toTreeData(deptData, handleAddChild, handleEdit, handleDelete);
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={24} md={7} lg={6} xl={5} style={{ display: 'flex' }}>
|
||||
<Card
|
||||
title="部门"
|
||||
style={{ width: '100%' }}
|
||||
styles={{ body: { padding: 12, overflow: 'auto' } }}
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddRoot}>
|
||||
新增
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
selectedKeys={[selectedKey]}
|
||||
blockNode
|
||||
onSelect={(keys) => {
|
||||
if (keys.length > 0) onSelect(String(keys[0]));
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<DeptModal
|
||||
open={modalOpen}
|
||||
title={modalTitle}
|
||||
initialValues={editingNode ? { name: editingNode.title } : undefined}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeptTree;
|
||||
66
src/pages/system/user/UserTable.tsx
Normal file
66
src/pages/system/user/UserTable.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Card, Col, Table, Tag } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listUser, type UserRecord } from '@/api/system/user';
|
||||
|
||||
/** 状态值与 Tag 颜色的映射 */
|
||||
const statusColorMap: Record<string, string> = {
|
||||
启用: 'success',
|
||||
禁用: 'error',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={statusColorMap[status] ?? 'default'}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface UserTableProps {
|
||||
deptKey: string;
|
||||
}
|
||||
|
||||
const UserTable = ({ deptKey }: UserTableProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<UserRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listUser(deptKey);
|
||||
// 用 cancelled 标志位防止组件卸载后的竞态更新
|
||||
if (!cancelled) setData(res.data ?? []);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [deptKey]);
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={24} md={17} lg={18} xl={19} style={{ display: 'flex' }}>
|
||||
<Card title="用户列表" style={{ width: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="username"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserTable;
|
||||
20
src/pages/system/user/index.tsx
Normal file
20
src/pages/system/user/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Row } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import DeptTree from './DeptTree';
|
||||
import UserTable from './UserTable';
|
||||
|
||||
const UserManagement = () => {
|
||||
const [selectedDeptKey, setSelectedDeptKey] = useState('1');
|
||||
|
||||
return (
|
||||
<Row
|
||||
gutter={[16, 16]}
|
||||
style={{ padding: 24, height: '100%', alignContent: 'flex-start' }}
|
||||
>
|
||||
<DeptTree selectedKey={selectedDeptKey} onSelect={setSelectedDeptKey} />
|
||||
<UserTable deptKey={selectedDeptKey} />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
import RootLayout from './layouts/RootLayout';
|
||||
import { routes } from './routes';
|
||||
import { toRouteObjects } from './routes/utils';
|
||||
import RootLayout from '@/layouts/RootLayout';
|
||||
import { routes } from '@/routes';
|
||||
import { toRouteObjects } from '@/routes/utils';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import About from '../pages/About';
|
||||
import Home from '../pages/Home';
|
||||
import NotFound from '../pages/NotFound';
|
||||
import { HomeOutlined, InfoCircleOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import SystemLayout from '@/layouts/SystemLayout';
|
||||
import About from '@/pages/about';
|
||||
import Home from '@/pages/home';
|
||||
import NotFound from '@/pages/not-found';
|
||||
import UserManagement from '@/pages/system/user';
|
||||
import type { RouteItem } from './types';
|
||||
|
||||
export const routes: RouteItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
label: '首页',
|
||||
icon: <HomeOutlined />,
|
||||
component: <Home />,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
label: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
component: <About />,
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
label: '系统配置',
|
||||
icon: <SettingOutlined />,
|
||||
component: <SystemLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/system/user',
|
||||
label: '用户管理',
|
||||
icon: <UserOutlined />,
|
||||
component: <UserManagement />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
label: '404',
|
||||
|
||||
@@ -21,3 +21,21 @@ export function toRouteObjects(items: RouteItem[]): RouteObject[] {
|
||||
return route;
|
||||
});
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
key: string;
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
export function toMenuItems(items: RouteItem[]): MenuItem[] {
|
||||
return items
|
||||
.filter((r) => !r.hideInMenu)
|
||||
.map((r) => ({
|
||||
key: r.path,
|
||||
icon: r.icon,
|
||||
label: r.label,
|
||||
children: r.children ? toMenuItems(r.children) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
15
src/types/http.d.ts
vendored
Normal file
15
src/types/http.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare namespace API {
|
||||
/** 全局 HTTP 响应结构体 */
|
||||
interface Response<T = unknown> {
|
||||
/** 业务状态码,成功为 "0" */
|
||||
code: string;
|
||||
/** 提示信息 */
|
||||
msg: string;
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 服务端时间戳 */
|
||||
time: number;
|
||||
/** 是否成功(code === "0" 时为 true) */
|
||||
ok: boolean;
|
||||
}
|
||||
}
|
||||
37
src/utils/request.ts
Normal file
37
src/utils/request.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.PUBLIC_BASE_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// TODO: 添加 token 等请求头
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
// TODO: 统一处理业务错误码
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// TODO: 统一处理网络错误
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const get = <T>(url: string, params?: object) =>
|
||||
request.get<API.Response<T>>(url, { params }).then((res) => res.data);
|
||||
|
||||
export const post = <T>(url: string, data?: object) =>
|
||||
request.post<API.Response<T>>(url, data).then((res) => res.data);
|
||||
|
||||
export default request;
|
||||
@@ -14,6 +14,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
/* 路径别名 */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* type checking */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
|
||||
Reference in New Issue
Block a user