Compare commits

5 Commits
main ... dev

22 changed files with 789 additions and 36 deletions

View File

@@ -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/

View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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
View 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 });

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

View File

@@ -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
View 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));
}),
];

View File

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

View File

@@ -19,6 +19,15 @@ interface MockUserRecord {
deptId?: string; deptId?: string;
} }
/** 角色记录mock 本地定义) */
interface MockRoleRecord {
id: string;
roleName: string;
roleKey: string;
status: string;
remark?: string;
}
// ── mock 数据 ──────────────────────────────── // ── mock 数据 ────────────────────────────────
/** 部门树 mock 数据 */ /** 部门树 mock 数据 */
@@ -56,6 +65,14 @@ const userMap: Record<string, MockUserRecord[]> = {
'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 });
@@ -154,4 +171,37 @@ export const systemHandlers = [
} }
return HttpResponse.json(ok({ 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
View 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;

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

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

View File

@@ -0,0 +1,6 @@
import RoleTable from './RoleTable';
/** 角色管理主页面 */
const RoleManagement = () => <RoleTable />;
export default RoleManagement;

View File

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

View File

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

View File

@@ -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) => {