Compare commits

...

9 Commits

31 changed files with 2090 additions and 57 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# 生产环境后端接口地址
PUBLIC_BASE_URL=http://localhost:8080

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

5
.gitignore vendored
View File

@@ -3,6 +3,11 @@
*.local *.local
*.log* *.log*
# Environment variables
.env
.env.*
!.env.example
# Dist # Dist
node_modules node_modules
dist/ dist/

View File

@@ -23,20 +23,26 @@
``` ```
src/ src/
index.tsx # 应用入口,将 React 根节点挂载到 #root index.tsx # 应用入口,将 React 根节点挂载到 #root
App.tsx # 根组件,渲染 <RouterProvider> App.tsx # 根组件,渲染 <RouterProvider>,包含 ConfigProvider / AntdApp 全局配置
App.css # 根组件样式 App.css # 全局样式reset
router.tsx # createBrowserRouter由路由树自动生成 router.tsx # createBrowserRouter由路由树自动生成
env.d.ts # Rsbuild 环境类型声明 env.d.ts # Rsbuild 环境变量类型声明ImportMetaEnv
routes/ routes/
types.ts # RouteItem 类型定义 types.ts # RouteItem 类型定义
index.tsx # 路由树数据(唯一数据源),导出 routes / RouteItem index.tsx # 路由树数据(唯一数据源),导出 routes / RouteItem
utils.tsx # toRouteObjects():将路由树转为 React Router RouteObject[] utils.tsx # toRouteObjects():将路由树转为 React Router RouteObject[]
layouts/ layouts/
RootLayout.tsx # 根布局,包含导航链接和 <Outlet /> RootLayout.tsx # 根布局Header + Sider + Content
pages/ pages/
Home.tsx # "/" 首页 Home.tsx # "/" 首页
About.tsx # "/about" 关于页 About.tsx # "/about" 关于页
NotFound.tsx # "*" 兜底 404 页 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/ public/
favicon.png favicon.png
rsbuild.config.ts # 构建配置 rsbuild.config.ts # 构建配置
@@ -44,6 +50,19 @@ eslint.config.mjs # ESLint 扁平配置(仅作用于 TS/TSX忽略 dist/
tsconfig.json 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` 使用 **React Router v7**`react-router`)的 `createBrowserRouter`
@@ -65,6 +84,30 @@ tsconfig.json
- **单引号**`.prettierrc``singleQuote: true` - **单引号**`.prettierrc``singleQuote: true`
- ESLint 仅作用于 `**/*.{ts,tsx}`,启用了 `react-hooks``react-refresh` 插件 - 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
**页面 UI 优先使用 antd 组件,不自行实现已有组件的功能。** **页面 UI 优先使用 antd 组件,不自行实现已有组件的功能。**
@@ -87,9 +130,43 @@ import { ConfigProvider } from 'antd';
</ConfigProvider> </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
```
## API 层规范
- **数据结构不导出**`src/api/` 目录下的 interface / type 不得加 `export`,禁止其他模块 import
- **组件自定类型**:页面组件如需使用数据结构,在组件文件内自行定义,不依赖 API 层的类型
- **mock 文件同理**`src/mock/` 下的文件也需自行定义类型,不导入 `src/api/` 中的类型
- API 函数可以内部使用类型,但签名中避免使用导出的复杂类型(可用 `Record<string, unknown>``any` 代替)
## 参考文档 ## 参考文档
- Rsbuild: https://rsbuild.rs/llms.txt - Rsbuild: https://rsbuild.rs/llms.txt
- Rspack: https://rspack.rs/llms.txt - Rspack: https://rspack.rs/llms.txt
- antd v6 完整文档(中文): https://ant.design/llms-full-cn.txt - antd v6 组件导航文档: https://ant.design/llms.txt
- antd v6 组件单页(中文): https://ant.design/components/{组件名}-cn.md如 button-cn.md

View File

@@ -13,6 +13,8 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.3",
"antd": "^6.4.2", "antd": "^6.4.2",
"axios": "^1.16.1",
"dayjs": "^1.11.20",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router": "^7.15.1" "react-router": "^7.15.1"
@@ -27,8 +29,14 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"msw": "^2.14.6",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.59.1" "typescript-eslint": "^8.59.1"
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }

618
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

349
public/mockServiceWorker.js Normal file
View 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,
}
}

View File

@@ -4,4 +4,7 @@ import { pluginReact } from '@rsbuild/plugin-react';
// Docs: https://rsbuild.rs/config/ // Docs: https://rsbuild.rs/config/
export default defineConfig({ export default defineConfig({
plugins: [pluginReact()], plugins: [pluginReact()],
server: {
port: 6000,
},
}); });

View File

@@ -1,26 +1,9 @@
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
color: #fff; padding: 0;
font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 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;
} }

View File

@@ -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 { 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; export default App;

86
src/api/system/user.ts Normal file
View File

@@ -0,0 +1,86 @@
import { get, post } from '@/utils/request';
/** 部门节点数据结构(不导出,仅供本文件内使用) */
interface DeptNode {
id: string;
title: string;
parentId: string | null;
children?: DeptNode[];
}
/** 新增/编辑部门的请求参数(不导出) */
interface DeptParams {
title: string;
parentId?: string | null;
}
/** 用户记录数据结构(不导出,仅供本文件内使用) */
interface UserRecord {
id: string;
userName: string;
nickName: string;
status: string;
deptId?: string;
}
/** 新增/编辑用户的请求参数(不导出) */
interface UserParams {
userName: string;
nickName: string;
status: string;
deptId: 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 id 部门 ID
* @param params 部门参数
*/
export const editDept = (id: string, params: DeptParams) =>
post<DeptNode>('/api/system/dept/edit', { id, ...params });
/**
* 删除部门
* @param id 部门 ID
*/
export const delDept = (id: string) => post('/api/system/dept/del', { id });
// ───────────── 用户接口 ─────────────
/**
* 获取用户列表
* @param deptId 部门 ID
*/
export const listUser = (deptId: string) =>
get<UserRecord[]>('/api/system/user/list', { deptId });
/**
* 新增用户
* @param params 用户参数
*/
export const addUser = (params: UserParams) => post<UserRecord>('/api/system/user/add', params);
/**
* 编辑用户
* @param id 用户 ID
* @param params 用户参数
*/
export const editUser = (id: string, params: UserParams) =>
post<UserRecord>('/api/system/user/edit', { id, ...params });
/**
* 删除用户
* @param id 用户 ID
*/
export const delUser = (id: string) => post('/api/system/user/del', { id });

4
src/env.d.ts vendored
View File

@@ -9,3 +9,7 @@ declare module '*.svg?react' {
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>; const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export default ReactComponent; export default ReactComponent;
} }
interface ImportMetaEnv {
readonly PUBLIC_BASE_URL: string;
}

View File

@@ -1,13 +1,23 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; 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'); const rootEl = document.getElementById('root');
if (rootEl) { if (rootEl) {
prepare().then(() => {
const root = ReactDOM.createRoot(rootEl); const root = ReactDOM.createRoot(rootEl);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
); );
});
} }

View File

@@ -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 RootLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
return ( return (
<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 />
) : (
<> <>
<nav> <MenuFoldOutlined style={{ marginRight: 8 }} />
<Link to="/"></Link> <span style={{ fontSize: 13 }}></span>
{' | '}
<Link to="/about"></Link>
</nav>
<main>
<Outlet />
</main>
</> </>
)}
</div>
</Sider>
<Content style={{ padding: 0, overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
); );
}; };

View File

@@ -0,0 +1,5 @@
import { Outlet } from 'react-router';
const SystemLayout = () => <Outlet />;
export default SystemLayout;

7
src/mock/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { setupWorker } from 'msw/browser';
import { systemHandlers } from './system';
/** 汇总所有模块的 mock handlers */
const worker = setupWorker(...systemHandlers);
export default worker;

157
src/mock/system.ts Normal file
View File

@@ -0,0 +1,157 @@
import { http, HttpResponse } from 'msw';
// ── 本地类型定义(不依赖 API 层)───────────
/** 部门节点mock 本地定义) */
interface MockDeptNode {
id: string;
title: string;
parentId: string | null;
children?: MockDeptNode[];
}
/** 用户记录mock 本地定义) */
interface MockUserRecord {
id: string;
userName: string;
nickName: string;
status: string;
deptId?: string;
}
// ── mock 数据 ────────────────────────────────
/** 部门树 mock 数据 */
const deptList: MockDeptNode[] = [
{
id: '1',
title: '总公司',
parentId: null,
children: [
{
id: '1-1',
title: '技术部',
parentId: '1',
children: [
{ id: '1-1-1', title: '前端组', parentId: '1-1', children: [] },
{ id: '1-1-2', title: '后端组', parentId: '1-1', children: [] },
],
},
{ id: '1-2', title: '产品部', parentId: '1', children: [] },
{ id: '1-3', title: '运营部', parentId: '1', children: [] },
],
},
];
/** 用户列表 mock 数据,按部门 id 分组(运行时可变) */
const userMap: Record<string, MockUserRecord[]> = {
'1': [{ id: '1', userName: 'admin', nickName: '管理员', status: '启用', deptId: '1' }],
'1-1': [
{ id: '2', userName: 'zhangsan', nickName: '张三', status: '启用', deptId: '1-1' },
{ id: '3', userName: 'lisi', nickName: '李四', status: '启用', deptId: '1-1' },
],
'1-1-1': [{ id: '4', userName: 'wangwu', nickName: '王五', status: '启用', deptId: '1-1-1' }],
'1-1-2': [{ id: '5', userName: 'zhaoliu', nickName: '赵六', status: '禁用', deptId: '1-1-2' }],
'1-2': [{ id: '6', userName: 'sunqi', nickName: '孙七', status: '启用', deptId: '1-2' }],
'1-3': [],
};
/** 包装为统一响应格式 */
const ok = (data: unknown) => ({ code: '0', msg: 'ok', data, time: Date.now(), ok: true });
// ── MSW handlers ─────────────────────────────
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>;
const newNode: MockDeptNode = {
id: `dept_${Date.now()}`,
title: body.title as string,
parentId: body.parentId as string ?? null,
children: [],
};
const addToTree = (nodes: MockDeptNode[]): boolean => {
for (const node of nodes) {
if (node.id === body.parentId) {
node.children = node.children ?? [];
node.children.push(newNode);
return true;
}
if (node.children && addToTree(node.children)) return true;
}
return false;
};
if (body.parentId) addToTree(deptList);
else deptList.push(newNode);
return HttpResponse.json(ok(newNode));
}),
http.post('/api/system/dept/edit', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const editInTree = (nodes: MockDeptNode[]) => {
for (const node of nodes) {
if (node.id === body.id) {
node.title = body.title as string;
return true;
}
if (node.children && editInTree(node.children)) return true;
}
return false;
};
editInTree(deptList);
return HttpResponse.json(ok(body));
}),
http.post('/api/system/dept/del', async ({ request }) => {
const body = await request.json() as { id: string };
const removeFromTree = (nodes: MockDeptNode[]): boolean => {
const idx = nodes.findIndex(n => n.id === body.id);
if (idx !== -1) { nodes.splice(idx, 1); return true; }
for (const node of nodes) {
if (node.children && removeFromTree(node.children)) return true;
}
return false;
};
removeFromTree(deptList);
return HttpResponse.json(ok({ id: body.id }));
}),
// ───── 用户接口 ─────
http.get('/api/system/user/list', ({ request }) => {
const url = new URL(request.url);
const deptId = url.searchParams.get('deptId') ?? '';
return HttpResponse.json(ok(userMap[deptId] ?? []));
}),
http.post('/api/system/user/add', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const newUser = { id: `user_${Date.now()}`, ...body } as MockUserRecord;
const list = userMap[newUser.deptId ?? ''] ?? [];
list.push(newUser);
userMap[newUser.deptId ?? ''] = list;
return HttpResponse.json(ok(newUser));
}),
http.post('/api/system/user/edit', async ({ request }) => {
const body = await request.json() as MockUserRecord;
const list = userMap[body.deptId ?? ''] ?? [];
const idx = list.findIndex((u) => u.id === body.id);
if (idx !== -1) list[idx] = body;
return HttpResponse.json(ok(body));
}),
http.post('/api/system/user/del', async ({ request }) => {
const body = await request.json() as { id: string };
for (const key of Object.keys(userMap)) {
userMap[key] = userMap[key].filter((u) => u.id !== body.id);
}
return HttpResponse.json(ok({ id: body.id }));
}),
];

View 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;

View File

@@ -0,0 +1,216 @@
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 } from '@/api/system/user';
import DeptModal from './DeptModal';
import type { DeptFormValues } from './DeptModal';
/** 部门节点(页面本地定义,不依赖 API 层类型) */
interface DeptNode {
id: string;
title: string;
parentId: string | null;
children?: DeptNode[];
}
/** 单个树节点标题,含悬浮操作按钮 */
const DeptTreeNode = ({
node,
onAdd,
onEdit,
onDelete,
}: {
node: DeptNode;
onAdd: (id: string) => void;
onEdit: (node: DeptNode) => void;
onDelete: (id: 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.id)}
/>
<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.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除" />
</Popconfirm>
</Space>
</div>
);
};
/** 递归将 DeptNode 转为 antd Tree 的 DataNode */
const toTreeData = (
nodes: DeptNode[],
onAdd: (id: string) => void,
onEdit: (node: DeptNode) => void,
onDelete: (id: string) => Promise<void>,
): DataNode[] =>
nodes.map((node) => ({
key: node.id, // antd Tree 必需
title: <DeptTreeNode node={node} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />,
children: node.children ? toTreeData(node.children, onAdd, onEdit, onDelete) : undefined,
}));
interface DeptTreeProps {
/** 当前选中的部门 id */
selectedId: string;
/** 选中部门回调 */
onSelect: (id: string) => void;
}
/** 弹窗模式:新增根部门 | 新增子部门 | 编辑 */
type ModalMode = 'addRoot' | 'addChild' | 'edit';
const DeptTree = ({ selectedId, 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 [parentId, setParentId] = useState<string>('');
/** 打开新增根部门弹窗 */
const handleAddRoot = () => {
setModalMode('addRoot');
setModalTitle('新增根部门');
setEditingNode(null);
setModalOpen(true);
};
/** 打开新增子部门弹窗 */
const handleAddChild = (pId: string) => {
setModalMode('addChild');
setModalTitle('新增子部门');
setParentId(pId);
setEditingNode(null);
setModalOpen(true);
};
/** 打开编辑弹窗 */
const handleEdit = (node: DeptNode) => {
setModalMode('edit');
setModalTitle('编辑部门');
setEditingNode(node);
setModalOpen(true);
};
/** 删除部门,由 Popconfirm 确认后调用 */
const handleDelete = async (id: string) => {
await delDept(id);
// 删除成功后重新加载部门树
loadDeptTree();
};
/** Modal 确认,根据模式调用对应接口后刷新部门树 */
const handleModalOk = async (values: DeptFormValues) => {
if (modalMode === 'addRoot') {
await addDept({ title: values.name, parentId: null });
} else if (modalMode === 'addChild') {
await addDept({ title: values.name, parentId });
} else if (modalMode === 'edit' && editingNode) {
await editDept(editingNode.id, { 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={[selectedId]}
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;

View File

@@ -0,0 +1,79 @@
import { Form, Input, Modal, Select } from 'antd';
import { useEffect } from 'react';
/** 用户表单字段(页面本地定义) */
export interface UserFormValues {
userName: string;
nickName: string;
status: string;
}
interface UserModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 弹窗标题 */
title: string;
/** 编辑时的初始值 */
initialValues?: Partial<UserFormValues>;
/** 确认回调 */
onOk: (values: UserFormValues) => void;
/** 取消回调 */
onCancel: () => void;
}
const UserModal = ({ open, title, initialValues, onOk, onCancel }: UserModalProps) => {
const [form] = Form.useForm<UserFormValues>();
/** 每次打开时重置并填充表单 */
useEffect(() => {
if (open) {
form.resetFields();
if (initialValues) {
form.setFieldsValue({
userName: initialValues.userName,
nickName: initialValues.nickName,
status: initialValues.status,
});
}
}
}, [open, initialValues, form]);
const handleOk = async () => {
const values = await form.validateFields();
onOk(values);
};
const isEdit = !!initialValues;
return (
<Modal title={title} open={open} onOk={handleOk} onCancel={onCancel} destroyOnClose>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="用户名"
name="userName"
rules={[{ required: true, message: '请输入用户名' }]}
>
{/* 编辑时用户名不可修改 */}
<Input placeholder="请输入用户名" disabled={isEdit} />
</Form.Item>
<Form.Item
label="昵称"
name="nickName"
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item
label="状态"
name="status"
initialValue="启用"
rules={[{ required: true, message: '请选择状态' }]}
>
<Select options={[{ label: '启用', value: '启用' }, { label: '禁用', value: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
);
};
export default UserModal;

View File

@@ -0,0 +1,177 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { App, Button, Card, Col, Popconfirm, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useState } from 'react';
import { addUser, delUser, editUser, listUser } from '@/api/system/user';
import UserModal from './UserModal';
import type { UserFormValues } from './UserModal';
/** 用户记录(页面本地定义,不依赖 API 层类型) */
interface UserRecord {
id: string;
userName: string;
nickName: string;
status: string;
deptId?: string;
}
/** 状态值与 Tag 颜色的映射 */
const statusColorMap: Record<string, string> = {
: 'success',
: 'error',
};
interface UserTableProps {
/** 当前选中的部门 id */
deptId: string;
}
const UserTable = ({ deptId }: UserTableProps) => {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<UserRecord[]>([]);
// tick 变化时触发用户列表重新加载
const [tick, setTick] = useState(0);
/** 触发用户列表刷新 */
const refresh = () => setTick((n) => n + 1);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const res = await listUser(deptId);
// 用 cancelled 标志位防止组件卸载后的竞态更新
if (!cancelled) setData(res.data ?? []);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [deptId, tick]);
// 弹窗状态
const [modalOpen, setModalOpen] = useState(false);
const [modalTitle, setModalTitle] = useState('新增用户');
const [editingRecord, setEditingRecord] = useState<UserRecord | undefined>();
/** 打开新增弹窗 */
const handleAdd = () => {
setModalTitle('新增用户');
setEditingRecord(undefined);
setModalOpen(true);
};
/** 打开编辑弹窗 */
const handleEdit = (record: UserRecord) => {
setModalTitle('编辑用户');
setEditingRecord(record);
setModalOpen(true);
};
/** 删除用户 */
const handleDelete = async (id: string) => {
await delUser(id);
message.success('删除成功');
refresh();
};
/** Modal 确认,根据是否有 editingRecord 判断新增/编辑 */
const handleModalOk = async (values: UserFormValues) => {
if (editingRecord) {
await editUser(editingRecord.id, { ...values, deptId });
message.success('编辑成功');
} else {
await addUser({ ...values, deptId });
message.success('新增成功');
}
setModalOpen(false);
refresh();
};
const columns: ColumnsType<UserRecord> = [
{
title: '序列',
width: 80,
align: 'center' as const,
render: (_: unknown, _record: UserRecord, index: number) => index + 1,
},
{ title: '用户名', dataIndex: 'userName', key: 'userName' },
{ title: '昵称', dataIndex: 'nickName', key: 'nickName' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={statusColorMap[status] ?? 'default'}>{status}</Tag>
),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: UserRecord) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确认删除"
description={`确定要删除用户「${record.nickName}」吗?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Col xs={24} sm={24} md={17} lg={18} xl={19} style={{ display: 'flex' }}>
<Card
title="用户列表"
style={{ width: '100%' }}
extra={
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
}
>
<Table<UserRecord>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
/>
</Card>
<UserModal
open={modalOpen}
title={modalTitle}
initialValues={editingRecord}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</Col>
);
};
export default UserTable;

View File

@@ -0,0 +1,22 @@
import { Row } from 'antd';
import { useState } from 'react';
import DeptTree from './DeptTree';
import UserTable from './UserTable';
/** 用户管理主页面:左侧部门树 + 右侧用户表格 */
const UserManagement = () => {
// 当前选中的部门 id由部门树点击触发
const [selectedDeptId, setSelectedDeptId] = useState<string>('1');
return (
<Row
gutter={[16, 16]}
style={{ padding: 24, height: '100%', alignContent: 'flex-start' }}
>
<DeptTree selectedId={selectedDeptId} onSelect={setSelectedDeptId} />
<UserTable deptId={selectedDeptId} />
</Row>
);
};
export default UserManagement;

View File

@@ -1,7 +1,7 @@
import { createBrowserRouter } from 'react-router'; import { createBrowserRouter } from 'react-router';
import RootLayout from './layouts/RootLayout'; import RootLayout from '@/layouts/RootLayout';
import { routes } from './routes'; import { routes } from '@/routes';
import { toRouteObjects } from './routes/utils'; import { toRouteObjects } from '@/routes/utils';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {

View File

@@ -1,19 +1,38 @@
import About from '../pages/About'; import { HomeOutlined, InfoCircleOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
import Home from '../pages/Home'; import SystemLayout from '@/layouts/SystemLayout';
import NotFound from '../pages/NotFound'; 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'; import type { RouteItem } from './types';
export const routes: RouteItem[] = [ export const routes: RouteItem[] = [
{ {
path: '/', path: '/',
label: '首页', label: '首页',
icon: <HomeOutlined />,
component: <Home />, component: <Home />,
}, },
{ {
path: '/about', path: '/about',
label: '关于', label: '关于',
icon: <InfoCircleOutlined />,
component: <About />, component: <About />,
}, },
{
path: '/system',
label: '系统配置',
icon: <SettingOutlined />,
component: <SystemLayout />,
children: [
{
path: '/system/user',
label: '用户管理',
icon: <UserOutlined />,
component: <UserManagement />,
},
],
},
{ {
path: '*', path: '*',
label: '404', label: '404',

View File

@@ -21,3 +21,21 @@ export function toRouteObjects(items: RouteItem[]): RouteObject[] {
return route; 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
View 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
View 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;

View File

@@ -14,6 +14,11 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
/* */
"paths": {
"@/*": ["./src/*"]
},
/* type checking */ /* type checking */
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true