Compare commits
7 Commits
311ce55fb7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b1631b9a86 | |||
| 19fcfb38c8 | |||
| 5159e7c90d | |||
| 7e8470c5c2 | |||
| dead892f4e | |||
| ff54a2bba5 | |||
| 5d8ddd2bd7 |
58
AGENTS.md
58
AGENTS.md
@@ -22,10 +22,10 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
index.tsx # 应用入口,将 React 根节点挂载到 #root
|
index.tsx # 应用入口,挂载 React 根节点 + 启动 MSW mock
|
||||||
App.tsx # 根组件,渲染 <RouterProvider>,包含 ConfigProvider / AntdApp 全局配置
|
App.tsx # 根组件,ConfigProvider / AntdApp / RouterProvider
|
||||||
App.css # 全局样式(reset)
|
App.css # 全局样式(reset)
|
||||||
router.tsx # createBrowserRouter,由路由树自动生成
|
router.tsx # createBrowserRouter,登录页独立路由 + 布局子路由
|
||||||
env.d.ts # Rsbuild 环境变量类型声明(ImportMetaEnv)
|
env.d.ts # Rsbuild 环境变量类型声明(ImportMetaEnv)
|
||||||
routes/
|
routes/
|
||||||
types.ts # RouteItem 类型定义
|
types.ts # RouteItem 类型定义
|
||||||
@@ -33,14 +33,48 @@ src/
|
|||||||
utils.tsx # toRouteObjects():将路由树转为 React Router RouteObject[]
|
utils.tsx # toRouteObjects():将路由树转为 React Router RouteObject[]
|
||||||
layouts/
|
layouts/
|
||||||
RootLayout.tsx # 根布局(Header + Sider + Content)
|
RootLayout.tsx # 根布局(Header + Sider + Content)
|
||||||
|
SystemLayout.tsx # 系统配置布局(<Outlet />,作为 /system 父路由容器)
|
||||||
|
components/
|
||||||
|
AuthGuard.tsx # 路由守卫(未登录跳转登录页)
|
||||||
|
hooks/
|
||||||
|
useAppInit.ts # 应用初始化 hook(刷新页面时重新获取用户信息)
|
||||||
|
api/
|
||||||
|
auth.ts # 登录接口
|
||||||
|
system/
|
||||||
|
user.ts # 部门 / 用户接口
|
||||||
|
role.ts # 角色接口
|
||||||
|
store/
|
||||||
|
index.ts # 统一导出入口
|
||||||
|
app.ts # 全局应用状态(侧边栏折叠等)
|
||||||
|
user.ts # 用户状态(userInfo / token)
|
||||||
|
mock/
|
||||||
|
index.ts # MSW worker 初始化,汇总所有 handlers
|
||||||
|
auth.ts # 登录 mock
|
||||||
|
system.ts # 部门 / 用户 / 角色 mock
|
||||||
pages/
|
pages/
|
||||||
Home.tsx # "/" 首页
|
login/
|
||||||
About.tsx # "/about" 关于页
|
index.tsx # "/login" 登录页(不加载布局)
|
||||||
NotFound.tsx # "*" 兜底 404 页
|
home/
|
||||||
|
index.tsx # "/" 首页
|
||||||
|
about/
|
||||||
|
index.tsx # "/about" 关于页
|
||||||
|
not-found/
|
||||||
|
index.tsx # "*" 兜底 404 页
|
||||||
|
system/
|
||||||
|
user/
|
||||||
|
index.tsx # "/system/user" 用户管理入口
|
||||||
|
DeptTree.tsx # 部门树组件
|
||||||
|
DeptModal.tsx # 部门弹窗组件
|
||||||
|
UserTable.tsx # 用户表格组件
|
||||||
|
UserModal.tsx # 用户弹窗组件
|
||||||
|
role/
|
||||||
|
index.tsx # "/system/role" 角色管理入口
|
||||||
|
RoleTable.tsx # 角色表格组件
|
||||||
|
RoleModal.tsx # 角色弹窗组件
|
||||||
types/
|
types/
|
||||||
http.d.ts # 全局 API 命名空间(无需 import 直接使用 API.Response<T>)
|
http.d.ts # 全局 API 命名空间(无需 import 直接使用 API.Response<T>)
|
||||||
utils/
|
utils/
|
||||||
request.ts # axios 实例封装,导出 get / post / put / del
|
request.ts # axios 实例封装,导出 get / post(自动附加 token)
|
||||||
.env # 本地环境变量(已 gitignore,勿提交)
|
.env # 本地环境变量(已 gitignore,勿提交)
|
||||||
.env.example # 环境变量模板(提交到仓库供参考)
|
.env.example # 环境变量模板(提交到仓库供参考)
|
||||||
public/
|
public/
|
||||||
@@ -158,9 +192,15 @@ const res = await get<User[]>('/api/users');
|
|||||||
// res.code / res.msg / res.data / res.ok / res.time
|
// 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)
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"dayjs": "^1.11.20",
|
"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",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.15.1
|
specifier: ^7.15.1
|
||||||
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.13
|
||||||
|
version: 5.0.13(@types/react@19.2.14)(react@19.2.6)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
@@ -1467,6 +1470,24 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
zustand@5.0.13:
|
||||||
|
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@ant-design/colors@8.0.1':
|
'@ant-design/colors@8.0.1':
|
||||||
@@ -2965,3 +2986,8 @@ snapshots:
|
|||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|
||||||
|
zustand@5.0.13(@types/react@19.2.14)(react@19.2.6):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
react: 19.2.6
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -1,19 +1,35 @@
|
|||||||
import { App as AntdApp, ConfigProvider } from 'antd';
|
import { App as AntdApp, ConfigProvider, Spin } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
import { RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
import useAppInit from '@/hooks/useAppInit';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
const App = () => (
|
const App = () => {
|
||||||
|
const initialized = useAppInit();
|
||||||
|
|
||||||
|
// 初始化完成前显示全局 loading
|
||||||
|
if (!initialized) {
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
35
src/api/auth.ts
Normal file
35
src/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { get, post } from '@/utils/request';
|
||||||
|
|
||||||
|
/** 登录请求参数(不导出) */
|
||||||
|
interface LoginParams {
|
||||||
|
tenantId: string;
|
||||||
|
userName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应数据(不导出) */
|
||||||
|
interface LoginResult {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
nickName: string;
|
||||||
|
roles: string[];
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户信息(不导出,与 LoginResult 结构相同) */
|
||||||
|
interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
nickName: string;
|
||||||
|
roles: string[];
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口
|
||||||
|
* @param data 登录参数
|
||||||
|
*/
|
||||||
|
export const login = (data: LoginParams) => post<LoginResult>('/api/auth/login', data);
|
||||||
|
|
||||||
|
/** 获取当前登录用户信息(应用初始化时调用,刷新 userInfo) */
|
||||||
|
export const getCurrentUser = () => get<CurrentUser>('/api/auth/me');
|
||||||
48
src/api/system/role.ts
Normal file
48
src/api/system/role.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { get, post } from '@/utils/request';
|
||||||
|
|
||||||
|
/** 角色记录数据结构(不导出,仅供本文件内使用) */
|
||||||
|
interface RoleRecord {
|
||||||
|
/** 字符串 ID */
|
||||||
|
id: string;
|
||||||
|
/** 角色名称 */
|
||||||
|
roleName: string;
|
||||||
|
/** 角色标识 */
|
||||||
|
roleKey: string;
|
||||||
|
/** 状态:启用 | 禁用 */
|
||||||
|
status: string;
|
||||||
|
/** 备注 */
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑角色的请求参数(不导出) */
|
||||||
|
interface RoleParams {
|
||||||
|
roleName: string;
|
||||||
|
roleKey: string;
|
||||||
|
status: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── 角色接口 ─────────────
|
||||||
|
|
||||||
|
/** 获取角色列表 */
|
||||||
|
export const listRole = () => get<RoleRecord[]>('/api/system/role/list');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色
|
||||||
|
* @param params 角色参数
|
||||||
|
*/
|
||||||
|
export const addRole = (params: RoleParams) => post<RoleRecord>('/api/system/role/add', params);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑角色
|
||||||
|
* @param id 角色 ID
|
||||||
|
* @param params 角色参数
|
||||||
|
*/
|
||||||
|
export const editRole = (id: string, params: RoleParams) =>
|
||||||
|
post<RoleRecord>('/api/system/role/edit', { id, ...params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除角色
|
||||||
|
* @param id 角色 ID
|
||||||
|
*/
|
||||||
|
export const delRole = (id: string) => post('/api/system/role/del', { id });
|
||||||
@@ -1,30 +1,39 @@
|
|||||||
import { get, post } from '@/utils/request';
|
import { get, post } from '@/utils/request';
|
||||||
|
|
||||||
/** 部门节点数据结构 */
|
/** 部门节点数据结构(不导出,仅供本文件内使用) */
|
||||||
export interface DeptNode {
|
interface DeptNode {
|
||||||
key: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
parentKey: string | null;
|
parentId: string | null;
|
||||||
children?: DeptNode[];
|
children?: DeptNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增/编辑部门的请求参数 */
|
/** 新增/编辑部门的请求参数(不导出) */
|
||||||
export interface DeptParams {
|
interface DeptParams {
|
||||||
title: string;
|
title: string;
|
||||||
parentKey?: string | null;
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户记录数据结构 */
|
/** 用户记录数据结构(不导出,仅供本文件内使用) */
|
||||||
export interface UserRecord {
|
interface UserRecord {
|
||||||
username: string;
|
id: string;
|
||||||
name: string;
|
userName: string;
|
||||||
email: string;
|
nickName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
deptId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 新增/编辑用户的请求参数(不导出) */
|
||||||
* 获取部门树
|
interface UserParams {
|
||||||
*/
|
userName: string;
|
||||||
|
nickName: string;
|
||||||
|
status: string;
|
||||||
|
deptId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── 部门接口 ─────────────
|
||||||
|
|
||||||
|
/** 获取部门树 */
|
||||||
export const deptTree = () => get<DeptNode[]>('/api/system/dept/tree');
|
export const deptTree = () => get<DeptNode[]>('/api/system/dept/tree');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,21 +44,43 @@ export const addDept = (params: DeptParams) => post<DeptNode>('/api/system/dept/
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑部门
|
* 编辑部门
|
||||||
* @param key 部门 key
|
* @param id 部门 ID
|
||||||
* @param params 部门参数
|
* @param params 部门参数
|
||||||
*/
|
*/
|
||||||
export const editDept = (key: string, params: DeptParams) =>
|
export const editDept = (id: string, params: DeptParams) =>
|
||||||
post<DeptNode>('/api/system/dept/edit', { key, ...params });
|
post<DeptNode>('/api/system/dept/edit', { id, ...params });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除部门
|
* 删除部门
|
||||||
* @param key 部门 key
|
* @param id 部门 ID
|
||||||
*/
|
*/
|
||||||
export const delDept = (key: string) => post('/api/system/dept/del', { key });
|
export const delDept = (id: string) => post('/api/system/dept/del', { id });
|
||||||
|
|
||||||
|
// ───────────── 用户接口 ─────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户列表
|
* 获取用户列表
|
||||||
* @param deptKey 部门 key
|
* @param deptId 部门 ID
|
||||||
*/
|
*/
|
||||||
export const listUser = (deptKey: string) =>
|
export const listUser = (deptId: string) =>
|
||||||
get<UserRecord[]>('/api/system/user/list', { deptKey });
|
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 });
|
||||||
|
|||||||
15
src/components/AuthGuard.tsx
Normal file
15
src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
|
/** 路由守卫:未登录时跳转到登录页 */
|
||||||
|
const AuthGuard = () => {
|
||||||
|
const userInfo = useUserStore((s) => s.userInfo);
|
||||||
|
|
||||||
|
if (!userInfo) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthGuard;
|
||||||
42
src/hooks/useAppInit.ts
Normal file
42
src/hooks/useAppInit.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCurrentUser } from '@/api/auth';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用初始化 hook
|
||||||
|
* 刷新页面时,如果 localStorage 中有 token,则调用 /api/auth/me 刷新用户信息
|
||||||
|
* token 失效时自动清除 userInfo
|
||||||
|
*/
|
||||||
|
const useAppInit = () => {
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const setUserInfo = useUserStore((s) => s.setUserInfo);
|
||||||
|
const clearUserInfo = useUserStore((s) => s.clearUserInfo);
|
||||||
|
const token = useUserStore((s) => s.userInfo?.token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 没有 token,跳过初始化
|
||||||
|
if (!token) {
|
||||||
|
setInitialized(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCurrentUser();
|
||||||
|
// 用接口返回的最新数据更新 userInfo(保留原 token)
|
||||||
|
setUserInfo({ ...res.data!, token });
|
||||||
|
} catch {
|
||||||
|
// token 失效,清除用户信息
|
||||||
|
clearUserInfo();
|
||||||
|
} finally {
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return initialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAppInit;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import { Layout, Menu } from 'antd';
|
import { Avatar, Dropdown, Layout, Menu, Space } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
import { toMenuItems } from '@/routes/utils';
|
import { toMenuItems } from '@/routes/utils';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -13,6 +14,23 @@ const RootLayout = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const userInfo = useUserStore((s) => s.userInfo);
|
||||||
|
const clearUserInfo = useUserStore((s) => s.clearUserInfo);
|
||||||
|
|
||||||
|
/** 用户头像下拉菜单 */
|
||||||
|
const userMenuItems = [
|
||||||
|
{ key: 'profile', label: '个人信息' },
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{ key: 'logout', label: '退出登录', danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 下拉菜单点击事件 */
|
||||||
|
const handleUserMenuClick = ({ key }: { key: string }) => {
|
||||||
|
if (key === 'logout') {
|
||||||
|
clearUserInfo();
|
||||||
|
// TODO: 跳转到登录页
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh' }}>
|
<Layout style={{ height: '100vh' }}>
|
||||||
@@ -20,12 +38,24 @@ const RootLayout = () => {
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
borderBottom: '1px solid #f0f0f0',
|
borderBottom: '1px solid #f0f0f0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 'bold', fontSize: 18 }}>TaoTie</span>
|
<span style={{ fontWeight: 'bold', fontSize: 18 }}>TaoTie</span>
|
||||||
|
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
|
||||||
|
<Space style={{ cursor: 'pointer' }}>
|
||||||
|
<Avatar
|
||||||
|
size="small"
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
src={undefined}
|
||||||
|
style={{ backgroundColor: '#00b96b' }}
|
||||||
|
/>
|
||||||
|
{userInfo && <span>{userInfo.nickName}</span>}
|
||||||
|
</Space>
|
||||||
|
</Dropdown>
|
||||||
</Header>
|
</Header>
|
||||||
<Layout style={{ overflow: 'hidden' }}>
|
<Layout style={{ overflow: 'hidden' }}>
|
||||||
<Sider
|
<Sider
|
||||||
|
|||||||
39
src/mock/auth.ts
Normal file
39
src/mock/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
/** 包装为统一响应格式 */
|
||||||
|
const ok = (data: unknown) => ({ code: '0', msg: 'ok', data, time: Date.now(), ok: true });
|
||||||
|
|
||||||
|
/** 包装为错误响应格式 */
|
||||||
|
const fail = (msg: string) => ({ code: '401', msg, data: null, time: Date.now(), ok: false });
|
||||||
|
|
||||||
|
export const authHandlers = [
|
||||||
|
http.post('/api/auth/login', async ({ request }) => {
|
||||||
|
const body = await request.json() as { tenantId: string; userName: string; password: string };
|
||||||
|
// 模拟登录:任意租户号 + 用户名密码均返回成功
|
||||||
|
const result = {
|
||||||
|
id: '1',
|
||||||
|
userName: body.userName,
|
||||||
|
nickName: body.userName === 'admin' ? '管理员' : body.userName,
|
||||||
|
roles: ['admin'],
|
||||||
|
token: `mock_token_${Date.now()}`,
|
||||||
|
};
|
||||||
|
return HttpResponse.json(ok(result));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get('/api/auth/me', ({ request }) => {
|
||||||
|
// 从请求头中获取 token,有则返回用户信息,无则返回 401
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return HttpResponse.json(fail('未登录'), { status: 401 });
|
||||||
|
}
|
||||||
|
// 模拟返回当前用户信息
|
||||||
|
const result = {
|
||||||
|
id: '1',
|
||||||
|
userName: 'admin',
|
||||||
|
nickName: '管理员',
|
||||||
|
roles: ['admin'],
|
||||||
|
token: authHeader.replace('Bearer ', ''),
|
||||||
|
};
|
||||||
|
return HttpResponse.json(ok(result));
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { setupWorker } from 'msw/browser';
|
import { setupWorker } from 'msw/browser';
|
||||||
|
import { authHandlers } from './auth';
|
||||||
import { systemHandlers } from './system';
|
import { systemHandlers } from './system';
|
||||||
|
|
||||||
/** 汇总所有模块的 mock handlers */
|
/** 汇总所有模块的 mock handlers */
|
||||||
const worker = setupWorker(...systemHandlers);
|
const worker = setupWorker(...authHandlers, ...systemHandlers);
|
||||||
|
|
||||||
export default worker;
|
export default worker;
|
||||||
|
|||||||
@@ -1,71 +1,207 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
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 本地定义) */
|
||||||
|
interface MockRoleRecord {
|
||||||
|
id: string;
|
||||||
|
roleName: string;
|
||||||
|
roleKey: string;
|
||||||
|
status: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mock 数据 ────────────────────────────────
|
||||||
|
|
||||||
/** 部门树 mock 数据 */
|
/** 部门树 mock 数据 */
|
||||||
const deptList = [
|
const deptList: MockDeptNode[] = [
|
||||||
{
|
{
|
||||||
key: '1',
|
id: '1',
|
||||||
title: '总公司',
|
title: '总公司',
|
||||||
parentKey: null,
|
parentId: null,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: '1-1',
|
id: '1-1',
|
||||||
title: '技术部',
|
title: '技术部',
|
||||||
parentKey: '1',
|
parentId: '1',
|
||||||
children: [
|
children: [
|
||||||
{ key: '1-1-1', title: '前端组', parentKey: '1-1', children: [] },
|
{ id: '1-1-1', title: '前端组', parentId: '1-1', children: [] },
|
||||||
{ key: '1-1-2', title: '后端组', parentKey: '1-1', children: [] },
|
{ id: '1-1-2', title: '后端组', parentId: '1-1', children: [] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '1-2', title: '产品部', parentKey: '1', children: [] },
|
{ id: '1-2', title: '产品部', parentId: '1', children: [] },
|
||||||
{ key: '1-3', title: '运营部', parentKey: '1', children: [] },
|
{ id: '1-3', title: '运营部', parentId: '1', children: [] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 用户列表 mock 数据,按部门 key 分组 */
|
/** 用户列表 mock 数据,按部门 id 分组(运行时可变) */
|
||||||
const userMap: Record<string, object[]> = {
|
const userMap: Record<string, MockUserRecord[]> = {
|
||||||
'1': [{ username: 'admin', name: '管理员', email: 'admin@example.com', status: '启用' }],
|
'1': [{ id: '1', userName: 'admin', nickName: '管理员', status: '启用', deptId: '1' }],
|
||||||
'1-1': [
|
'1-1': [
|
||||||
{ username: 'zhangsan', name: '张三', email: 'zhangsan@example.com', status: '启用' },
|
{ id: '2', userName: 'zhangsan', nickName: '张三', status: '启用', deptId: '1-1' },
|
||||||
{ username: 'lisi', name: '李四', email: 'lisi@example.com', status: '启用' },
|
{ id: '3', userName: 'lisi', nickName: '李四', status: '启用', deptId: '1-1' },
|
||||||
],
|
],
|
||||||
'1-1-1': [{ username: 'wangwu', name: '王五', email: 'wangwu@example.com', status: '启用' }],
|
'1-1-1': [{ id: '4', userName: 'wangwu', nickName: '王五', status: '启用', deptId: '1-1-1' }],
|
||||||
'1-1-2': [{ username: 'zhaoliu', name: '赵六', email: 'zhaoliu@example.com', status: '禁用' }],
|
'1-1-2': [{ id: '5', userName: 'zhaoliu', nickName: '赵六', status: '禁用', deptId: '1-1-2' }],
|
||||||
'1-2': [{ username: 'sunqi', name: '孙七', email: 'sunqi@example.com', status: '启用' }],
|
'1-2': [{ id: '6', userName: 'sunqi', nickName: '孙七', status: '启用', deptId: '1-2' }],
|
||||||
'1-3': [],
|
'1-3': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 角色列表 mock 数据(运行时可变) */
|
||||||
|
const roleList: MockRoleRecord[] = [
|
||||||
|
{ id: '1', roleName: '超级管理员', roleKey: 'admin', status: '启用', remark: '拥有所有权限' },
|
||||||
|
{ id: '2', roleName: '编辑员', roleKey: 'editor', status: '启用', remark: '内容编辑权限' },
|
||||||
|
{ id: '3', roleName: '审核员', roleKey: 'reviewer', status: '启用', remark: '内容审核权限' },
|
||||||
|
{ id: '4', roleName: '访客', roleKey: 'guest', status: '禁用', remark: '只读权限' },
|
||||||
|
];
|
||||||
|
|
||||||
/** 包装为统一响应格式 */
|
/** 包装为统一响应格式 */
|
||||||
const ok = (data: unknown) => ({ code: '0', msg: 'ok', data, time: Date.now(), ok: true });
|
const ok = (data: unknown) => ({ code: '0', msg: 'ok', data, time: Date.now(), ok: true });
|
||||||
|
|
||||||
|
// ── MSW handlers ─────────────────────────────
|
||||||
|
|
||||||
export const systemHandlers = [
|
export const systemHandlers = [
|
||||||
// 获取部门树
|
// ───── 部门接口 ─────
|
||||||
|
|
||||||
http.get('/api/system/dept/tree', () => {
|
http.get('/api/system/dept/tree', () => {
|
||||||
return HttpResponse.json(ok(deptList));
|
return HttpResponse.json(ok(deptList));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 新增部门
|
|
||||||
http.post('/api/system/dept/add', async ({ request }) => {
|
http.post('/api/system/dept/add', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = await request.json() as Record<string, unknown>;
|
||||||
return HttpResponse.json(ok({ key: `dept_${Date.now()}`, ...body }));
|
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 }) => {
|
http.post('/api/system/dept/edit', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
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));
|
return HttpResponse.json(ok(body));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 删除部门
|
|
||||||
http.post('/api/system/dept/del', async ({ request }) => {
|
http.post('/api/system/dept/del', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = await request.json() as { id: string };
|
||||||
return HttpResponse.json(ok({ key: body.key }));
|
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 }) => {
|
http.get('/api/system/user/list', ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const deptKey = url.searchParams.get('deptKey') ?? '';
|
const deptId = url.searchParams.get('deptId') ?? '';
|
||||||
return HttpResponse.json(ok(userMap[deptKey] ?? []));
|
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 }));
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ───── 角色接口 ─────
|
||||||
|
|
||||||
|
http.get('/api/system/role/list', () => {
|
||||||
|
return HttpResponse.json(ok(roleList));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post('/api/system/role/add', async ({ request }) => {
|
||||||
|
const body = await request.json() as Record<string, unknown>;
|
||||||
|
const newRole: MockRoleRecord = {
|
||||||
|
id: `role_${Date.now()}`,
|
||||||
|
roleName: body.roleName as string,
|
||||||
|
roleKey: body.roleKey as string,
|
||||||
|
status: body.status as string,
|
||||||
|
remark: body.remark as string | undefined,
|
||||||
|
};
|
||||||
|
roleList.push(newRole);
|
||||||
|
return HttpResponse.json(ok(newRole));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post('/api/system/role/edit', async ({ request }) => {
|
||||||
|
const body = await request.json() as MockRoleRecord;
|
||||||
|
const idx = roleList.findIndex((r) => r.id === body.id);
|
||||||
|
if (idx !== -1) roleList[idx] = body;
|
||||||
|
return HttpResponse.json(ok(body));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post('/api/system/role/del', async ({ request }) => {
|
||||||
|
const body = await request.json() as { id: string };
|
||||||
|
const idx = roleList.findIndex((r) => r.id === body.id);
|
||||||
|
if (idx !== -1) roleList.splice(idx, 1);
|
||||||
|
return HttpResponse.json(ok({ id: body.id }));
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
80
src/pages/login/index.tsx
Normal file
80
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { App, Button, Card, Form, Input } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { login } from '@/api/auth';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
|
/** 登录表单字段 */
|
||||||
|
interface LoginFormValues {
|
||||||
|
tenantId: string;
|
||||||
|
userName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录页面(不加载布局) */
|
||||||
|
const Login = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const setUserInfo = useUserStore((s) => s.setUserInfo);
|
||||||
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
|
|
||||||
|
/** 登录提交 */
|
||||||
|
const handleLogin = async (values: LoginFormValues) => {
|
||||||
|
const res = await login(values);
|
||||||
|
const data = res.data!;
|
||||||
|
setUserInfo({
|
||||||
|
id: data.id,
|
||||||
|
userName: data.userName,
|
||||||
|
nickName: data.nickName,
|
||||||
|
roles: data.roles,
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
message.success('登录成功');
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: '#f0f2f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card title="TaoTie 管理系统" style={{ width: 400 }}>
|
||||||
|
<Form form={form} onFinish={handleLogin} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label="租户号"
|
||||||
|
name="tenantId"
|
||||||
|
rules={[{ required: true, message: '请输入租户号' }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="请输入租户号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="用户名"
|
||||||
|
name="userName"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="请输入用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="密码"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="请输入密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
77
src/pages/system/role/RoleModal.tsx
Normal file
77
src/pages/system/role/RoleModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Form, Input, Modal, Select } from 'antd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/** 角色表单字段(页面本地定义) */
|
||||||
|
export interface RoleFormValues {
|
||||||
|
roleName: string;
|
||||||
|
roleKey: string;
|
||||||
|
status: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleModalProps {
|
||||||
|
/** 弹窗是否可见 */
|
||||||
|
open: boolean;
|
||||||
|
/** 弹窗标题 */
|
||||||
|
title: string;
|
||||||
|
/** 编辑时的初始值 */
|
||||||
|
initialValues?: Partial<RoleFormValues>;
|
||||||
|
/** 确认回调 */
|
||||||
|
onOk: (values: RoleFormValues) => void;
|
||||||
|
/** 取消回调 */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoleModal = ({ open, title, initialValues, onOk, onCancel }: RoleModalProps) => {
|
||||||
|
const [form] = Form.useForm<RoleFormValues>();
|
||||||
|
|
||||||
|
/** 每次打开时重置并填充表单 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
if (initialValues) form.setFieldsValue(initialValues);
|
||||||
|
}
|
||||||
|
}, [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="roleName"
|
||||||
|
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入角色名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="角色标识"
|
||||||
|
name="roleKey"
|
||||||
|
rules={[{ required: true, message: '请输入角色标识' }]}
|
||||||
|
>
|
||||||
|
{/* 编辑时角色标识不可修改 */}
|
||||||
|
<Input placeholder="请输入角色标识" disabled={isEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="状态"
|
||||||
|
name="status"
|
||||||
|
initialValue="启用"
|
||||||
|
rules={[{ required: true, message: '请选择状态' }]}
|
||||||
|
>
|
||||||
|
<Select options={[{ label: '启用', value: '启用' }, { label: '禁用', value: '禁用' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" name="remark">
|
||||||
|
<Input.TextArea placeholder="请输入备注" rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleModal;
|
||||||
169
src/pages/system/role/RoleTable.tsx
Normal file
169
src/pages/system/role/RoleTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { App, Button, Card, Popconfirm, Space, Table, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { addRole, delRole, editRole, listRole } from '@/api/system/role';
|
||||||
|
import RoleModal from './RoleModal';
|
||||||
|
import type { RoleFormValues } from './RoleModal';
|
||||||
|
|
||||||
|
/** 角色记录(页面本地定义,不依赖 API 层类型) */
|
||||||
|
interface RoleRecord {
|
||||||
|
id: string;
|
||||||
|
roleName: string;
|
||||||
|
roleKey: string;
|
||||||
|
status: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态值与 Tag 颜色的映射 */
|
||||||
|
const statusColorMap: Record<string, string> = {
|
||||||
|
启用: 'success',
|
||||||
|
禁用: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoleTable = () => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<RoleRecord[]>([]);
|
||||||
|
|
||||||
|
// 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 listRole();
|
||||||
|
// 用 cancelled 标志位防止组件卸载后的竞态更新
|
||||||
|
if (!cancelled) setData(res.data ?? []);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalTitle, setModalTitle] = useState('新增角色');
|
||||||
|
const [editingRecord, setEditingRecord] = useState<RoleRecord | undefined>();
|
||||||
|
|
||||||
|
/** 打开新增弹窗 */
|
||||||
|
const handleAdd = () => {
|
||||||
|
setModalTitle('新增角色');
|
||||||
|
setEditingRecord(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 打开编辑弹窗 */
|
||||||
|
const handleEdit = (record: RoleRecord) => {
|
||||||
|
setModalTitle('编辑角色');
|
||||||
|
setEditingRecord(record);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除角色 */
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await delRole(id);
|
||||||
|
message.success('删除成功');
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modal 确认,根据是否有 editingRecord 判断新增/编辑 */
|
||||||
|
const handleModalOk = async (values: RoleFormValues) => {
|
||||||
|
if (editingRecord) {
|
||||||
|
await editRole(editingRecord.id, values);
|
||||||
|
message.success('编辑成功');
|
||||||
|
} else {
|
||||||
|
await addRole(values);
|
||||||
|
message.success('新增成功');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<RoleRecord> = [
|
||||||
|
{
|
||||||
|
title: '序列',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, _record: RoleRecord, index: number) => index + 1,
|
||||||
|
},
|
||||||
|
{ title: '角色名称', dataIndex: 'roleName', key: 'roleName' },
|
||||||
|
{ title: '角色标识', dataIndex: 'roleKey', key: 'roleKey' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag color={statusColorMap[status] ?? 'default'}>{status}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, record: RoleRecord) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description={`确定要删除角色「${record.roleName}」吗?`}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="角色列表"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<RoleRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleModal
|
||||||
|
open={modalOpen}
|
||||||
|
title={modalTitle}
|
||||||
|
initialValues={editingRecord}
|
||||||
|
onOk={handleModalOk}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleTable;
|
||||||
6
src/pages/system/role/index.tsx
Normal file
6
src/pages/system/role/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import RoleTable from './RoleTable';
|
||||||
|
|
||||||
|
/** 角色管理主页面 */
|
||||||
|
const RoleManagement = () => <RoleTable />;
|
||||||
|
|
||||||
|
export default RoleManagement;
|
||||||
@@ -2,16 +2,18 @@ import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
|||||||
import { Button, Card, Col, Popconfirm, Space, Spin, Tree } from 'antd';
|
import { Button, Card, Col, Popconfirm, Space, Spin, Tree } from 'antd';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import { addDept, delDept, deptTree, editDept } from '@/api/system/user';
|
||||||
addDept,
|
|
||||||
delDept,
|
|
||||||
deptTree,
|
|
||||||
editDept,
|
|
||||||
type DeptNode,
|
|
||||||
} from '@/api/system/user';
|
|
||||||
import DeptModal from './DeptModal';
|
import DeptModal from './DeptModal';
|
||||||
import type { DeptFormValues } from './DeptModal';
|
import type { DeptFormValues } from './DeptModal';
|
||||||
|
|
||||||
|
/** 部门节点(页面本地定义,不依赖 API 层类型) */
|
||||||
|
interface DeptNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
children?: DeptNode[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 单个树节点标题,含悬浮操作按钮 */
|
/** 单个树节点标题,含悬浮操作按钮 */
|
||||||
const DeptTreeNode = ({
|
const DeptTreeNode = ({
|
||||||
node,
|
node,
|
||||||
@@ -20,9 +22,9 @@ const DeptTreeNode = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
node: DeptNode;
|
node: DeptNode;
|
||||||
onAdd: (key: string) => void;
|
onAdd: (id: string) => void;
|
||||||
onEdit: (node: DeptNode) => void;
|
onEdit: (node: DeptNode) => void;
|
||||||
onDelete: (key: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ const DeptTreeNode = ({
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
title="新增子部门"
|
title="新增子部门"
|
||||||
onClick={() => onAdd(node.key)}
|
onClick={() => onAdd(node.id)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -60,7 +62,7 @@ const DeptTreeNode = ({
|
|||||||
okText="删除"
|
okText="删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={() => onDelete(node.key)}
|
onConfirm={() => onDelete(node.id)}
|
||||||
>
|
>
|
||||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除" />
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除" />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@@ -72,27 +74,27 @@ const DeptTreeNode = ({
|
|||||||
/** 递归将 DeptNode 转为 antd Tree 的 DataNode */
|
/** 递归将 DeptNode 转为 antd Tree 的 DataNode */
|
||||||
const toTreeData = (
|
const toTreeData = (
|
||||||
nodes: DeptNode[],
|
nodes: DeptNode[],
|
||||||
onAdd: (key: string) => void,
|
onAdd: (id: string) => void,
|
||||||
onEdit: (node: DeptNode) => void,
|
onEdit: (node: DeptNode) => void,
|
||||||
onDelete: (key: string) => Promise<void>,
|
onDelete: (id: string) => Promise<void>,
|
||||||
): DataNode[] =>
|
): DataNode[] =>
|
||||||
nodes.map((node) => ({
|
nodes.map((node) => ({
|
||||||
key: node.key,
|
key: node.id, // antd Tree 必需
|
||||||
title: <DeptTreeNode node={node} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />,
|
title: <DeptTreeNode node={node} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />,
|
||||||
children: node.children ? toTreeData(node.children, onAdd, onEdit, onDelete) : undefined,
|
children: node.children ? toTreeData(node.children, onAdd, onEdit, onDelete) : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface DeptTreeProps {
|
interface DeptTreeProps {
|
||||||
/** 当前选中的部门 key */
|
/** 当前选中的部门 id */
|
||||||
selectedKey: string;
|
selectedId: string;
|
||||||
/** 选中部门回调 */
|
/** 选中部门回调 */
|
||||||
onSelect: (key: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 弹窗模式:新增根部门 | 新增子部门 | 编辑 */
|
/** 弹窗模式:新增根部门 | 新增子部门 | 编辑 */
|
||||||
type ModalMode = 'addRoot' | 'addChild' | 'edit';
|
type ModalMode = 'addRoot' | 'addChild' | 'edit';
|
||||||
|
|
||||||
const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
const DeptTree = ({ selectedId, onSelect }: DeptTreeProps) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deptData, setDeptData] = useState<DeptNode[]>([]);
|
const [deptData, setDeptData] = useState<DeptNode[]>([]);
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
|||||||
const [modalMode, setModalMode] = useState<ModalMode>('addRoot');
|
const [modalMode, setModalMode] = useState<ModalMode>('addRoot');
|
||||||
const [modalTitle, setModalTitle] = useState('新增部门');
|
const [modalTitle, setModalTitle] = useState('新增部门');
|
||||||
const [editingNode, setEditingNode] = useState<DeptNode | null>(null);
|
const [editingNode, setEditingNode] = useState<DeptNode | null>(null);
|
||||||
const [parentKey, setParentKey] = useState<string>('');
|
const [parentId, setParentId] = useState<string>('');
|
||||||
|
|
||||||
/** 打开新增根部门弹窗 */
|
/** 打开新增根部门弹窗 */
|
||||||
const handleAddRoot = () => {
|
const handleAddRoot = () => {
|
||||||
@@ -136,10 +138,10 @@ const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 打开新增子部门弹窗 */
|
/** 打开新增子部门弹窗 */
|
||||||
const handleAddChild = (pKey: string) => {
|
const handleAddChild = (pId: string) => {
|
||||||
setModalMode('addChild');
|
setModalMode('addChild');
|
||||||
setModalTitle('新增子部门');
|
setModalTitle('新增子部门');
|
||||||
setParentKey(pKey);
|
setParentId(pId);
|
||||||
setEditingNode(null);
|
setEditingNode(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -153,8 +155,8 @@ const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 删除部门,由 Popconfirm 确认后调用 */
|
/** 删除部门,由 Popconfirm 确认后调用 */
|
||||||
const handleDelete = async (key: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await delDept(key);
|
await delDept(id);
|
||||||
// 删除成功后重新加载部门树
|
// 删除成功后重新加载部门树
|
||||||
loadDeptTree();
|
loadDeptTree();
|
||||||
};
|
};
|
||||||
@@ -162,11 +164,11 @@ const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
|||||||
/** Modal 确认,根据模式调用对应接口后刷新部门树 */
|
/** Modal 确认,根据模式调用对应接口后刷新部门树 */
|
||||||
const handleModalOk = async (values: DeptFormValues) => {
|
const handleModalOk = async (values: DeptFormValues) => {
|
||||||
if (modalMode === 'addRoot') {
|
if (modalMode === 'addRoot') {
|
||||||
await addDept({ title: values.name, parentKey: null });
|
await addDept({ title: values.name, parentId: null });
|
||||||
} else if (modalMode === 'addChild') {
|
} else if (modalMode === 'addChild') {
|
||||||
await addDept({ title: values.name, parentKey: parentKey });
|
await addDept({ title: values.name, parentId });
|
||||||
} else if (modalMode === 'edit' && editingNode) {
|
} else if (modalMode === 'edit' && editingNode) {
|
||||||
await editDept(editingNode.key, { title: values.name });
|
await editDept(editingNode.id, { title: values.name });
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
// 操作成功后重新加载部门树
|
// 操作成功后重新加载部门树
|
||||||
@@ -191,7 +193,7 @@ const DeptTree = ({ selectedKey, onSelect }: DeptTreeProps) => {
|
|||||||
<Tree
|
<Tree
|
||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
defaultExpandAll
|
defaultExpandAll
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedId]}
|
||||||
blockNode
|
blockNode
|
||||||
onSelect={(keys) => {
|
onSelect={(keys) => {
|
||||||
if (keys.length > 0) onSelect(String(keys[0]));
|
if (keys.length > 0) onSelect(String(keys[0]));
|
||||||
|
|||||||
79
src/pages/system/user/UserModal.tsx
Normal file
79
src/pages/system/user/UserModal.tsx
Normal 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;
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { Card, Col, Table, Tag } from 'antd';
|
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 { useEffect, useState } from 'react';
|
||||||
import { listUser, type UserRecord } from '@/api/system/user';
|
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 颜色的映射 */
|
/** 状态值与 Tag 颜色的映射 */
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
@@ -8,35 +21,30 @@ const statusColorMap: Record<string, string> = {
|
|||||||
禁用: 'error',
|
禁用: '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 {
|
interface UserTableProps {
|
||||||
deptKey: string;
|
/** 当前选中的部门 id */
|
||||||
|
deptId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserTable = ({ deptKey }: UserTableProps) => {
|
const UserTable = ({ deptId }: UserTableProps) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<UserRecord[]>([]);
|
const [data, setData] = useState<UserRecord[]>([]);
|
||||||
|
|
||||||
|
// tick 变化时触发用户列表重新加载
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
/** 触发用户列表刷新 */
|
||||||
|
const refresh = () => setTick((n) => n + 1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await listUser(deptKey);
|
const res = await listUser(deptId);
|
||||||
// 用 cancelled 标志位防止组件卸载后的竞态更新
|
// 用 cancelled 标志位防止组件卸载后的竞态更新
|
||||||
if (!cancelled) setData(res.data ?? []);
|
if (!cancelled) setData(res.data ?? []);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,19 +54,122 @@ const UserTable = ({ deptKey }: UserTableProps) => {
|
|||||||
|
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [deptKey]);
|
}, [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 (
|
return (
|
||||||
<Col xs={24} sm={24} md={17} lg={18} xl={19} style={{ display: 'flex' }}>
|
<Col xs={24} sm={24} md={17} lg={18} xl={19} style={{ display: 'flex' }}>
|
||||||
<Card title="用户列表" style={{ width: '100%' }}>
|
<Card
|
||||||
<Table
|
title="用户列表"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<UserRecord>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="username"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 10 }}
|
pagination={{ pageSize: 10 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<UserModal
|
||||||
|
open={modalOpen}
|
||||||
|
title={modalTitle}
|
||||||
|
initialValues={editingRecord}
|
||||||
|
onOk={handleModalOk}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { useState } from 'react';
|
|||||||
import DeptTree from './DeptTree';
|
import DeptTree from './DeptTree';
|
||||||
import UserTable from './UserTable';
|
import UserTable from './UserTable';
|
||||||
|
|
||||||
|
/** 用户管理主页面:左侧部门树 + 右侧用户表格 */
|
||||||
const UserManagement = () => {
|
const UserManagement = () => {
|
||||||
const [selectedDeptKey, setSelectedDeptKey] = useState('1');
|
// 当前选中的部门 id,由部门树点击触发
|
||||||
|
const [selectedDeptId, setSelectedDeptId] = useState<string>('1');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
gutter={[16, 16]}
|
gutter={[16, 16]}
|
||||||
style={{ padding: 24, height: '100%', alignContent: 'flex-start' }}
|
style={{ padding: 24, height: '100%', alignContent: 'flex-start' }}
|
||||||
>
|
>
|
||||||
<DeptTree selectedKey={selectedDeptKey} onSelect={setSelectedDeptKey} />
|
<DeptTree selectedId={selectedDeptId} onSelect={setSelectedDeptId} />
|
||||||
<UserTable deptKey={selectedDeptKey} />
|
<UserTable deptId={selectedDeptId} />
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { createBrowserRouter } from 'react-router';
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
import RootLayout from '@/layouts/RootLayout';
|
import RootLayout from '@/layouts/RootLayout';
|
||||||
|
import Login from '@/pages/login/index';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
import { toRouteObjects } from '@/routes/utils';
|
import { toRouteObjects } from '@/routes/utils';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
|
// 登录页:独立路由,不加载布局,无需鉴权
|
||||||
|
{ path: '/login', element: <Login /> },
|
||||||
|
// 需要鉴权的路由:AuthGuard 判断登录状态
|
||||||
|
{
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <RootLayout />,
|
element: <RootLayout />,
|
||||||
children: toRouteObjects(routes),
|
children: toRouteObjects(routes),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { HomeOutlined, InfoCircleOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
|
import { HomeOutlined, InfoCircleOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import SystemLayout from '@/layouts/SystemLayout';
|
import SystemLayout from '@/layouts/SystemLayout';
|
||||||
import About from '@/pages/about';
|
import About from '@/pages/about';
|
||||||
import Home from '@/pages/home';
|
import Home from '@/pages/home';
|
||||||
import NotFound from '@/pages/not-found';
|
import NotFound from '@/pages/not-found';
|
||||||
|
import RoleManagement from '@/pages/system/role';
|
||||||
import UserManagement from '@/pages/system/user';
|
import UserManagement from '@/pages/system/user';
|
||||||
import type { RouteItem } from './types';
|
import type { RouteItem } from './types';
|
||||||
|
|
||||||
@@ -31,6 +32,12 @@ export const routes: RouteItem[] = [
|
|||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
component: <UserManagement />,
|
component: <UserManagement />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/system/role',
|
||||||
|
label: '角色管理',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
component: <RoleManagement />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
17
src/store/app.ts
Normal file
17
src/store/app.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/** 全局共用状态(放置应用级别的全局状态) */
|
||||||
|
interface AppState {
|
||||||
|
/** 侧边栏是否折叠 */
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
/** 切换侧边栏折叠状态 */
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局 app store */
|
||||||
|
const useAppStore = create<AppState>((set) => ({
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useAppStore;
|
||||||
3
src/store/index.ts
Normal file
3
src/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as useAppStore } from './app';
|
||||||
|
export { default as useUserStore } from './user';
|
||||||
|
export type { UserInfo } from './user';
|
||||||
43
src/store/user.ts
Normal file
43
src/store/user.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/** 当前登录用户信息 */
|
||||||
|
interface UserInfo {
|
||||||
|
/** 用户 ID */
|
||||||
|
id: string;
|
||||||
|
/** 用户名 */
|
||||||
|
userName: string;
|
||||||
|
/** 昵称 */
|
||||||
|
nickName: string;
|
||||||
|
/** 角色标识列表 */
|
||||||
|
roles: string[];
|
||||||
|
/** 访问令牌 */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户状态 */
|
||||||
|
interface UserState {
|
||||||
|
/** 当前登录用户信息,未登录时为 null */
|
||||||
|
userInfo: UserInfo | null;
|
||||||
|
/** 设置用户信息(登录成功后调用) */
|
||||||
|
setUserInfo: (info: UserInfo | null) => void;
|
||||||
|
/** 清除用户信息(退出登录时调用) */
|
||||||
|
clearUserInfo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户状态 store,使用 persist 中间件持久化到 localStorage */
|
||||||
|
const useUserStore = create<UserState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
userInfo: null,
|
||||||
|
setUserInfo: (info) => set({ userInfo: info }),
|
||||||
|
clearUserInfo: () => set({ userInfo: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'taotie-user',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
|
export type { UserInfo };
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: import.meta.env.PUBLIC_BASE_URL,
|
baseURL: import.meta.env.PUBLIC_BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器:自动附加 token
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
// TODO: 添加 token 等请求头
|
const token = useUserStore.getState().userInfo?.token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user