Compare commits

...

10 Commits

Author SHA1 Message Date
gaoziman
05999e0231 chore(项目配置): 更新项目名称
- 将项目名称从 arco-design-pro 更新为 codernew-api-frontend
- 与实际项目定位保持一致
2025-11-18 23:48:44 +08:00
gaoziman
4a6a0c2209 docs(项目文档): 添加项目指南和开发文档
- 添加 AGENTS.md 项目结构和开发指南
- 添加 CLAUDE.md AI辅助开发配置文档
- 更新 .gitignore 忽略 docs 和 .serena 目录
2025-11-18 21:20:10 +08:00
gaoziman
660ab2eb9c style(UI优化): 优化导航栏、组件和全局样式
导航栏优化:
- 移除搜索框
- 移除消息通知图标
- 修复 React Hooks 规则问题
- 优化头像显示逻辑

组件样式:
- 优化 TabBar 样式
- 优化 Pagination 样式
- 更新全局布局样式
2025-11-18 20:47:48 +08:00
gaoziman
555d3b164e feat(功能页面): 完成登录、用户信息和用户管理模块
登录模块:
- 集成 Zustand 状态管理
- 使用宽松相等判断响应码

用户信息页面:
- 个人信息展示和编辑
- 头像上传功能
- 密码修改功能

用户管理模块:
- 用户列表展示(支持头像显示)
- 新增/编辑/删除用户功能
- 密码重置功能
- 隐藏超级管理员操作按钮
- 支持批量操作
- 列设置组件
2025-11-18 20:47:15 +08:00
gaoziman
f20951fb92 refactor(状态管理): 迁移到 Zustand 并优化布局
- 新增 Zustand 用户状态管理(userStore.ts)
- 集成 persist 中间件实现自动持久化
- 简化 layout.tsx 用户状态管理逻辑
- 更新 Redux store 配置
- 优化主入口和国际化配置
2025-11-18 20:46:40 +08:00
gaoziman
b3c0a05a3e feat(API层): 新增完整的 API 接口封装
- 新增用户认证接口(auth.ts):登录、注册、获取用户信息
- 新增用户管理接口(user.ts):增删改查、批量操作
- 实现完整的请求/响应类型定义
- 统一错误处理和响应格式
2025-11-18 20:46:02 +08:00
gaoziman
bc70cbf087 feat(工具函数): 新增核心工具函数模块
- 新增认证工具函数(auth.ts):Token管理、权限检查
- 新增 HTTP 请求封装(request.ts):Axios拦截器、响应处理
- 新增本地存储工具(storage.ts):统一的存储接口
2025-11-18 20:45:19 +08:00
gaoziman
f312be1aa8 feat(类型系统): 新增项目类型定义和常量
- 新增 API 响应类型定义(src/types/api.ts)
- 新增用户认证相关类型(src/types/auth.ts)
- 新增用户信息类型(src/types/user.ts)
- 新增全局常量定义(src/constants/)
- 更新项目设置配置
2025-11-18 20:44:43 +08:00
gaoziman
3ea18a417d chore(依赖): 添加项目依赖和构建配置
- 添加 Zustand 状态管理库
- 配置开发和生产环境变量
- 更新 Vite 构建配置
- 更新 Git 钩子配置
- 修复 NavBar 组件的 React Hooks 规则问题
2025-11-18 20:44:07 +08:00
gaoziman
c8c20299d9 style(导航栏): 优化导航栏标题和布局样式
- 修改导航栏标题为 Codernew React Pro
- 调整 Logo 容器宽度为自适应
- 添加标题文字不换行样式
- 确保 Logo 和标题在同一行显示
2025-11-07 22:37:04 +08:00
43 changed files with 18856 additions and 2252 deletions

10
.env.development Normal file
View File

@ -0,0 +1,10 @@
# 开发环境配置
# API 基础地址(使用代理)
VITE_API_BASE_URL=/api
# API 请求超时时间(毫秒)
VITE_API_TIMEOUT=30000
# 应用标题
VITE_APP_TITLE=Codernew React Pro

10
.env.production Normal file
View File

@ -0,0 +1,10 @@
# 生产环境配置
# API 基础地址(生产环境需要修改为实际地址)
VITE_API_BASE_URL=http://localhost:8080
# API 请求超时时间(毫秒)
VITE_API_TIMEOUT=30000
# 应用标题
VITE_APP_TITLE=Codernew React Pro

6
.gitignore vendored
View File

@ -48,4 +48,8 @@ yarn-error.log*
.vscode
# build output
/dist
/dist
/docs/
# IDE and tool configs
.serena/

View File

@ -4,3 +4,4 @@
npm run pre-commit
npm run pre-commit
npm run pre-commit
npm run pre-commit

39
AGENTS.md Normal file
View File

@ -0,0 +1,39 @@
# Repository Guidelines
## 项目结构与模块组织
- `src/` 存放核心前端代码:`pages/` 管理业务页面(登录、用户管理、个人信息),`components/` 复用组件,`api/` 与后端交互封装,`store/` 保存全局设置与标签页状态,`utils/` 管理工具函数,`mock/` 仅在本地调试启用。
- `docs/` 收录 API 对接与集成报告,提交前务必同步更新。
- `dist/``pnpm run build` 产物,禁止手动修改;`.env.*` 提供环境变量,请按需复制调整。
## 构建与开发命令
| 命令 | 说明 |
| ---------------------------------------- | --------------------------------------------------------- |
| `pnpm install` | 安装依赖,项目首选 pnpm若使用 npm需同步更新锁文件。 |
| `pnpm run dev` | 启动 Vite 开发服务器,默认走 `/api` 代理到本地 8080。 |
| `pnpm run build` | 生成产物到 `dist/`,用于联调或上线前校验。 |
| `pnpm run preview` | 以本地静态服务验证构建结果。 |
| `pnpm run eslint` / `pnpm run stylelint` | 校验并自动修复 TS/TSX 与 Less。 |
| `pnpm run pre-commit` | 执行 pretty-quick、ESLint、StylelintPR 前必跑。 |
## 编码风格与命名
- 语法统一使用 TypeScript + React 17 Hooks目录与文件采用小写连字符示例`user-management`)。
- ESLint 继承 `plugin:@typescript-eslint/recommended``plugin:react-hooks/recommended`Prettier 约定 2 空格缩进、单引号、强制分号。
- 样式使用 Less + CSS Modules遵循 `.module.less` 命名Stylelint 规则在 `.stylelintrc` 中定义。
## 测试指引
- 当前仓库尚未集成自动化测试;提交复杂功能时,需给出手工测试步骤,并优先在 `src/__tests__`(或相应页面目录)补充 `*.spec.tsx`
- 覆盖重点:核心表单(登录、用户管理)与 API 适配层,可借助 Jest + @testing-library/react命名遵循 `模块名.行为.spec.tsx`
## 提交与 PR 规范
- Commit 信息遵循 `type(scope): 描述`,与现有记录(如 `feat(布局): ...`、`style(导航栏): ...`)保持一致,描述使用简体中文。
- Pull Request 需包含:变更摘要、关联 Issue/需求编号、截图或命令输出、测试与 lint 结果;若改动 API 或配置,同步更新 `docs/``.env.*` 示例。
## 安全与配置提示
- `.env.development` 默认将 `VITE_API_BASE_URL` 指向 `/api`,生产环境务必更新为真实网关地址,避免调试代理泄漏。
- Mock 入口在 `src/main.tsx` 引入,如需生产构建剔除,可通过环境变量包装 `import './mock'`,防止将虚拟数据发布到线上。

517
CLAUDE.md Normal file
View File

@ -0,0 +1,517 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
---
# React Arco Design Pro 项目指南
## 项目概述
这是一个基于 React 17 + Arco Design + TypeScript + Vite 的企业级中后台管理系统模板项目。项目采用前后端分离架构,使用 Redux 进行状态管理,使用 Mock.js 模拟后端数据。
## 常用命令
### 开发与构建
```bash
# 启动开发服务器(推荐)
npm run dev
# 或
npm start
# 生产环境构建
npm run build
# 预览生产构建
npm run preview
```
### 代码质量检查
```bash
# ESLint 检查并自动修复
npm run eslint
# Stylelint 检查并自动修复
npm run stylelint
# Git 提交前检查(自动执行)
npm run pre-commit
```
**注意**: 建议在 `.eslintrc.js` 中配置规则,禁止在响应码判断时使用严格相等:
```javascript
// 可选:配置 ESLint 规则提醒
rules: {
// 其他规则...
// 在代码审查时人工检查响应码判断逻辑
}
```
### 包管理
项目使用 pnpm 作为包管理器,但 npm/yarn 也兼容。建议使用 pnpm 以保持依赖一致性。
## 核心架构设计
### 技术栈核心
- **构建工具**: Vite 2.6.14(快速 HMR基于 ESM
- **UI 框架**: React 17.0.2
- **UI 组件库**: @arco-design/web-react 2.32.2(字节跳动开源)
- **状态管理**: Redux 4.1.2(传统模式,非 Redux Toolkit
- **路由**: React Router 5.2.0
- **HTTP 客户端**: Axios 0.24.0
- **CSS 预处理**: Less 4.1.2
- **类型系统**: TypeScript 4.5.2strict: false
### 架构分层
```
src/
├── pages/ # 页面组件(按功能模块组织,支持懒加载)
├── components/ # 公共组件NavBar, TabBar, Footer 等)
├── layout.tsx # 主布局组件(侧边栏、标签页、内容区)
├── routes.ts # 路由配置 + 权限过滤逻辑
├── store/ # Redux 状态管理settings, userInfo, tabs
├── utils/ # 工具函数(认证、主题、国际化等)
├── mock/ # Mock.js 数据模拟
├── locale/ # 国际化资源文件
└── main.tsx # 应用入口
```
### 关键架构模式
#### 1. 路由懒加载机制
- 使用 `import.meta.glob()` 动态导入页面组件
- 通过 `@loadable/component` 实现代码分割
- 路由配置在 `src/routes.ts`,约定式路由:`./pages/${route.key}/index.tsx`
#### 2. 权限控制系统
- **路由级权限**: `routes.ts` 中的 `useRoute` Hook 根据用户权限过滤路由
- **组件级权限**: 使用 `<PermissionWrapper>` 组件包裹需要权限控制的元素
- **权限验证**: `utils/authentication.ts` 提供权限判断逻辑
- **角色系统**: 简单的 Admin/User 角色,权限存储在 Redux Store
#### 3. 状态管理架构
Redux Store 管理三类全局状态:
```typescript
// src/store/index.ts
GlobalState {
settings: { // 系统设置(主题色、布局配置)
themeColor,
navbar,
menu,
footer
},
userInfo: { // 用户信息
name,
avatar,
permissions // 权限映射表
},
tabs: { // 多标签页状态
activeKey,
tabs[]
}
}
```
**重要**: Redux 使用传统模式(非 Redux Toolkit所有 Action 通过字符串类型派发。
#### 4. 主题系统
- **主题配置**: `src/settings.json` 定义默认主题色
- **主题切换**: `utils/changeTheme.ts` 切换明暗模式
- **色值计算**: `utils/initThemeColor.ts` 根据主题色自动生成 10 个梯度色
- **Vite 集成**: `vite.config.ts` 通过 `@arco-plugins/vite-react` 注入主题变量
#### 5. 多标签页系统
标签页状态由 Redux 管理,支持:
- 添加/关闭标签页(`tabs/addTab`, `tabs/removeTab`
- 切换标签页(`tabs/setActiveTab`
- 右键菜单操作(关闭左侧/右侧/其他)
标签页与路由联动,在 `layout.tsx` 中实现。
### 数据流设计
```
用户交互 → Component
dispatch Redux Action
Reducer 更新 Store
useSelector 订阅变化
Component 重新渲染
```
HTTP 请求流:
```
Component → axios → Mock.js (开发环境) / Backend API (生产环境) → Component State/Redux
```
### 路径别名配置
```typescript
// 在代码中使用 @/ 代替 src/
import utils from '@/utils/xxx';
```
配置位置:
- `tsconfig.json`: `"@/*": ["src/*"]`
- `vite.config.ts`: `alias: [{ find: '@', replacement: '/src' }]`
## API 开发规范
### 响应码处理规范
**【重要】必须使用宽松相等判断响应码,避免类型不一致问题**
#### 问题背景
后端不同接口可能返回不同类型的响应码:
- 部分接口返回字符串 `"0"`
- 部分接口返回数字 `0`
使用严格相等 `===` 判断时会导致类型不匹配,即使业务成功也会被判断为失败。
#### 强制规范
**1. 在所有业务代码中判断响应码时,必须使用宽松相等 `==`**
```typescript
// ✅ 正确:使用宽松相等
if (result.code == SUCCESS_CODE) {
// 处理成功逻辑
}
if (result.code == 0) {
// 处理成功逻辑
}
// ❌ 错误:使用严格相等
if (result.code === SUCCESS_CODE) {
// 可能导致字符串 "0" !== 数字 0
// 处理成功逻辑
}
```
**2. 响应拦截器中必须使用宽松相等**
`src/utils/request.ts` 响应拦截器中:
```typescript
// ✅ 正确
if (data.code == SUCCESS_CODE) {
return response;
}
// ❌ 错误
if (data.code === SUCCESS_CODE) {
return response;
}
```
**3. 类型守卫函数中必须使用宽松相等**
`src/types/api.ts` 中:
```typescript
// ✅ 正确
export function isSuccessResponse<T>(response: R<T>): boolean {
return response.code == 0;
}
// ❌ 错误
export function isSuccessResponse<T>(response: R<T>): boolean {
return response.code === 0;
}
```
#### 适用范围
以下场景必须遵守此规范:
1. **登录处理** (`src/pages/login/form.tsx`)
2. **用户信息页面** (`src/pages/user-info/index.tsx`)
3. **用户管理页面** (`src/pages/user-management/`)
4. **所有调用后端 API 的业务代码**
5. **所有自定义的响应处理工具函数**
#### 常量定义
`src/constants/index.ts` 中:
```typescript
// 响应码定义为数字类型(推荐)
export const SUCCESS_CODE = 0;
export const ERROR_CODE = 1;
```
#### 检查清单
在编写或修改 API 调用代码时,必须检查:
- [ ] 所有 `result.code === xxx` 改为 `result.code == xxx`
- [ ] 所有 `response.code === xxx` 改为 `response.code == xxx`
- [ ] 响应拦截器使用宽松相等判断
- [ ] 工具函数使用宽松相等判断
#### 历史问题记录
**问题日期**: 2025-11-17
**问题描述**: 登录功能失效,用户管理页面空白
**根本原因**:
- 后端登录接口返回 `code: "0"` (字符串)
- 前端使用 `result.code === 0` (严格相等判断数字)
- 导致 `"0" !== 0`,成功响应被判断为失败
**解决方案**:
1. 将所有 `===` 改为 `==` 进行宽松相等判断
2. 修改了以下文件:
- `src/utils/request.ts`
- `src/pages/login/form.tsx`
- `src/pages/user-info/index.tsx`
- `src/pages/user-management/UserFormModal.tsx`
- `src/pages/user-management/index.tsx`
- `src/types/api.ts`
**预防措施**: 制定本规范文档,在代码审查时强制检查
### API 调用最佳实践
```typescript
// 1. 标准 API 调用模式
async function fetchData() {
try {
const result = await apiFunction(params);
// ✅ 使用宽松相等判断
if (result.code == SUCCESS_CODE) {
// 处理成功逻辑
setData(result.data);
}
// 错误消息已在拦截器中显示,无需额外处理
} catch (error) {
console.error('请求失败:', error);
// 异常已在拦截器中提示,此处仅需记录日志
}
}
// 2. 带加载状态的 API 调用
async function handleSubmit() {
try {
setLoading(true);
const result = await apiFunction(params);
if (result.code == SUCCESS_CODE) {
Message.success('操作成功');
onSuccess();
}
} catch (error) {
console.error('操作失败:', error);
} finally {
setLoading(false);
}
}
```
## 关键开发约定
### 页面组件开发
1. **文件位置**: 所有页面放在 `src/pages/{module-name}/index.tsx`
2. **路由注册**: 在 `src/routes.ts``routes` 数组中添加路由配置
3. **权限控制**: 通过 `requiredPermissions``oneOfPerm` 配置权限
4. **国际化**: 页面标题使用 `locale` 多语言 key
示例:
```typescript
// src/routes.ts
{
name: 'menu.moduleName', // 国际化 key
key: 'module-name', // 对应 pages/module-name/index.tsx
requiredPermissions: [ // 可选:需要的权限
{ resource: 'module', actions: ['read'] }
]
}
```
### Redux 状态操作
```typescript
// 获取状态
import { useSelector } from 'react-redux';
const { userInfo, settings } = useSelector((state: GlobalState) => state);
// 更新状态
import { useDispatch } from 'react-redux';
const dispatch = useDispatch();
dispatch({
type: 'update-userInfo',
payload: { userInfo: newUserInfo },
});
```
### 国际化使用
```typescript
// 在组件中使用国际化
import useLocale from '@/utils/useLocale';
import locale from './locale'; // 本地语言文件
function MyComponent() {
const t = useLocale(locale);
return <div>{t['key.name']}</div>;
}
```
### 主题色动态修改
```typescript
// 修改主题色(自动生成 10 个梯度色 CSS 变量)
import initThemeColor from '@/utils/initThemeColor';
initThemeColor('#026166', false); // (颜色值, 是否暗色模式)
```
## Mock 数据开发
- Mock 文件位置: `src/mock/`
- 使用 Mock.js 拦截 axios 请求
- 在 `src/main.tsx` 中导入: `import './mock'`
- 生产环境需移除 mock 导入
## 已知技术债务
1. **TypeScript 严格模式未开启**: `tsconfig.json``strict: false`,建议开启以提升类型安全
2. **依赖版本较旧**:
- React 17建议升级到 18
- axios 0.24.0(存在安全漏洞,建议立即升级)
- React Router 5建议升级到 v6
3. **缺少测试**: 项目无单元测试和集成测试
4. **Redux 传统模式**: 代码冗长,建议迁移到 Redux Toolkit
5. **安全防护不足**:
- localStorage 明文存储敏感信息
- 缺少 CSRF 防护
- 缺少输入验证和清洗
## 常见问题排查
### API 响应成功但业务逻辑未执行
**症状**:
- API 请求返回成功(控制台显示 `code: "0"``code: 0`
- 但成功回调未执行,数据未更新
- 或登录成功但未跳转
**原因**:
使用了严格相等 `===` 判断响应码,导致字符串 `"0"` 和数字 `0` 类型不匹配
**排查步骤**:
1. 打开浏览器开发者工具的 Network 标签页
2. 查看 API 响应中的 `code` 字段类型(字符串还是数字)
3. 检查业务代码中是否使用了 `result.code === xxx`
4. 将所有 `===` 改为 `==`
**解决方案**:
```typescript
// ❌ 错误写法
if (result.code === SUCCESS_CODE) { ... }
if (result.code === 0) { ... }
// ✅ 正确写法
if (result.code == SUCCESS_CODE) { ... }
if (result.code == 0) { ... }
```
**预防措施**:
- 遵守 [API 开发规范](#api-开发规范) 中的响应码处理规范
- 代码审查时检查所有响应码判断逻辑
### 路由懒加载失败
检查页面组件路径是否符合约定:`src/pages/{route.key}/index.tsx`
### 权限控制不生效
1. 检查 Redux Store 中的 `userInfo.permissions` 是否正确
2. 检查路由配置中的 `requiredPermissions` 格式
3. 检查 `utils/authentication.ts` 的权限判断逻辑
### 主题色不生效
1. 检查 `src/settings.json` 中的 `themeColor` 配置
2. 确保在应用启动时调用了 `initThemeColor()`(位于 `src/main.tsx:64-70`
3. 明暗模式切换时需重新调用 `initThemeColor()`
### Vite 构建失败
1. 检查 TypeScript 类型错误(虽然 noEmit: true但仍需类型正确
2. 检查 Less 语法错误
3. 检查路径别名配置是否正确
## 性能优化建议
- 大数据量表格使用虚拟滚动
- 图片较多时使用懒加载
- 搜索、滚动等高频操作添加防抖节流
- 使用 `rollup-plugin-visualizer` 分析打包产物
## 安全注意事项
- 敏感信息Token、用户数据应加密后再存储到 localStorage
- 所有用户输入需进行验证和清洗
- 生产环境强制使用 HTTPS
- 定期运行 `npm audit` 检查依赖漏洞
## 扩展开发指引
### 添加新页面
1. 创建页面组件: `src/pages/new-page/index.tsx`
2. 添加路由配置: `src/routes.ts`
3. 添加国际化: `src/locale/zh-CN.ts``en-US.ts`
4. 如需权限控制,配置 `requiredPermissions`
### 添加新组件
- 业务组件: `src/pages/{module}/components/`
- 公共组件: `src/components/`
- 组件应包含独立的样式文件(`.module.less`
### 添加新的 Redux 状态
`src/store/index.ts` 中:
1. 扩展 `GlobalState` 接口
2. 更新 `initialState`
3. 在 `reducer` 中添加新的 case
## 参考文档
- [Arco Design 官方文档](https://arco.design/)
- [Vite 官方文档](https://vitejs.dev/)
- [React Router v5 文档](https://v5.reactrouter.com/)

12585
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "arco-design-pro",
"name": "codernew-api-frontend",
"version": "1.0.0",
"description": "Arco Design Pro",
"scripts": {
@ -36,7 +36,8 @@
"react-redux": "^7.2.6",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"redux": "^4.1.2"
"redux": "^4.1.2",
"zustand": "^4.5.2"
},
"devDependencies": {
"@arco-design/webpack-plugin": "^1.6.0",

117
src/api/auth.ts Normal file
View File

@ -0,0 +1,117 @@
/**
* API
* @description
*/
import request from '@/utils/request';
import type { R } from '@/types/api';
import type {
LoginReq,
LoginResp,
RegisterReq,
UserInfoResp,
} from '@/types/auth';
// ==================== 认证 API 路径 ====================
const AUTH_API = {
/** 登录 */
LOGIN: '/auth/login',
/** 注册 */
REGISTER: '/auth/register',
/** 登出 */
LOGOUT: '/auth/logout',
/** 获取用户信息 */
USER_INFO: '/auth/user/info',
};
// ==================== 认证 API 函数 ====================
/**
*
* @param data -
* @returns
* @example
* ```typescript
* const result = await login({
* account: 'admin',
* password: 'admin123'
* });
*
* if (result.code === 0) {
* const { token, userId, username, nickname, role } = result.data;
* // 存储 token 和用户信息
* setToken(token);
* setUserInfo({ userId, username, nickname, role });
* }
* ```
*/
export function login(data: LoginReq): Promise<R<LoginResp>> {
return request.post<LoginResp>(AUTH_API.LOGIN, data);
}
/**
*
* @param data -
* @returns
* @example
* ```typescript
* const result = await register({
* username: 'testuser',
* password: 'Test@123',
* confirmPassword: 'Test@123',
* email: 'test@leocoder.cn',
* phone: '13800138001',
* nickname: '测试用户'
* });
*
* if (result.code === 0) {
* Message.success('注册成功,请登录');
* // 跳转到登录页
* history.push('/login');
* }
* ```
*/
export function register(data: RegisterReq): Promise<R<null>> {
return request.post<null>(AUTH_API.REGISTER, data);
}
/**
*
* @returns
* @example
* ```typescript
* const result = await logout();
*
* if (result.code === 0) {
* // 清除本地存储
* clearAuthInfo();
* // 跳转到登录页
* window.location.href = '/login';
* }
* ```
*/
export function logout(): Promise<R<null>> {
return request.post<null>(AUTH_API.LOGOUT);
}
/**
*
* @returns
* @example
* ```typescript
* const result = await getUserInfo();
*
* if (result.code === 0) {
* const userInfo = result.data;
* // 更新 Redux Store
* dispatch({
* type: 'update-userInfo',
* payload: { userInfo }
* });
* }
* ```
*/
export function getUserInfo(): Promise<R<UserInfoResp>> {
return request.get<UserInfoResp>(AUTH_API.USER_INFO);
}

274
src/api/user.ts Normal file
View File

@ -0,0 +1,274 @@
/**
* API
* @description CRUD
*/
import request from '@/utils/request';
import type { R, PageResp } from '@/types/api';
import type {
UserQuery,
UserResp,
UserReq,
BatchDeleteReq,
ResetPasswordReq,
UpdateProfileReq,
} from '@/types/user';
// ==================== 用户管理 API 路径 ====================
const USER_API = {
/** 分页查询用户 */
PAGE: '/system/user',
/** 获取用户详情 */
GET: (id: number) => `/system/user/${id}`,
/** 创建用户 */
CREATE: '/system/user',
/** 更新用户 */
UPDATE: (id: number) => `/system/user/${id}`,
/** 删除用户 */
DELETE: (id: number) => `/system/user/${id}`,
/** 批量删除用户DELETE /system/user携带 ids 数组) */
BATCH_DELETE: '/system/user',
/** 导出用户 */
EXPORT: '/system/user/export',
/** 重置用户密码 */
RESET_PASSWORD: '/system/user/password/reset',
/** 修改个人信息 */
UPDATE_PROFILE: '/system/user/profile',
/** 上传头像 */
UPLOAD_AVATAR: '/system/user/avatar',
};
// ==================== 用户管理 API 函数 ====================
/**
*
* @param params -
* @returns
* @example
* ```typescript
* const result = await getUserPage({
* current: 1,
* size: 10,
* username: 'admin',
* role: 'ADMIN',
* status: 1
* });
*
* if (result.code === 0) {
* const { list, total } = result.data;
* setTableData(list);
* setPagination({ ...pagination, total });
* }
* ```
*/
export function getUserPage(params: UserQuery): Promise<R<PageResp<UserResp>>> {
return request.get<PageResp<UserResp>>(USER_API.PAGE, { params });
}
/**
*
* @param id - ID
* @returns
* @example
* ```typescript
* const result = await getUserDetail(1);
*
* if (result.code === 0) {
* const userDetail = result.data;
* form.setFieldsValue(userDetail);
* }
* ```
*/
export function getUserDetail(id: number): Promise<R<UserResp>> {
return request.get<UserResp>(USER_API.GET(id));
}
/**
*
* @param data -
* @returns ID
* @example
* ```typescript
* const result = await createUser({
* username: 'testuser',
* role: 'USER',
* password: 'Test@123',
* email: 'test@leocoder.cn',
* phone: '13800138001',
* nickname: '测试用户',
* status: 1
* });
*
* if (result.code === 0) {
* Message.success('用户创建成功');
* fetchData(); // 刷新列表
* }
* ```
*/
export function createUser(data: UserReq): Promise<R<number>> {
return request.post<number>(USER_API.CREATE, data);
}
/**
*
* @param id - ID
* @param data -
* @returns
* @example
* ```typescript
* const result = await updateUser(1, {
* username: 'updateduser',
* role: 'USER',
* email: 'updated@leocoder.cn',
* phone: '13800138003',
* nickname: '更新后的昵称',
* status: 1
* });
*
* if (result.code === 0) {
* Message.success('用户更新成功');
* fetchData(); // 刷新列表
* }
* ```
*/
export function updateUser(id: number, data: UserReq): Promise<R<null>> {
return request.put<null>(USER_API.UPDATE(id), data);
}
/**
*
* @param id - ID
* @returns
* @example
* ```typescript
* await Modal.confirm({
* title: '确认删除',
* content: '确定要删除该用户吗?此操作不可恢复。'
* });
*
* const result = await deleteUser(1);
*
* if (result.code === 0) {
* Message.success('删除成功');
* fetchData(); // 刷新列表
* }
* ```
*/
export function deleteUser(id: number): Promise<R<null>> {
return request.delete<null>(USER_API.DELETE(id));
}
/**
*
* @param ids - ID列表
* @returns
* @example
* ```typescript
* await Modal.confirm({
* title: '批量删除确认',
* content: `确定要删除选中的 ${ids.length} 个用户吗?`
* });
*
* const result = await batchDeleteUsers([1, 2, 3, 4, 5]);
*
* if (result.code === 0) {
* Message.success('批量删除成功');
* setSelectedRowKeys([]); // 清空选择
* fetchData(); // 刷新列表
* }
* ```
*/
export function batchDeleteUsers(ids: number[]): Promise<R<null>> {
return request.delete<null>(USER_API.BATCH_DELETE, {
data: { ids },
});
}
/**
*
* @param data -
*/
export function resetUserPassword(data: ResetPasswordReq): Promise<R<null>> {
return request.put<null>(USER_API.RESET_PASSWORD, data);
}
/**
*
* @param params -
* @returns Blob
* @example
* ```typescript
* const response = await exportUsers({
* username: 'admin',
* role: 'ADMIN',
* status: 1
* });
*
* // 创建下载链接
* const url = window.URL.createObjectURL(new Blob([response.data]));
* const link = document.createElement('a');
* link.href = url;
* link.setAttribute('download', `用户列表_${new Date().getTime()}.xlsx`);
* document.body.appendChild(link);
* link.click();
* link.remove();
*
* Message.success('导出成功');
* ```
*/
export function exportUsers(params: UserQuery): Promise<any> {
return request.post(USER_API.EXPORT, params, {
responseType: 'blob', // 重要:指定响应类型为 blob
});
}
/**
*
* @param data -
* @returns
* @example
* ```typescript
* const result = await updateProfile({
* nickname: 'Leo哥',
* email: 'leo@example.com',
* phone: '13800138000'
* });
*
* if (result.code === 0) {
* Message.success('个人信息修改成功');
* // 重新获取用户信息
* fetchUserInfo();
* }
* ```
*/
export function updateProfile(data: UpdateProfileReq): Promise<R<null>> {
return request.put<null>(USER_API.UPDATE_PROFILE, data);
}
/**
*
* @param file -
* @returns URL
* @example
* ```typescript
* const formData = new FormData();
* formData.append('avatar', file);
*
* const result = await uploadAvatar(formData);
*
* if (result.code === 0) {
* const avatarUrl = result.data;
* Message.success('头像上传成功');
* // 更新个人信息中的头像字段
* await updateProfile({ avatar: avatarUrl });
* }
* ```
*/
export function uploadAvatar(formData: FormData): Promise<R<string>> {
return request.put<string>(USER_API.UPLOAD_AVATAR, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}

View File

@ -0,0 +1,28 @@
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.setting-list {
max-height: 320px;
overflow-y: auto;
}
.setting-item {
display: flex;
align-items: center;
padding: 6px 0;
gap: 6px;
}
.setting-drag {
color: #c9cdd4;
font-size: 16px;
}
.setting-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}

View File

@ -0,0 +1,98 @@
import React, { useMemo } from 'react';
import { Drawer, Checkbox, Button, Divider } from '@arco-design/web-react';
import { IconDragDotVertical } from '@arco-design/web-react/icon';
import styles from './index.module.less';
export interface ColumnItemConfig {
key: string;
title: string;
fixed?: 'left' | 'right';
disabled?: boolean;
}
export interface ColumnSettingProps {
visible: boolean;
columns: ColumnItemConfig[];
value: string[];
onChange: (keys: string[]) => void;
onClose: () => void;
}
const ColumnSetting: React.FC<ColumnSettingProps> = ({
visible,
columns,
value,
onChange,
onClose,
}) => {
const groups = useMemo(() => {
const selectMap = new Map(value.map((key) => [key, true]));
return columns.map((item) => ({
...item,
checked: !!selectMap.get(item.key),
}));
}, [columns, value]);
const handleToggle = (key: string, checked: boolean) => {
if (checked) {
onChange([...value, key]);
} else {
onChange(value.filter((item) => item !== key));
}
};
const isAllSelected = value.length === columns.length;
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(columns.map((item) => item.key));
}
};
return (
<Drawer
title="列显示设置"
visible={visible}
width={280}
footer={
<div className={styles['setting-footer']}>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={onClose}>
</Button>
</div>
}
onCancel={onClose}
maskClosable
unmountOnExit
>
<div className={styles['setting-header']}>
<Checkbox checked={isAllSelected} onChange={handleSelectAll}>
</Checkbox>
<Button size="mini" type="text" onClick={() => onChange([])}>
</Button>
</div>
<Divider style={{ margin: '8px 0' }} />
<div className={styles['setting-list']}>
{groups.map((item) => (
<div key={item.key} className={styles['setting-item']}>
<IconDragDotVertical className={styles['setting-drag']} />
<Checkbox
disabled={item.disabled}
checked={item.checked}
onChange={(checked) => handleToggle(item.key, checked)}
>
{item.title}
</Checkbox>
</div>
))}
</div>
</Drawer>
);
};
export default ColumnSetting;

View File

@ -1,7 +1,6 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext } from 'react';
import {
Tooltip,
Input,
Avatar,
Select,
Dropdown,
@ -10,9 +9,9 @@ import {
Message,
Button,
} from '@arco-design/web-react';
import cs from 'classnames';
import {
IconLanguage,
IconNotification,
IconSunFill,
IconMoonFill,
IconUser,
@ -20,33 +19,56 @@ import {
IconPoweroff,
IconLoading,
} from '@arco-design/web-react/icon';
import { useSelector, useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { GlobalState } from '@/store';
import { GlobalContext } from '@/context';
import useLocale from '@/utils/useLocale';
import Logo from '@/assets/logo.svg';
import MessageBox from '@/components/MessageBox';
import IconButton from './IconButton';
import Settings from '../Settings';
import styles from './style/index.module.less';
import defaultLocale from '@/locale';
import useStorage from '@/utils/useStorage';
import { generatePermission } from '@/routes';
import { useUserStore } from '@/store/userStore';
function Navbar({ show }: { show: boolean }) {
interface NavBreadcrumbNode {
key: string;
name: string;
icon?: React.ReactNode;
}
interface NavbarProps {
show: boolean;
breadcrumb?: NavBreadcrumbNode[];
onBreadcrumbClick?: (node: NavBreadcrumbNode, index: number) => void;
}
function Navbar({ show, breadcrumb = [], onBreadcrumbClick }: NavbarProps) {
const t = useLocale();
const history = useHistory();
const { userInfo, userLoading } = useSelector((state: GlobalState) => state);
const dispatch = useDispatch();
const [_, setUserStatus] = useStorage('userStatus');
const [role, setRole] = useStorage('userRole', 'admin');
// 使用 Zustand 获取用户信息
const { userInfo, userLoading, clearUserInfo } = useUserStore();
const [, setUserStatus] = useStorage('userStatus');
const { setLang, lang, theme, setTheme } = useContext(GlobalContext);
// Hooks 必须在所有条件语句之前调用
const [avatarError, setAvatarError] = React.useState(false);
// 使用 Zustand 后,数据已自动持久化,无需手动合并
const avatar = userInfo?.avatar;
const nickname =
userInfo?.nickname || userInfo?.name || userInfo?.username || '';
// 当 avatar 改变时,重置 avatarError
React.useEffect(() => {
setAvatarError(false);
}, [avatar]);
function logout() {
setUserStatus('logout');
clearUserInfo(); // 清除 Zustand 中的用户信息
window.location.href = '/login';
}
@ -58,18 +80,6 @@ function Navbar({ show }: { show: boolean }) {
}
}
useEffect(() => {
dispatch({
type: 'update-userInfo',
payload: {
userInfo: {
...userInfo,
permissions: generatePermission(role),
},
},
});
}, [role]);
if (!show) {
return (
<div className={styles['fixed-settings']}>
@ -101,16 +111,45 @@ function Navbar({ show }: { show: boolean }) {
<div className={styles.left}>
<div className={styles.logo}>
<Logo />
<div className={styles['logo-name']}>Arco Pro</div>
<div className={styles['logo-name']}>Codernew React Pro</div>
</div>
{breadcrumb.length > 0 && (
<div className={styles['navbar-breadcrumb']}>
{breadcrumb.map((node, index) => {
const label = t[node.name] || node.name;
return (
<React.Fragment key={`${node.key}-${index}`}>
<span
className={cs(styles['breadcrumb-node'], {
[styles['breadcrumb-clickable']]:
index !== breadcrumb.length - 1,
})}
onClick={() => {
if (
onBreadcrumbClick &&
index !== breadcrumb.length - 1
) {
onBreadcrumbClick(node, index);
}
}}
>
{node.icon && (
<span className={styles['breadcrumb-icon']}>
{node.icon}
</span>
)}
<span>{label}</span>
</span>
{index !== breadcrumb.length - 1 && (
<span className={styles['breadcrumb-divider']}>/</span>
)}
</React.Fragment>
);
})}
</div>
)}
</div>
<ul className={styles.right}>
<li>
<Input.Search
className={styles.round}
placeholder={t['navbar.search.placeholder']}
/>
</li>
<li>
<Select
triggerElement={<IconButton icon={<IconLanguage />} />}
@ -132,11 +171,6 @@ function Navbar({ show }: { show: boolean }) {
}}
/>
</li>
<li>
<MessageBox>
<IconButton icon={<IconNotification />} />
</MessageBox>
</li>
<li>
<Tooltip
content={
@ -152,19 +186,25 @@ function Navbar({ show }: { show: boolean }) {
</Tooltip>
</li>
<Settings />
{userInfo && (
<li>
<Dropdown droplist={droplist} position="br" disabled={userLoading}>
<Avatar size={32} style={{ cursor: 'pointer' }}>
{userLoading ? (
<IconLoading />
) : (
<img alt="avatar" src={userInfo.avatar} />
)}
</Avatar>
</Dropdown>
</li>
)}
<li>
<Dropdown droplist={droplist} position="br" disabled={userLoading}>
<Avatar size={32} style={{ cursor: 'pointer' }}>
{userLoading ? (
<IconLoading />
) : avatar && !avatarError ? (
<img
alt="avatar"
src={avatar}
onError={() => setAvatarError(true)}
/>
) : nickname ? (
nickname.charAt(0)
) : (
<IconUser />
)}
</Avatar>
</Dropdown>
</li>
</ul>
</div>
);

View File

@ -10,12 +10,15 @@
.left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.logo {
display: flex;
align-items: center;
width: 200px;
width: auto;
min-width: 200px;
padding-left: 20px;
box-sizing: border-box;
}
@ -26,6 +29,57 @@
font-size: 20px;
margin-left: 10px;
font-family: 'PingFang SC';
white-space: nowrap;
}
.navbar-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-left: 24px;
font-size: 15px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.breadcrumb-node {
color: var(--color-text-2);
transition: color 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
.breadcrumb-clickable {
cursor: pointer;
}
.breadcrumb-clickable:hover {
color: var(--color-text-1);
}
.breadcrumb-node:last-of-type {
color: var(--color-text-1);
font-weight: 600;
cursor: default;
}
.breadcrumb-divider {
color: var(--color-text-4);
margin: 0 2px;
white-space: nowrap;
}
.breadcrumb-icon {
color: var(--color-text-3);
display: inline-flex;
align-items: center;
font-size: 16px;
}
.right {

View File

@ -2,59 +2,52 @@
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 0;
padding: 12px 0;
// 覆盖 Arco Design 分页组件的样式
:global {
.arco-pagination {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
// 总数显示
.arco-pagination-total-text {
font-size: 14px;
font-size: 13px;
color: #4e5969;
margin-right: 16px;
margin-right: 8px;
}
// 页码按钮样式
.arco-pagination-item {
min-width: 36px;
height: 36px;
line-height: 36px;
padding: 0;
min-width: 30px;
height: 30px;
line-height: 30px;
padding: 0 8px;
margin: 0 4px;
border: none;
border-radius: 8px;
background-color: #f2f3f5;
color: #4e5969;
border: 1px solid #d0d5dd;
border-radius: 4px;
background-color: #fff;
color: #475467;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background-color: #e5e6eb;
border-color: #a0aec0;
color: #1d2129;
}
// 当前选中的页码
&.arco-pagination-item-active {
background: linear-gradient(
135deg,
rgb(var(--arcoblue-6)) 0%,
rgb(var(--arcoblue-7)) 100%
);
background: rgb(var(--arcoblue-6));
border-color: rgb(var(--arcoblue-6));
color: #fff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(var(--arcoblue-6), 0.3);
box-shadow: none;
&:hover {
background: linear-gradient(
135deg,
rgb(var(--arcoblue-7)) 0%,
rgb(var(--arcoblue-8)) 100%
);
box-shadow: 0 4px 12px rgba(var(--arcoblue-6), 0.4);
background: rgb(var(--arcoblue-5));
border-color: rgb(var(--arcoblue-5));
}
}
@ -73,9 +66,9 @@
// 省略号
.arco-pagination-item-jumper {
min-width: 36px;
height: 36px;
line-height: 36px;
min-width: 30px;
height: 30px;
line-height: 30px;
border: none;
background-color: transparent;
color: #86909c;
@ -84,19 +77,20 @@
// 上一页/下一页按钮
.arco-pagination-item-previous,
.arco-pagination-item-next {
min-width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background-color: #f2f3f5;
min-width: 30px;
height: 30px;
border: 1px solid #d0d5dd;
border-radius: 4px;
background-color: #fff;
transition: all 0.2s ease;
margin: 0 4px;
.arco-icon {
color: #4e5969;
color: #475467;
}
&:not(.arco-pagination-item-disabled):hover {
background-color: #e5e6eb;
border-color: #a0aec0;
.arco-icon {
color: #1d2129;
@ -115,36 +109,35 @@
// 每页显示数量选择器
.arco-pagination-size-changer {
margin-left: 16px;
margin-left: 8px;
.arco-select-view {
border-radius: 8px;
background-color: #f2f3f5;
border: none;
border-radius: 4px;
background-color: #fff;
border: 1px solid #d0d5dd;
&:hover {
background-color: #e5e6eb;
border-color: #a0aec0;
}
}
}
// 跳转输入框
.arco-pagination-jumper {
margin-left: 16px;
margin-left: 8px;
.arco-pagination-jumper-input {
.arco-input-inner-wrapper {
border-radius: 8px;
background-color: #f2f3f5;
border: none;
border-radius: 4px;
background-color: #fff;
border: 1px solid #d0d5dd;
&:hover {
background-color: #e5e6eb;
border-color: #a0aec0;
}
&:focus-within {
background-color: #fff;
border: 1px solid rgb(var(--arcoblue-6));
border-color: rgb(var(--arcoblue-6));
}
}
}

View File

@ -1,10 +1,11 @@
.tab-bar {
display: flex;
align-items: center;
height: 48px;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
padding: 0 16px;
min-height: 40px;
background-color: transparent;
border-bottom: none;
padding: 0 10px;
margin-bottom: 16px;
overflow-x: auto;
overflow-y: hidden;
@ -26,15 +27,18 @@
align-items: center;
gap: 8px;
height: 100%;
width: 100%;
}
.tab-item {
display: inline-flex;
align-items: center;
height: 32px;
height: 34px;
padding: 0 16px;
background-color: var(--color-fill-2);
border-radius: 16px;
background-color: #f4f6fb;
border-radius: 999px;
border: none;
box-shadow: none;
cursor: pointer;
user-select: none;
transition: all 0.3s cubic-bezier(0.34, 0.69, 0.1, 1);
@ -43,35 +47,26 @@
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-3);
transform: translateY(-1px);
color: var(--color-text-1);
}
&.tab-item-active {
background: linear-gradient(
135deg,
rgb(var(--primary-6)) 0%,
rgb(var(--primary-5)) 100%
);
color: #fff;
background: rgba(22, 93, 255, 10%);
color: rgb(var(--primary-6));
font-weight: 500;
box-shadow: 0 2px 8px rgba(var(--primary-6), 0.3);
border: none;
box-shadow: none;
&:hover {
background: linear-gradient(
135deg,
rgb(var(--primary-5)) 0%,
rgb(var(--primary-4)) 100%
);
box-shadow: 0 4px 12px rgba(var(--primary-6), 0.4);
background: rgba(22, 93, 255, 15%);
}
.tab-close {
color: rgba(255, 255, 255, 80%);
color: rgb(var(--primary-6));
&:hover {
color: #fff;
background-color: rgba(255, 255, 255, 20%);
background-color: rgba(22, 93, 255, 25%);
}
}
}
@ -92,13 +87,13 @@
height: 16px;
margin-left: 8px;
border-radius: 50%;
color: var(--color-text-3);
color: var(--color-text-2);
transition: all 0.2s;
font-size: 12px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-fill-3);
color: rgb(var(--primary-6));
background-color: rgba(22, 93, 255, 12%);
}
}
}

71
src/constants/index.ts Normal file
View File

@ -0,0 +1,71 @@
/**
*
* @description 使
*/
// ==================== API 响应码常量 ====================
/** 成功响应码 */
export const SUCCESS_CODE = 0;
/** 失败响应码 */
export const ERROR_CODE = 1;
/** 未认证(需要登录) */
export const UNAUTHORIZED_CODE = 401;
/** 无权限 */
export const FORBIDDEN_CODE = 403;
/** 资源不存在 */
export const NOT_FOUND_CODE = 404;
/** 服务器错误 */
export const SERVER_ERROR_CODE = 500;
// ==================== 本地存储 Key 常量 ====================
/** Token 存储 Key */
export const TOKEN_KEY = 'token';
/** 用户信息存储 Key */
export const USERINFO_KEY = 'userInfo';
/** 用户登录状态 Key */
export const USER_STATUS_KEY = 'userStatus';
/** 用户角色 Key */
export const USER_ROLE_KEY = 'userRole';
/** 语言设置 Key与现有项目保持一致 */
export const LANG_KEY = 'arco-lang';
/** 主题设置 Key与现有项目保持一致 */
export const THEME_KEY = 'arco-theme';
// ==================== 用户角色常量 ====================
/** 管理员角色 */
export const ROLE_ADMIN = 'ADMIN';
/** 普通用户角色 */
export const ROLE_USER = 'USER';
// ==================== 用户状态常量 ====================
/** 用户状态 - 禁用 */
export const USER_STATUS_DISABLED = 0;
/** 用户状态 - 启用 */
export const USER_STATUS_ENABLED = 1;
// ==================== 默认分页配置 ====================
/** 默认页码 */
export const DEFAULT_PAGE = 1;
/** 默认每页条数 */
export const DEFAULT_PAGE_SIZE = 10;
/** 每页条数选项 */
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];

View File

@ -1,11 +1,15 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Switch, Route, Redirect, useHistory } from 'react-router-dom';
import { Layout, Menu, Breadcrumb, Spin } from '@arco-design/web-react';
import { Layout, Menu, Spin, Button } from '@arco-design/web-react';
import cs from 'classnames';
import {
IconUser,
IconMenuFold,
IconMenuUnfold,
IconApps,
IconHome,
IconRefresh,
IconQuestionCircle,
} from '@arco-design/web-react/icon';
import { useSelector, useDispatch } from 'react-redux';
import qs from 'query-string';
@ -19,7 +23,9 @@ import useLocale from './utils/useLocale';
import getUrlParams from './utils/getUrlParams';
import lazyload from './utils/lazyload';
import { GlobalState } from './store';
import { useUserStore } from './store/userStore';
import { TabItem } from './types/tabs';
import { getUserInfo as fetchUserInfoAPI } from '@/api/auth';
import styles from './style/layout.module.less';
const MenuItem = Menu.Item;
@ -28,6 +34,21 @@ const SubMenu = Menu.SubMenu;
const Sider = Layout.Sider;
const Content = Layout.Content;
function findRouteByKey(key: string, routeList: IRoute[] = []): IRoute | null {
for (const route of routeList) {
if (route.key === key) {
return route;
}
if (route.children?.length) {
const child = findRouteByKey(key, route.children);
if (child) {
return child;
}
}
}
return null;
}
// 简单的404页面组件
function NotFound() {
return (
@ -54,7 +75,7 @@ function getIconFromKey(key) {
case 'user-management':
return <IconUser className={styles.icon} />;
default:
return <div className={styles['icon-empty']} />;
return <IconApps className={styles.icon} />;
}
}
@ -92,22 +113,65 @@ function PageLayout() {
const pathname = history.location.pathname;
const currentComponent = qs.parseUrl(pathname).url.slice(1);
const locale = useLocale();
const { settings, userLoading, userInfo } = useSelector(
(state: GlobalState) => state
);
// 从 Redux 获取 settings 和 tabs
const { settings } = useSelector((state: GlobalState) => state);
// 从 Zustand 获取用户信息
const { userInfo, userLoading, setUserInfo, updateUserInfo, setUserLoading } =
useUserStore();
const [routes, defaultRoute] = useRoute(userInfo?.permissions);
const defaultSelectedKeys = [currentComponent || defaultRoute];
const paths = (currentComponent || defaultRoute).split('/');
const defaultOpenKeys = paths.slice(0, paths.length - 1);
const [breadcrumb, setBreadCrumb] = useState([]);
const [breadcrumb, setBreadCrumb] = useState<CrumbNode[]>([]);
const [collapsed, setCollapsed] = useState<boolean>(false);
const [selectedKeys, setSelectedKeys] =
useState<string[]>(defaultSelectedKeys);
const [openKeys, setOpenKeys] = useState<string[]>(defaultOpenKeys);
const routeMap = useRef<Map<string, React.ReactNode[]>>(new Map());
// 使用 Zustand调用 API 获取最新用户信息
useEffect(() => {
const fetchLatestUserInfo = async () => {
// 只有有用户数据时才调用 API
if (!userInfo?.userId) {
return;
}
setUserLoading(true);
try {
const result = await fetchUserInfoAPI();
if (result.code == 0 && result.data) {
const data = result.data;
// 使用 updateUserInfo 部分更新,自动保留现有字段(如 avatar
updateUserInfo({
userId: data.id || data.userId,
username: data.username,
nickname: data.nickname || data.name,
name: data.nickname || data.name,
role: data.role,
avatar: data.avatar || userInfo.avatar, // 如果 API 返回空,保留现有
email: data.email,
phone: data.phone,
});
}
} catch (error) {
console.error('获取最新用户信息失败:', error);
// 失败时不需要做任何事Zustand 自动保留现有数据
} finally {
setUserLoading(false);
}
};
fetchLatestUserInfo();
}, []);
type CrumbNode = { key: string; name: string; icon?: React.ReactNode };
const routeMap = useRef<Map<string, CrumbNode[]>>(new Map());
const menuMap = useRef<
Map<string, { menuItem?: boolean; subMenu?: boolean }>
>(new Map());
@ -120,6 +184,34 @@ function PageLayout() {
const showFooter = settings.footer && urlParams.footer !== false;
const flattenRoutes = useMemo(() => getFlattenRoutes(routes) || [], [routes]);
const fallbackCrumb = useMemo<CrumbNode | null>(() => {
const matched = findRouteByKey(defaultRoute, routes);
if (matched) {
return {
key: matched.key,
name: matched.name,
icon: getIconFromKey(matched.key),
};
}
return null;
}, [defaultRoute, routes]);
const displayedBreadcrumb = useMemo<CrumbNode[]>(() => {
const nodes: CrumbNode[] = [
{
key: 'home',
name: 'breadcrumb.home',
icon: <IconHome />,
},
];
const currentNode = breadcrumb.length
? breadcrumb[breadcrumb.length - 1]
: fallbackCrumb;
if (currentNode) {
nodes.push(currentNode);
}
return nodes;
}, [breadcrumb, fallbackCrumb]);
function onClickMenuItem(key) {
const currentRoute = flattenRoutes.find((r) => r.key === key);
@ -136,13 +228,28 @@ function PageLayout() {
setCollapsed((collapsed) => !collapsed);
}
function handleBreadcrumbClick(node: CrumbNode, index: number) {
if (index === displayedBreadcrumb.length - 1) {
return;
}
if (node.key === 'home') {
history.push('/');
return;
}
history.push(`/${node.key}`);
}
const paddingLeft = showMenu ? { paddingLeft: menuWidth } : {};
const paddingTop = showNavbar ? { paddingTop: navbarHeight } : {};
const paddingStyle = { ...paddingLeft, ...paddingTop };
function renderRoutes(locale) {
routeMap.current.clear();
return function travel(_routes: IRoute[], level, parentNode = []) {
return function travel(
_routes: IRoute[],
level,
parentNode: CrumbNode[] = []
) {
return _routes.map((route) => {
const { breadcrumb = true, ignore } = route;
const iconDom = getIconFromKey(route.key);
@ -154,7 +261,16 @@ function PageLayout() {
routeMap.current.set(
`/${route.key}`,
breadcrumb ? [...parentNode, route.name] : []
breadcrumb
? [
...parentNode,
{
key: route.key,
name: route.name,
icon: getIconFromKey(route.key),
},
]
: []
);
const visibleChildren = (route.children || []).filter((child) => {
@ -162,7 +278,21 @@ function PageLayout() {
if (ignore || route.ignore) {
routeMap.current.set(
`/${child.key}`,
breadcrumb ? [...parentNode, route.name, child.name] : []
breadcrumb
? [
...parentNode,
{
key: route.key,
name: route.name,
icon: getIconFromKey(route.key),
},
{
key: child.key,
name: child.name,
icon: getIconFromKey(child.key),
},
]
: []
);
}
@ -176,7 +306,10 @@ function PageLayout() {
menuMap.current.set(route.key, { subMenu: true });
return (
<SubMenu key={route.key} title={titleDom}>
{travel(visibleChildren, level + 1, [...parentNode, route.name])}
{travel(visibleChildren, level + 1, [
...parentNode,
{ key: route.key, name: route.name },
])}
</SubMenu>
);
}
@ -238,7 +371,11 @@ function PageLayout() {
[styles['layout-navbar-hidden']]: !showNavbar,
})}
>
<Navbar show={showNavbar} />
<Navbar
show={showNavbar}
breadcrumb={displayedBreadcrumb}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</div>
{userLoading ? (
<Spin className={styles['spin']} />
@ -275,17 +412,6 @@ function PageLayout() {
)}
<Layout className={styles['layout-content']} style={paddingStyle}>
<div className={styles['layout-content-wrapper']}>
{!!breadcrumb.length && (
<div className={styles['layout-breadcrumb']}>
<Breadcrumb>
{breadcrumb.map((node, index) => (
<Breadcrumb.Item key={index}>
{typeof node === 'string' ? locale[node] || node : node}
</Breadcrumb.Item>
))}
</Breadcrumb>
</div>
)}
<TabBar />
<Content>
<Switch>

View File

@ -2,6 +2,7 @@ const i18n = {
'en-US': {
'menu.userManagement': 'User Management',
'menu.userInfo': 'User Info',
'breadcrumb.home': 'Home',
'navbar.userInfo': 'User Info',
'navbar.logout': 'Logout',
'settings.title': 'Settings',
@ -36,6 +37,7 @@ const i18n = {
'zh-CN': {
'menu.userManagement': '用户管理',
'menu.userInfo': '个人信息',
'breadcrumb.home': '首页',
'navbar.userInfo': '个人信息',
'navbar.logout': '退出登录',
'settings.title': '页面配置',

View File

@ -16,6 +16,7 @@ import checkLogin from './utils/checkLogin';
import changeTheme from './utils/changeTheme';
import initThemeColor from './utils/initThemeColor';
import useStorage from './utils/useStorage';
import { setUserInfo as cacheUserInfo } from './utils/storage';
import './mock';
const store = createStore(rootReducer);
@ -41,9 +42,22 @@ function Index() {
payload: { userLoading: true },
});
axios.get('/api/user/userInfo').then((res) => {
const serverUserInfo = res.data || {};
const mergedUserInfo = {
...serverUserInfo,
userId: serverUserInfo.userId || serverUserInfo.id,
name: serverUserInfo.nickname || serverUserInfo.name,
};
store.dispatch({
type: 'update-userInfo',
payload: { userInfo: res.data, userLoading: false },
payload: { userInfo: mergedUserInfo, userLoading: false },
});
cacheUserInfo({
userId: mergedUserInfo.userId,
username: mergedUserInfo.username,
nickname: mergedUserInfo.nickname || mergedUserInfo.name,
role: mergedUserInfo.role,
avatar: mergedUserInfo.avatar,
});
});
}

View File

@ -1,61 +1,72 @@
import {
Form,
Input,
Checkbox,
Link,
Button,
Space,
Message,
} from '@arco-design/web-react';
import { Form, Input, Button, Message } from '@arco-design/web-react';
import { FormInstance } from '@arco-design/web-react/es/Form';
import { IconLock, IconUser } from '@arco-design/web-react/icon';
import React, { useEffect, useRef, useState } from 'react';
import axios from 'axios';
import useStorage from '@/utils/useStorage';
import React, { useRef, useState } from 'react';
import { login as loginAPI } from '@/api/auth';
import { setToken } from '@/utils/storage';
import { useUserStore } from '@/store/userStore';
import { SUCCESS_CODE } from '@/constants';
import useLocale from '@/utils/useLocale';
import locale from './locale';
import styles from './style/index.module.less';
export default function LoginForm() {
const formRef = useRef<FormInstance>();
const [errorMessage, setErrorMessage] = useState('');
const [loading, setLoading] = useState(false);
const [loginParams, setLoginParams, removeLoginParams] =
useStorage('loginParams');
const t = useLocale(locale);
const [rememberPassword, setRememberPassword] = useState(!!loginParams);
// 使用 Zustand
const setUserInfo = useUserStore((state) => state.setUserInfo);
function afterLoginSuccess(params) {
// 记住密码
if (rememberPassword) {
setLoginParams(JSON.stringify(params));
} else {
removeLoginParams();
}
// 记录登录状态
function afterLoginSuccess(loginData) {
const { token, userId, username, nickname, role, avatar } = loginData;
// 存储 Token
setToken(token);
// 使用 Zustand 存储用户信息(自动持久化到 localStorage
setUserInfo({
userId,
username,
nickname,
role,
avatar,
name: nickname,
});
// 记录登录状态(兼容原有逻辑)
localStorage.setItem('userStatus', 'login');
// 跳转首页
window.location.href = '/';
// 显示成功提示
Message.success('登录成功');
// 延迟跳转,让用户看到提示
setTimeout(() => {
window.location.href = '/';
}, 500);
}
function login(params) {
setErrorMessage('');
async function login(params) {
setLoading(true);
axios
.post('/api/user/login', params)
.then((res) => {
const { status, msg } = res.data;
if (status === 'ok') {
afterLoginSuccess(params);
} else {
setErrorMessage(msg || t['login.form.login.errMsg']);
}
})
.finally(() => {
setLoading(false);
try {
// 调用后端登录 API
const result = await loginAPI({
account: params.userName,
password: params.password,
});
// 判断业务响应码(使用宽松相等,兼容字符串和数字)
if (result.code == SUCCESS_CODE) {
// 登录成功
afterLoginSuccess(result.data);
}
// 失败的情况由 Axios 拦截器自动显示 Message 提示
} catch (error: any) {
// 网络错误或其他异常Axios 拦截器已经显示了 Message 提示)
console.error('登录失败:', error);
} finally {
setLoading(false);
}
}
function onSubmitClick() {
@ -64,24 +75,10 @@ export default function LoginForm() {
});
}
// 读取 localStorage设置初始值
useEffect(() => {
const rememberPassword = !!loginParams;
setRememberPassword(rememberPassword);
if (formRef.current && rememberPassword) {
const parseParams = JSON.parse(loginParams);
formRef.current.setFieldsValue(parseParams);
}
}, [loginParams]);
return (
<div className={styles['login-form-wrapper']}>
<div className={styles['login-form-title']}></div>
{errorMessage && (
<div className={styles['login-form-error-msg']}>{errorMessage}</div>
)}
<Form
className={styles['login-form']}
layout="vertical"
@ -110,13 +107,6 @@ export default function LoginForm() {
/>
</Form.Item>
<div className={styles['login-form-actions']}>
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>
{t['login.form.rememberPassword']}
</Checkbox>
<Link>{t['login.form.forgetPassword']}</Link>
</div>
<Button
type="primary"
long

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Card,
Form,
@ -8,406 +8,423 @@ import {
Upload,
Space,
Typography,
Divider,
Grid,
Message,
Select,
DatePicker,
Radio,
Descriptions,
Tag,
Statistic,
Tabs,
} from '@arco-design/web-react';
import {
IconUser,
IconEmail,
IconPhone,
IconLocation,
IconEdit,
IconCamera,
IconCheck,
IconClose,
IconLock,
IconIdcard,
IconUserGroup,
} from '@arco-design/web-react/icon';
import { getUserInfo } from '@/api/auth';
import { updateProfile, uploadAvatar } from '@/api/user';
import { useUserStore } from '@/store/userStore';
import styles from './style/index.module.less';
const { Title, Paragraph, Text } = Typography;
const { Title, Paragraph } = Typography;
const { Row, Col } = Grid;
const FormItem = Form.Item;
const Option = Select.Option;
const RadioGroup = Radio.Group;
const TabPane = Tabs.TabPane;
function pad(num: number) {
return `${num}`.padStart(2, '0');
}
function formatDateTime(value?: string | number | Date) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds()
)}`;
}
/**
*
*/
function UserInfo() {
const [form] = Form.useForm();
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
// 模拟用户数据
const [userInfo, setUserInfo] = useState({
avatar:
'https://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
name: '张三',
username: 'zhangsan',
email: 'zhangsan@example.com',
phone: '138****8888',
gender: 'male',
birthday: '1990-01-01',
department: '技术部',
position: '高级工程师',
location: '北京市朝阳区',
introduction:
'热爱技术,专注于前端开发领域,有丰富的 React 和 TypeScript 开发经验。',
joinDate: '2020-01-15',
employeeId: 'EMP001',
});
// 使用 Zustand
const { userInfo, updateUserInfo } = useUserStore();
const [localUserInfo, setLocalUserInfo] = useState<any>(userInfo);
const [pageLoading, setPageLoading] = useState(!userInfo?.userId);
const [profileLoading, setProfileLoading] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
// 统计数据
const statistics = {
projectCount: 28,
taskCompleted: 156,
contribution: 892,
teamSize: 12,
};
useEffect(() => {
if (userInfo) {
profileForm.setFieldsValue({
nickname: userInfo.nickname,
email: userInfo.email,
phone: userInfo.phone,
});
}
}, [profileForm, userInfo]);
// 初始化表单值
React.useEffect(() => {
form.setFieldsValue(userInfo);
}, [userInfo, form]);
// 保存用户信息
const handleSave = async () => {
const fetchUserInfo = async () => {
try {
const values = await form.validate();
setLoading(true);
// 模拟保存
await new Promise((resolve) => setTimeout(resolve, 1000));
setUserInfo({ ...userInfo, ...values });
setIsEditing(false);
Message.success('保存成功');
} catch (error) {
console.error('表单验证失败:', error);
} finally {
setLoading(false);
}
};
// 取消编辑
const handleCancel = () => {
form.setFieldsValue(userInfo);
setIsEditing(false);
};
// 上传头像
const handleAvatarChange = (fileList) => {
if (fileList && fileList.length > 0) {
const file = fileList[0];
if (file.originFile) {
const reader = new FileReader();
reader.onload = (e) => {
setUserInfo({ ...userInfo, avatar: e.target?.result as string });
Message.success('头像上传成功');
};
reader.readAsDataURL(file.originFile);
setPageLoading(true);
const result = await getUserInfo();
if (result.code == 0 && result.data) {
const data = result.data;
// 使用 updateUserInfo 部分更新
updateUserInfo({
userId: data.id || data.userId,
username: data.username,
nickname: data.nickname || data.name,
name: data.nickname || data.name,
role: data.role,
avatar: data.avatar || userInfo?.avatar, // 保留现有 avatar
email: data.email,
phone: data.phone,
});
setLocalUserInfo(data);
profileForm.setFieldsValue({
nickname: data.nickname || data.name,
email: data.email,
phone: data.phone,
});
}
} catch (error) {
console.error('获取用户信息失败:', error);
Message.error('获取用户信息失败');
} finally {
setPageLoading(false);
}
};
useEffect(() => {
fetchUserInfo();
}, []);
const handleProfileSubmit = async () => {
try {
const values = await profileForm.validate();
setProfileLoading(true);
const result = await updateProfile({
nickname: values.nickname,
email: values.email,
phone: values.phone,
});
if (result.code == 0) {
Message.success('个人信息修改成功');
fetchUserInfo();
}
} catch (error) {
console.error('保存失败:', error);
} finally {
setProfileLoading(false);
}
};
const handleProfileReset = () => {
profileForm.setFieldsValue({
nickname: userInfo?.nickname,
email: userInfo?.email,
phone: userInfo?.phone,
});
};
const handlePasswordSubmit = async () => {
try {
const values = await passwordForm.validate();
if (values.newPassword !== values.confirmPassword) {
Message.error('两次输入的新密码不一致');
return;
}
setPasswordLoading(true);
Message.success('已提交密码修改请求(示例)');
passwordForm.resetFields();
} catch (error) {
console.error('修改密码失败:', error);
} finally {
setPasswordLoading(false);
}
};
const handleAvatarChange = async (fileList) => {
if (!fileList?.length) {
return;
}
const file = fileList[0];
if (!file.originFile) {
return;
}
if (avatarUploading) {
return;
}
try {
setAvatarUploading(true);
const formData = new FormData();
formData.append('avatar', file.originFile);
const result = await uploadAvatar(formData);
if (result.code == 0) {
Message.success('头像上传成功');
// 更新 Zustand 中的 avatar
updateUserInfo({ avatar: result.data.avatar });
fetchUserInfo();
}
} catch (error) {
console.error('头像上传失败:', error);
Message.error('头像上传失败');
} finally {
setAvatarUploading(false);
}
};
const displayAvatar = userInfo?.avatar || '';
const roleLabel =
userInfo?.role === 'ADMIN' || userInfo?.role === 0
? '超级管理员'
: '普通用户';
const registerTime = formatDateTime(localUserInfo?.createTime);
const infoItems = [
{
key: 'username',
label: '用户名',
icon: <IconUser />,
value: userInfo?.username || '-',
},
{
key: 'phone',
label: '手机号',
icon: <IconPhone />,
value: userInfo?.phone || '-',
},
{
key: 'email',
label: '邮箱',
icon: <IconEmail />,
value: userInfo?.email || '-',
},
{
key: 'role',
label: '角色',
icon: <IconUserGroup />,
value: roleLabel,
},
{
key: 'registerTime',
label: '注册时间',
icon: <IconIdcard />,
value: registerTime,
},
];
if (pageLoading || !userInfo?.userId) {
return (
<div className={styles.container}>
<Card loading />
</div>
);
}
return (
<div className={styles.container}>
{/* 顶部信息卡片 */}
<Card className={styles['profile-card']}>
<Row gutter={24}>
{/* 左侧头像区域 */}
<Col span={6}>
<div className={styles['avatar-section']}>
<div className={styles['avatar-wrapper']}>
<Avatar size={120} className={styles.avatar}>
<img src={userInfo.avatar} alt="avatar" />
</Avatar>
{isEditing && (
<Upload
accept="image/*"
showUploadList={false}
onChange={handleAvatarChange}
action="/"
<Row gutter={24} wrap>
<Col xs={24} xl={10}>
<Card className={styles['profile-card']} bordered={false}>
<div className={styles['profile-header']}>
<Upload
accept="image/*"
showUploadList={false}
onChange={handleAvatarChange}
action="/"
>
<div className={styles['avatar-shell']}>
<Avatar size={112} className={styles.avatar}>
{displayAvatar ? (
<img src={displayAvatar} alt="avatar" />
) : (
<IconUser style={{ fontSize: 44 }} />
)}
</Avatar>
<div className={styles['avatar-tip']}>
<IconCamera />
</div>
</div>
</Upload>
<div className={styles['profile-meta']}>
<Title heading={6}>
{userInfo?.nickname || userInfo?.username}
</Title>
<Paragraph type="secondary">
{userInfo?.username || roleLabel}
</Paragraph>
<div className={styles['status-pill']}>
<span className={styles['status-dot']} />
{localUserInfo?.status === 1 ? '状态 · 正常' : '状态 · 禁用'}
</div>
</div>
</div>
<div className={styles['info-list']}>
{infoItems.map((item) => (
<div className={styles['info-row']} key={item.key}>
<div className={styles['info-label']}>
<span className={styles['info-icon']}>{item.icon}</span>
{item.label}
</div>
<div className={styles['info-value']}>{item.value}</div>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} xl={14}>
<Card className={styles['form-panel']} bordered={false}>
<div className={styles['panel-header']}>
<div>
<Title heading={6}></Title>
<Paragraph type="secondary">
</Paragraph>
</div>
</div>
<Tabs defaultActiveTab="profile" type="line">
<TabPane key="profile" title="基本资料">
<Form
form={profileForm}
layout="vertical"
initialValues={{
nickname: userInfo?.nickname,
email: userInfo?.email,
phone: userInfo?.phone,
}}
>
<Row gutter={16}>
<Col span={12}>
<FormItem label="用户名">
<Input value={userInfo?.username} disabled />
</FormItem>
</Col>
<Col span={12}>
<FormItem label="角色">
<Input value={roleLabel} disabled />
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="昵称"
field="nickname"
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input placeholder="请输入昵称" prefix={<IconUser />} />
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="邮箱"
field="email"
rules={[
{ type: 'email', message: '请输入正确的邮箱格式' },
]}
>
<Input
placeholder="请输入邮箱"
prefix={<IconEmail />}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="手机号"
field="phone"
rules={[
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
},
]}
>
<Input
placeholder="请输入手机号"
prefix={<IconPhone />}
/>
</FormItem>
</Col>
</Row>
<FormItem>
<Space>
<Button
type="primary"
loading={profileLoading}
onClick={handleProfileSubmit}
>
</Button>
<Button onClick={handleProfileReset}></Button>
</Space>
</FormItem>
</Form>
</TabPane>
<TabPane key="password" title="修改密码">
<Form form={passwordForm} layout="vertical">
<FormItem
label="当前密码"
field="currentPassword"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<div className={styles['avatar-upload']}>
<IconCamera style={{ fontSize: 20 }} />
<div></div>
</div>
</Upload>
)}
</div>
<Title heading={5} style={{ marginTop: 16, textAlign: 'center' }}>
{userInfo.name}
</Title>
<Paragraph
style={{ textAlign: 'center', color: 'var(--color-text-3)' }}
>
{userInfo.position}
</Paragraph>
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Tag color="blue">{userInfo.department}</Tag>
</div>
</div>
</Col>
{/* 右侧统计信息 */}
<Col span={18}>
<div className={styles['stats-section']}>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="参与项目"
value={statistics.projectCount}
suffix="个"
/>
</Col>
<Col span={6}>
<Statistic
title="完成任务"
value={statistics.taskCompleted}
suffix="个"
/>
</Col>
<Col span={6}>
<Statistic
title="代码贡献"
value={statistics.contribution}
suffix="次"
/>
</Col>
<Col span={6}>
<Statistic
title="团队人数"
value={statistics.teamSize}
suffix="人"
/>
</Col>
</Row>
<Divider />
<Descriptions
column={2}
data={[
{
label: '工号',
value: userInfo.employeeId,
},
{
label: '入职时间',
value: userInfo.joinDate,
},
{
label: '邮箱',
value: userInfo.email,
},
{
label: '联系电话',
value: userInfo.phone,
},
]}
/>
</div>
</Col>
</Row>
</Card>
{/* 详细信息卡片 */}
<Card
style={{ marginTop: 20 }}
title="个人信息"
extra={
!isEditing ? (
<Button
type="primary"
icon={<IconEdit />}
onClick={() => setIsEditing(true)}
>
</Button>
) : (
<Space>
<Button icon={<IconClose />} onClick={handleCancel}>
</Button>
<Button
type="primary"
icon={<IconCheck />}
loading={loading}
onClick={handleSave}
>
</Button>
</Space>
)
}
>
<Form
form={form}
layout="vertical"
disabled={!isEditing}
autoComplete="off"
>
<Row gutter={24}>
<Col span={12}>
<FormItem
label="姓名"
field="name"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input
placeholder="请输入姓名"
prefix={<IconUser />}
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="用户名"
field="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
placeholder="请输入用户名"
prefix={<IconUser />}
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="邮箱"
field="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入正确的邮箱格式' },
]}
>
<Input
placeholder="请输入邮箱"
prefix={<IconEmail />}
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem
label="手机号"
field="phone"
rules={[{ required: true, message: '请输入手机号' }]}
>
<Input
placeholder="请输入手机号"
prefix={<IconPhone />}
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem label="性别" field="gender">
<RadioGroup disabled={!isEditing}>
<Radio value="male"></Radio>
<Radio value="female"></Radio>
</RadioGroup>
</FormItem>
</Col>
<Col span={12}>
<FormItem label="生日" field="birthday">
<DatePicker
style={{ width: '100%' }}
placeholder="请选择生日"
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem label="部门" field="department">
<Select placeholder="请选择部门" disabled={!isEditing}>
<Option value="技术部"></Option>
<Option value="产品部"></Option>
<Option value="设计部"></Option>
<Option value="运营部"></Option>
<Option value="市场部"></Option>
</Select>
</FormItem>
</Col>
<Col span={12}>
<FormItem label="职位" field="position">
<Input placeholder="请输入职位" disabled={!isEditing} />
</FormItem>
</Col>
<Col span={24}>
<FormItem label="所在地" field="location">
<Input
placeholder="请输入所在地"
prefix={<IconLocation />}
disabled={!isEditing}
/>
</FormItem>
</Col>
<Col span={24}>
<FormItem label="个人简介" field="introduction">
<Input.TextArea
placeholder="请输入个人简介"
rows={4}
disabled={!isEditing}
maxLength={200}
showWordLimit
/>
</FormItem>
</Col>
</Row>
</Form>
</Card>
{/* 账号安全卡片 */}
<Card style={{ marginTop: 20 }} title="账号安全">
<Descriptions
column={1}
data={[
{
label: '登录密码',
value: (
<Space>
<Text></Text>
<Button type="text" size="small">
</Button>
</Space>
),
},
{
label: '密保手机',
value: (
<Space>
<Text>{userInfo.phone}</Text>
<Button type="text" size="small">
</Button>
</Space>
),
},
{
label: '密保邮箱',
value: (
<Space>
<Text>{userInfo.email}</Text>
<Button type="text" size="small">
</Button>
</Space>
),
},
]}
/>
</Card>
<Input.Password
placeholder="请输入当前密码"
prefix={<IconLock />}
/>
</FormItem>
<FormItem
label="新密码"
field="newPassword"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password
placeholder="请输入新密码"
prefix={<IconLock />}
/>
</FormItem>
<FormItem
label="确认新密码"
field="confirmPassword"
rules={[{ required: true, message: '请再次输入新密码' }]}
>
<Input.Password
placeholder="请再次输入新密码"
prefix={<IconLock />}
/>
</FormItem>
<FormItem>
<Space>
<Button
type="primary"
loading={passwordLoading}
onClick={handlePasswordSubmit}
>
</Button>
<Button onClick={() => passwordForm.resetFields()}>
</Button>
</Space>
</FormItem>
</Form>
</TabPane>
</Tabs>
</Card>
</Col>
</Row>
</div>
);
}

View File

@ -1,81 +1,189 @@
.container {
padding: 20px;
background: var(--color-bg-2);
padding: 24px 0 32px;
background: transparent;
min-height: calc(100vh - 60px);
box-sizing: border-box;
}
.profile-card {
background: #fff;
border-radius: 12px;
border: 1px solid var(--color-border-2);
box-shadow: none;
:global(.arco-card-body) {
padding: 32px;
padding: 24px;
}
}
.avatar-section {
.profile-header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 20px;
}
.avatar-wrapper {
.avatar-shell {
position: relative;
display: inline-block;
border-radius: 50%;
padding: 4px;
background: linear-gradient(135deg, #f0f2f5, #fff);
cursor: pointer;
transition: box-shadow 0.2s ease;
.avatar {
border: 4px solid var(--color-border-2);
box-shadow: 0 2px 12px rgba(0, 0, 0, 10%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-upload {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 50%);
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
font-size: 12px;
&:hover {
opacity: 1;
}
&:hover {
box-shadow: 0 6px 18px rgba(15, 18, 36, 14%);
}
}
.stats-section {
:global(.arco-statistic-title) {
color: var(--color-text-3);
font-size: 14px;
}
.avatar {
background: #f7f8fa;
:global(.arco-statistic-value) {
color: var(--color-text-1);
font-weight: 600;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-tip {
position: absolute;
right: 10px;
bottom: 10px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 60%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.avatar-shell:hover .avatar-tip {
opacity: 1;
}
.profile-meta {
margin-top: 16px;
:global(.arco-typography-title) {
margin-bottom: 4px;
}
:global(.arco-typography) {
margin: 0;
}
}
.status-pill {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(0, 194, 111, 12%);
color: #00a36a;
font-size: 13px;
font-weight: 600;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00c26f;
}
.info-list {
border-top: 1px solid var(--color-border-2);
margin-top: 12px;
padding-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid var(--color-border-1);
&:last-child {
border-bottom: none;
}
}
.info-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-text-3);
font-size: 13px;
}
.info-icon {
color: rgb(var(--primary-6));
display: inline-flex;
align-items: center;
justify-content: center;
}
.info-value {
font-weight: 600;
color: var(--color-text-1);
max-width: 60%;
text-align: right;
word-break: break-all;
}
.form-panel {
background: #fff;
border-radius: 12px;
border: 1px solid var(--color-border-2);
box-shadow: none;
:global(.arco-card-body) {
padding: 28px;
}
:global(.arco-tabs-nav) {
margin-bottom: 16px;
}
}
.panel-header {
margin-bottom: 12px;
:global(.arco-typography) {
margin: 0;
}
:global(.arco-typography + .arco-typography) {
margin-top: 4px;
}
}
@media (max-width: 1024px) {
.container {
padding: 24px;
}
}
// 响应式布局
@media (max-width: 768px) {
.container {
padding: 12px;
padding: 16px;
}
.profile-card {
:global(.arco-card-body) {
padding: 16px;
}
.info-value {
max-width: 100%;
text-align: left;
}
}

View File

@ -0,0 +1,120 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Form, Input, Message } from '@arco-design/web-react';
import type { UserResp } from '@/types/user';
import { resetUserPassword } from '@/api/user';
import useLocale from '@/utils/useLocale';
import locale from './locale';
interface ResetPasswordModalProps {
visible: boolean;
user?: (UserResp & { name?: string }) | null;
onCancel: () => void;
onSuccess?: () => void;
}
const PASSWORD_PATTERN =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,20}$/;
const ResetPasswordModal: React.FC<ResetPasswordModalProps> = ({
visible,
user,
onCancel,
onSuccess,
}) => {
const t = useLocale(locale);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!visible) {
form.resetFields();
setSubmitting(false);
}
}, [visible, form]);
const title = useMemo(() => {
const displayName = user?.name || user?.nickname || user?.username || '';
if (displayName) {
return `${t['userManagement.resetPassword.title']} - ${displayName}`;
}
return t['userManagement.resetPassword.title'];
}, [t, user]);
const handleOk = async () => {
if (!user?.id) {
return;
}
try {
const values = await form.validate();
setSubmitting(true);
await resetUserPassword({
userId: user.id,
newPassword: values.newPassword,
});
Message.success(t['userManagement.resetPassword.success']);
if (onSuccess) {
onSuccess();
}
onCancel();
} catch (error: any) {
if (!error?.errorFields) {
console.error('重置密码失败:', error);
Message.error('重置密码失败,请稍后再试');
}
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={title}
visible={visible}
onOk={handleOk}
confirmLoading={submitting}
onCancel={onCancel}
unmountOnExit
maskClosable={false}
>
<Form form={form} layout="vertical">
<Form.Item
field="newPassword"
label={t['userManagement.resetPassword.newPassword']}
rules={[
{ required: true, message: '请输入新密码' },
{
match: PASSWORD_PATTERN,
message: '密码需8-20位且包含大小写字母与数字',
},
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
field="confirmPassword"
label={t['userManagement.resetPassword.confirmPassword']}
rules={[
{ required: true, message: '请再次输入新密码' },
{
validator: (value, callback) => {
if (!value) {
callback('请再次输入新密码');
return;
}
if (value !== form.getFieldValue('newPassword')) {
callback('两次输入的密码不一致');
return;
}
callback();
},
},
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
</Form>
</Modal>
);
};
export default ResetPasswordModal;

View File

@ -0,0 +1,269 @@
/**
*
*
*/
import React, { useEffect } from 'react';
import { Modal, Form, Input, Select, Message } from '@arco-design/web-react';
import { createUser, updateUser } from '@/api/user';
import { SUCCESS_CODE } from '@/constants';
import type { UserResp } from '@/types/user';
import styles from './style/modal.module.less';
const { useForm } = Form;
interface UserFormModalProps {
/** 是否显示弹窗 */
visible: boolean;
/** 关闭弹窗回调 */
onCancel: () => void;
/** 提交成功回调 */
onSuccess: () => void;
/** 编辑模式传入用户数据新建模式undefined */
editData?: UserResp;
}
export default function UserFormModal({
visible,
onCancel,
onSuccess,
editData,
}: UserFormModalProps) {
const [form] = useForm();
const isEdit = !!editData; // 是否为编辑模式
// 角色选项
const roleOptions = [
{ label: '管理员', value: 'ADMIN' },
{ label: '普通用户', value: 'USER' },
];
// 状态选项
const statusOptions = [
{ label: '禁用', value: 0 },
{ label: '启用', value: 1 },
];
// 编辑模式:回填表单数据
useEffect(() => {
if (visible && editData) {
form.setFieldsValue({
username: editData.username,
role: editData.role,
email: editData.email,
phone: editData.phone,
nickname: editData.nickname,
status: editData.status,
});
}
}, [visible, editData, form]);
useEffect(() => {
if (!visible) {
return;
}
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleCancel();
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [visible]);
// 提交表单
const handleSubmit = async () => {
try {
// 表单验证
const values = await form.validate();
// 如果是编辑模式且没有填写密码,则删除密码字段
if (isEdit && !values.password) {
delete values.password;
delete values.confirmPassword;
}
if (isEdit) {
// 编辑用户
const result = await updateUser(editData.id, {
username: values.username,
role: values.role,
password: values.password,
email: values.email,
phone: values.phone,
nickname: values.nickname,
status: values.status,
});
if (result.code == SUCCESS_CODE) {
Message.success('用户更新成功');
onSuccess();
handleCancel();
}
} else {
// 新建用户
const result = await createUser({
username: values.username,
role: values.role,
password: values.password,
email: values.email,
phone: values.phone,
nickname: values.nickname,
status: values.status ?? 1, // 默认启用
});
if (result.code == SUCCESS_CODE) {
Message.success('用户创建成功');
onSuccess();
handleCancel();
}
}
} catch (error: any) {
console.error('提交失败:', error);
// 表单验证失败或 API 调用失败API 错误已经在拦截器中提示)
}
};
// 取消/关闭
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title={isEdit ? '编辑用户' : '新建用户'}
visible={visible}
onOk={handleSubmit}
onCancel={handleCancel}
autoFocus={false}
focusLock={true}
maskClosable={false}
style={{ width: 720 }}
>
<Form
form={form}
layout="vertical"
autoComplete="off"
className={styles['form-grid']}
>
<Form.Item
label="用户名"
field="username"
rules={[
{ required: true, message: '请输入用户名' },
{
pattern: /^[a-zA-Z0-9_]{4,20}$/,
message: '用户名必须是4-20位字母、数字或下划线',
},
]}
className={styles['full-line']}
>
<Input
placeholder="请输入用户名4-20位字母、数字或下划线"
disabled={isEdit} // 编辑模式不允许修改用户名
/>
</Form.Item>
<Form.Item
label="昵称"
field="nickname"
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item
label="角色"
field="role"
rules={[{ required: true, message: '请选择角色' }]}
initialValue="USER"
>
<Select placeholder="请选择角色" options={roleOptions} />
</Form.Item>
{!isEdit && (
<>
<Form.Item
label="密码"
field="password"
rules={[
{
required: true,
message: '请输入密码',
},
{
pattern:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,20}$/,
message: '密码必须是8-20位且包含大小写字母和数字',
},
]}
>
<Input.Password placeholder="请输入密码8-20位包含大小写字母和数字" />
</Form.Item>
<Form.Item
label="确认密码"
field="confirmPassword"
dependencies={['password']}
rules={[
{
required: true,
message: '请确认密码',
},
{
validator: (value, callback) => {
const password = form.getFieldValue('password');
if (password && value !== password) {
callback('两次密码输入不一致');
} else {
callback();
}
},
},
]}
>
<Input.Password placeholder="请再次输入密码" />
</Form.Item>
</>
)}
<Form.Item
label="邮箱"
field="email"
rules={[
{
type: 'email',
message: '请输入正确的邮箱格式',
},
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item
label="手机号"
field="phone"
rules={[
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号格式',
},
]}
>
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item
label="状态"
field="status"
rules={[{ required: true, message: '请选择状态' }]}
initialValue={1}
>
<Select placeholder="请选择状态" options={statusOptions} />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -1,43 +1,79 @@
import React from 'react';
import { Button, Typography, Badge, Space } from '@arco-design/web-react';
import { IconEye, IconEdit, IconDelete } from '@arco-design/web-react/icon';
import dayjs from 'dayjs';
import {
Button,
Typography,
Space,
Avatar,
Tag,
Dropdown,
Menu,
} from '@arco-design/web-react';
import {
IconEye,
IconEdit,
IconDelete,
IconMoreVertical,
IconLock,
} from '@arco-design/web-react/icon';
import styles from './style/index.module.less';
const { Text } = Typography;
export const RoleType = ['管理员', '普通用户', '访客'];
export const Status = ['禁用', '启用'];
const getInitial = (name?: string, username?: string) => {
const base = name || username || '-';
return base.substring(0, 1).toUpperCase();
};
export function getColumns(
t: any,
callback: (record: Record<string, any>, type: string) => Promise<void>
callback: (record: Record<string, any>, type: string) => Promise<void>,
pagination?: { current?: number; pageSize?: number }
) {
const current = pagination?.current || 1;
const pageSize = pagination?.pageSize || 10;
return [
{
title: t['userManagement.columns.id'],
dataIndex: 'id',
width: 100,
render: (value) => <Text copyable>{value}</Text>,
title: '#',
dataIndex: 'index',
width: 70,
render: (_, __, index) => (current - 1) * pageSize + index + 1,
},
{
title: t['userManagement.columns.name'],
dataIndex: 'name',
width: 100,
},
{
title: t['userManagement.columns.username'],
dataIndex: 'username',
width: 120,
width: 180,
render: (value, record) => (
<div className={styles['user-cell']}>
<Avatar
size={32}
style={{ backgroundColor: '#165DFF1A', color: '#165DFF' }}
>
{record.avatar ? (
<img src={record.avatar} alt="avatar" />
) : (
getInitial(value, record.username)
)}
</Avatar>
<div>
<div className={styles['user-name']}>{value || '-'}</div>
<div className={styles['user-sub']}>{record.username}</div>
</div>
</div>
),
},
{
title: t['userManagement.columns.email'],
dataIndex: 'email',
width: 200,
width: 220,
ellipsis: true,
},
{
title: t['userManagement.columns.phone'],
dataIndex: 'phone',
width: 130,
width: 140,
},
{
title: t['userManagement.columns.role'],
@ -50,20 +86,24 @@ export function getColumns(
2: { text: t['userManagement.role.guest'], color: 'gray' },
};
const role = roleMap[value] || roleMap[1];
return <Badge color={role.color} text={role.text} />;
return (
<Tag size="small" className={styles['role-tag']} color={role.color}>
{role.text}
</Tag>
);
},
},
{
title: t['userManagement.columns.department'],
dataIndex: 'department',
width: 120,
},
{
title: t['userManagement.columns.createdTime'],
dataIndex: 'createdTime',
width: 180,
render: (x) => dayjs().subtract(x, 'days').format('YYYY-MM-DD HH:mm:ss'),
sorter: (a, b) => b.createdTime - a.createdTime,
render: (x) => x || '-',
sorter: (a, b) => {
// 按时间字符串排序
const timeA = new Date(a.createdTime || 0).getTime();
const timeB = new Date(b.createdTime || 0).getTime();
return timeB - timeA;
},
},
{
title: t['userManagement.columns.status'],
@ -71,45 +111,83 @@ export function getColumns(
width: 100,
render: (x) => {
if (x === 0) {
return <Badge status="error" text={Status[x]}></Badge>;
return (
<div className={styles['status-cell']}>
<span
className={`${styles['status-dot']} ${styles['is-offline']}`}
/>
{Status[x]}
</div>
);
}
return <Badge status="success" text={Status[x]}></Badge>;
return (
<div className={styles['status-cell']}>
<span
className={`${styles['status-dot']} ${styles['is-online']}`}
/>
{Status[x]}
</div>
);
},
},
{
title: t['userManagement.columns.operations'],
dataIndex: 'operations',
width: 240,
width: 200,
fixed: 'right' as const,
render: (_, record) => (
<Space>
<Button
type="text"
size="small"
icon={<IconEye />}
onClick={() => callback(record, 'view')}
>
{t['userManagement.columns.operations.view']}
</Button>
<Button
type="text"
size="small"
icon={<IconEdit />}
onClick={() => callback(record, 'edit')}
>
{t['userManagement.columns.operations.edit']}
</Button>
<Button
type="text"
size="small"
status="danger"
icon={<IconDelete />}
onClick={() => callback(record, 'delete')}
>
{t['userManagement.columns.operations.delete']}
</Button>
</Space>
),
render: (_, record) => {
// 超级管理员不显示操作按钮
if (record.role === 0) {
return <span style={{ color: '#86909c' }}>-</span>;
}
return (
<Space size={8} align="center">
<Button
type="text"
size="mini"
onClick={() => callback(record, 'view')}
>
<IconEye /> {t['userManagement.columns.operations.view']}
</Button>
<Button
type="text"
size="mini"
onClick={() => callback(record, 'edit')}
>
<IconEdit /> {t['userManagement.columns.operations.edit']}
</Button>
<Dropdown
trigger="click"
droplist={
<Menu>
<Menu.Item onClick={() => callback(record, 'resetPassword')}>
<Space size={6} align="center">
<IconLock />
<span>
{t['userManagement.columns.operations.resetPassword']}
</span>
</Space>
</Menu.Item>
<Menu.Item
onClick={() => callback(record, 'delete')}
style={{ color: '#f53f3f' }}
>
<Space size={6} align="center">
<IconDelete />
<span>
{t['userManagement.columns.operations.delete']}
</span>
</Space>
</Menu.Item>
</Menu>
}
>
<Button type="text" size="mini" icon={<IconMoreVertical />} />
</Dropdown>
</Space>
);
},
},
];
}

View File

@ -1,28 +1,33 @@
import React, { useContext } from 'react';
import dayjs from 'dayjs';
import React from 'react';
import {
Form,
Input,
Select,
DatePicker,
Button,
Grid,
Space,
} from '@arco-design/web-react';
import { GlobalContext } from '@/context';
import {
IconRefresh,
IconSearch,
IconDown,
IconUp,
} from '@arco-design/web-react/icon';
import locale from './locale';
import useLocale from '@/utils/useLocale';
import { IconRefresh, IconSearch } from '@arco-design/web-react/icon';
import { RoleType, Status } from './constants';
import { Status } from './constants';
import styles from './style/index.module.less';
const { Row, Col } = Grid;
const { useForm } = Form;
function SearchForm(props: {
interface SearchFormProps {
onSearch: (values: Record<string, any>) => void;
}) {
const { lang } = useContext(GlobalContext);
collapsed: boolean;
onToggle: () => void;
}
function SearchForm(props: SearchFormProps) {
const t = useLocale(locale);
const [form] = useForm();
@ -36,35 +41,11 @@ function SearchForm(props: {
props.onSearch({});
};
const colSpan = lang === 'zh-CN' ? 8 : 12;
return (
<div className={styles['search-form-wrapper']}>
<Form
form={form}
className={styles['search-form']}
labelAlign="left"
labelCol={{ span: 5 }}
wrapperCol={{ span: 19 }}
>
<Row gutter={24}>
<Col span={colSpan}>
<Form.Item label={t['userManagement.columns.id']} field="id">
<Input
placeholder={t['userManagement.form.id.placeholder']}
allowClear
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item label={t['userManagement.columns.name']} field="name">
<Input
allowClear
placeholder={t['userManagement.form.name.placeholder']}
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form form={form} className={styles['search-form']} layout="vertical">
<Row gutter={16}>
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
<Form.Item
label={t['userManagement.columns.username']}
field="username"
@ -75,7 +56,37 @@ function SearchForm(props: {
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
<Form.Item label={t['userManagement.columns.name']} field="name">
<Input
allowClear
placeholder={t['userManagement.form.name.placeholder']}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
<Form.Item
label={t['userManagement.columns.status']}
field="status"
>
<Select
allowClear
placeholder={t['userManagement.form.all.placeholder']}
options={Status.map((item, index) => ({
label: item,
value: index,
}))}
/>
</Form.Item>
</Col>
<Col
xs={12}
sm={8}
md={6}
lg={6}
xl={6}
className={props.collapsed ? styles['field-hidden'] : ''}
>
<Form.Item label={t['userManagement.columns.email']} field="email">
<Input
allowClear
@ -83,7 +94,14 @@ function SearchForm(props: {
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Col
xs={12}
sm={8}
md={6}
lg={6}
xl={6}
className={props.collapsed ? styles['field-hidden'] : ''}
>
<Form.Item label={t['userManagement.columns.phone']} field="phone">
<Input
allowClear
@ -91,68 +109,27 @@ function SearchForm(props: {
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item label={t['userManagement.columns.role']} field="role">
<Select
placeholder={t['userManagement.form.all.placeholder']}
options={RoleType.map((item, index) => ({
label: item,
value: index,
}))}
mode="multiple"
allowClear
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item
label={t['userManagement.columns.department']}
field="department"
>
<Input
allowClear
placeholder={t['userManagement.form.department.placeholder']}
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item
label={t['userManagement.columns.status']}
field="status"
>
<Select
placeholder={t['userManagement.form.all.placeholder']}
options={Status.map((item, index) => ({
label: item,
value: index,
}))}
mode="multiple"
allowClear
/>
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item
label={t['userManagement.columns.createdTime']}
field="createdTime"
>
<DatePicker.RangePicker
allowClear
style={{ width: '100%' }}
disabledDate={(date) => dayjs(date).isAfter(dayjs())}
/>
</Form.Item>
</Col>
</Row>
<div className={styles['search-actions']}>
<Space size={12}>
<Button type="primary" icon={<IconSearch />} onClick={handleSubmit}>
{t['userManagement.form.search']}
</Button>
<Button icon={<IconRefresh />} onClick={handleReset}>
{t['userManagement.form.reset']}
</Button>
<Button
type="text"
icon={props.collapsed ? <IconDown /> : <IconUp />}
onClick={props.onToggle}
>
{props.collapsed
? t['userManagement.form.expand']
: t['userManagement.form.collapse']}
</Button>
</Space>
</div>
</Form>
<div className={styles['right-button']}>
<Button type="primary" icon={<IconSearch />} onClick={handleSubmit}>
{t['userManagement.form.search']}
</Button>
<Button icon={<IconRefresh />} onClick={handleReset}>
{t['userManagement.form.reset']}
</Button>
</div>
</div>
);
}

View File

@ -1,48 +1,162 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Table,
Card,
PaginationProps,
Button,
Space,
Typography,
Message,
Modal,
} from '@arco-design/web-react';
import PermissionWrapper from '@/components/PermissionWrapper';
import Pagination from '@/components/Pagination';
import ColumnSetting from '@/components/ColumnSetting';
import { IconPlus, IconDelete, IconRefresh } from '@arco-design/web-react/icon';
import {
IconDownload,
IconPlus,
IconDelete,
} from '@arco-design/web-react/icon';
import axios from 'axios';
getUserPage,
deleteUser,
batchDeleteUsers,
exportUsers,
getUserDetail,
} from '@/api/user';
import { SUCCESS_CODE } from '@/constants';
import type { UserResp } from '@/types/user';
import useLocale from '@/utils/useLocale';
import SearchForm from './form';
import UserFormModal from './UserFormModal';
import ResetPasswordModal from './ResetPasswordModal';
import locale from './locale';
import styles from './style/index.module.less';
import './mock';
import { getColumns } from './constants';
const { Title } = Typography;
const { Title, Paragraph } = Typography;
const pad = (num: number) => `${num}`.padStart(2, '0');
const formatDateTime = (value?: string | number | Date | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds()
)}`;
};
function UserManagement() {
const t = useLocale(locale);
const [filterCollapsed, setFilterCollapsed] = useState(true);
const COLUMN_STORAGE_KEY = 'user-management-visible-columns';
const [resetModalVisible, setResetModalVisible] = useState(false);
const [resetUserData, setResetUserData] = useState<
(UserResp & { name?: string }) | undefined
>(undefined);
const tableCallback = async (record, type) => {
console.log(record, type);
// 简单的操作提示
if (type === 'view') {
Message.info(`查看用户: ${record.name}`);
// 查看用户详情
try {
const result = await getUserDetail(record.id);
if (result.code == SUCCESS_CODE) {
const detail = result.data;
const infoList = [
{ label: '用户ID', value: detail.id || '-' },
{ label: '角色', value: detail.role || '-' },
{ label: '邮箱', value: detail.email || '-' },
{ label: '手机号', value: detail.phone || '-' },
{
label: '状态',
value: detail.status === 1 ? '正常' : '禁用',
},
{
label: '创建时间',
value: formatDateTime(detail.createTime),
},
];
Modal.info({
title: null,
icon: null,
okText: '确定',
style: { width: 720 },
content: (
<div className={styles['detail-modal']}>
<div className={styles['detail-header']}>
<div className={styles['detail-avatar']}>
{(detail.nickname || detail.username || 'U')
.slice(0, 1)
.toUpperCase()}
</div>
<div className={styles['detail-names']}>
<div className={styles['detail-name']}>
{detail.nickname || detail.username || '-'}
</div>
<div className={styles['detail-sub']}>
{detail.username || '-'}
</div>
</div>
<div
className={`${styles['detail-status']} ${
detail.status === 1
? styles['status-active']
: styles['status-disabled']
}`}
>
{detail.status === 1 ? '状态 · 正常' : '状态 · 禁用'}
</div>
</div>
<div className={styles['detail-grid']}>
{infoList.map((item) => (
<div className={styles['detail-item']} key={item.label}>
<div className={styles['item-label']}>{item.label}</div>
<div className={styles['item-value']}>{item.value}</div>
</div>
))}
</div>
</div>
),
});
}
} catch (error) {
console.error('获取用户详情失败:', error);
}
} else if (type === 'edit') {
Message.info(`编辑用户: ${record.name}`);
// 编辑用户
try {
const result = await getUserDetail(record.id);
if (result.code == SUCCESS_CODE) {
setEditUserData(result.data);
setModalVisible(true);
}
} catch (error) {
console.error('获取用户详情失败:', error);
}
} else if (type === 'resetPassword') {
setResetUserData(record);
setResetModalVisible(true);
} else if (type === 'delete') {
Message.warning(`删除用户: ${record.name}`);
// 删除用户
Modal.confirm({
title: '确认删除',
content: `确定要删除用户 "${record.name}" 吗?此操作不可恢复。`,
onOk: async () => {
try {
const result = await deleteUser(record.id);
if (result.code == SUCCESS_CODE) {
Message.success('删除成功');
fetchData(); // 刷新列表
}
} catch (error) {
console.error('删除失败:', error);
}
},
});
} else if (type === 'enable' || type === 'disable') {
Message.info('状态切换功能将与后端联调后生效');
}
};
const columns = useMemo(() => getColumns(t, tableCallback), [t]);
const [data, setData] = useState([]);
const [pagination, setPagination] = useState<PaginationProps>({
sizeCanChange: true,
@ -54,35 +168,103 @@ function UserManagement() {
const [loading, setLoading] = useState(true);
const [formParams, setFormParams] = useState({});
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [visibleColumns, setVisibleColumns] = useState<string[]>([]);
const [columnSettingVisible, setColumnSettingVisible] = useState(false);
// 弹窗状态
const [modalVisible, setModalVisible] = useState(false);
const [editUserData, setEditUserData] = useState<UserResp | undefined>(
undefined
);
const baseColumns = useMemo(
() => getColumns(t, tableCallback, pagination),
[t, pagination]
);
const columns = useMemo(() => {
if (!visibleColumns.length) {
return baseColumns;
}
const visibleSet = new Set(visibleColumns);
return baseColumns.filter((col) => visibleSet.has(col.dataIndex));
}, [baseColumns, visibleColumns]);
useEffect(() => {
fetchData();
}, [pagination.current, pagination.pageSize, JSON.stringify(formParams)]);
function fetchData() {
useEffect(() => {
if (!visibleColumns.length && baseColumns.length) {
const cached = localStorage.getItem(COLUMN_STORAGE_KEY);
if (cached) {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed) && parsed.length) {
setVisibleColumns(parsed);
return;
}
}
setVisibleColumns(baseColumns.map((col) => col.dataIndex));
}
}, [baseColumns, visibleColumns.length]);
useEffect(() => {
if (visibleColumns.length) {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
async function fetchData() {
const { current, pageSize } = pagination;
setLoading(true);
axios
.get('/api/user-management', {
params: {
page: current,
pageSize,
...formParams,
},
})
.then((res) => {
setData(res.data.list);
try {
// 调用后端 API
const result = await getUserPage({
current: current,
size: pageSize,
...formParams,
});
if (result.code == SUCCESS_CODE) {
// 后端返回的字段是 list不是 records
const { list, total } = result.data;
// 字段映射:后端字段 -> 前端字段
const mappedData = (list || []).map((item) => ({
id: item.id,
name: item.nickname,
username: item.username,
email: item.email,
phone: item.phone,
role: mapRoleToNumber(item.role),
createdTime: item.createTime,
status: item.status,
avatar: item.avatar,
}));
setData(mappedData);
setPagination({
...pagination,
current,
pageSize,
total: res.data.total,
total,
});
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}
} catch (error) {
console.error('查询用户列表失败:', error);
} finally {
setLoading(false);
}
}
// 角色映射:字符串 -> 数字
function mapRoleToNumber(role: string): number {
const roleMap = {
ADMIN: 0,
USER: 1,
};
return roleMap[role] ?? 1; // 默认为普通用户
}
function onChangeTable(current: number, pageSize: number) {
@ -94,77 +276,199 @@ function UserManagement() {
}
function handleSearch(params) {
// 处理搜索参数:类型转换、字段映射、移除空值
const processedParams: any = {};
// 用户名:直接传递
if (params.username) {
processedParams.username = params.username;
}
// 昵称:前端 name -> 后端 nickname
if (params.name) {
processedParams.nickname = params.name;
}
// 邮箱:直接传递
if (params.email) {
processedParams.email = params.email;
}
// 手机号:直接传递
if (params.phone) {
processedParams.phone = params.phone;
}
// 状态:直接传递(现在是单选,不是多选)
if (params.status !== undefined && params.status !== null) {
processedParams.status = params.status;
}
setPagination({ ...pagination, current: 1 });
setFormParams(params);
setFormParams(processedParams);
}
function handleImport() {
Message.info('导入功能待接入后端接口');
}
function handleToolbarRefresh() {
fetchData();
}
// 新建用户
function handleAdd() {
Message.info(t['userManagement.operations.add']);
setEditUserData(undefined); // 清空编辑数据
setModalVisible(true);
}
// 批量删除
function handleBatchDelete() {
if (selectedRowKeys.length === 0) {
Message.warning('请选择要删除的用户');
return;
}
Message.warning(`批量删除 ${selectedRowKeys.length} 个用户`);
Modal.confirm({
title: '批量删除确认',
content: `确定要删除选中的 ${selectedRowKeys.length} 个用户吗?此操作不可恢复。`,
onOk: async () => {
try {
const result = await batchDeleteUsers(selectedRowKeys as number[]);
if (result.code == SUCCESS_CODE) {
Message.success('批量删除成功');
setSelectedRowKeys([]); // 清空选择
fetchData(); // 刷新列表
}
} catch (error) {
console.error('批量删除失败:', error);
}
},
});
}
function handleExport() {
Message.info(t['userManagement.operations.export']);
// 导出用户
async function handleExport() {
try {
const response: any = await exportUsers(formParams);
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `用户列表_${new Date().getTime()}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
Message.success('导出成功');
} catch (error) {
console.error('导出失败:', error);
Message.error('导出失败');
}
}
// 弹窗关闭
function handleModalCancel() {
setModalVisible(false);
setEditUserData(undefined);
}
// 弹窗提交成功
function handleModalSuccess() {
fetchData(); // 刷新列表
}
function handleResetModalClose() {
setResetModalVisible(false);
setResetUserData(undefined);
}
return (
<Card>
<Title heading={6}>{t['menu.list.userManagement']}</Title>
<SearchForm onSearch={handleSearch} />
<PermissionWrapper
requiredPermissions={[
{ resource: 'menu.list.userManagement', actions: ['write'] },
]}
>
<div className={styles['button-group']}>
<Space>
<Button type="primary" icon={<IconPlus />} onClick={handleAdd}>
{t['userManagement.operations.add']}
</Button>
<div className={styles['page']}>
<div className={styles['filters-card']}>
<div className={styles['filters-header']}>
<div>
<Title heading={5}>{t['menu.list.userManagement']}</Title>
<Paragraph type="secondary" className={styles['page-desc']}>
{t['userManagement.page.desc']}
</Paragraph>
</div>
</div>
<SearchForm
onSearch={handleSearch}
collapsed={filterCollapsed}
onToggle={() => setFilterCollapsed(!filterCollapsed)}
/>
</div>
<div className={styles['table-card']}>
<div className={styles['table-head']}>
<Button type="primary" icon={<IconPlus />} onClick={handleAdd}>
{t['userManagement.operations.add']}
</Button>
<Space size={10}>
<Button icon={<IconDelete />} onClick={handleBatchDelete}>
{t['userManagement.operations.batchDelete']}
</Button>
</Space>
<Space>
<Button icon={<IconDownload />} onClick={handleExport}>
{t['userManagement.operations.export']}
<Button onClick={() => setColumnSettingVisible(true)}>
</Button>
<Button icon={<IconRefresh />} onClick={handleToolbarRefresh}>
</Button>
</Space>
</div>
</PermissionWrapper>
<Table
rowKey="id"
loading={loading}
pagination={false}
columns={columns}
data={data}
rowSelection={{
type: 'checkbox',
selectedRowKeys,
onChange: (selectedRowKeys) => {
setSelectedRowKeys(selectedRowKeys);
},
}}
scroll={{ x: 1640 }}
<Table
rowKey="id"
loading={loading}
pagination={false}
columns={columns}
data={data}
rowSelection={{
type: 'checkbox',
selectedRowKeys,
onChange: (keys) => {
setSelectedRowKeys(keys);
},
}}
scroll={{ x: 1640 }}
/>
<Pagination
total={pagination.total}
current={pagination.current}
pageSize={pagination.pageSize}
sizeCanChange={pagination.sizeCanChange}
onChange={onChangeTable}
onPageSizeChange={(pageSize) => {
setPagination({ ...pagination, current: 1, pageSize });
}}
/>
</div>
<UserFormModal
visible={modalVisible}
onCancel={handleModalCancel}
onSuccess={handleModalSuccess}
editData={editUserData}
/>
<Pagination
total={pagination.total}
current={pagination.current}
pageSize={pagination.pageSize}
sizeCanChange={pagination.sizeCanChange}
onChange={onChangeTable}
onPageSizeChange={(pageSize) => {
setPagination({ ...pagination, current: 1, pageSize });
}}
<ColumnSetting
visible={columnSettingVisible}
columns={baseColumns.map((col) => ({
key: col.dataIndex,
title: col.title,
}))}
value={visibleColumns}
onChange={setVisibleColumns}
onClose={() => setColumnSettingVisible(false)}
/>
</Card>
<ResetPasswordModal
visible={resetModalVisible}
user={resetUserData}
onCancel={handleResetModalClose}
/>
</div>
);
}

View File

@ -3,6 +3,8 @@ const i18n = {
'menu.list.userManagement': 'User Management',
'userManagement.form.search': 'Search',
'userManagement.form.reset': 'Reset',
'userManagement.form.collapse': 'Collapse filters',
'userManagement.form.expand': 'Expand filters',
'userManagement.columns.id': 'User ID',
'userManagement.columns.name': 'Name',
'userManagement.columns.username': 'Username',
@ -15,12 +17,15 @@ const i18n = {
'userManagement.columns.operations': 'Operations',
'userManagement.columns.operations.view': 'View',
'userManagement.columns.operations.edit': 'Edit',
'userManagement.columns.operations.resetPassword': 'Reset Password',
'userManagement.columns.operations.delete': 'Delete',
'userManagement.columns.operations.enable': 'Enable',
'userManagement.columns.operations.disable': 'Disable',
'userManagement.operations.add': 'Add User',
'userManagement.operations.batchDelete': 'Batch Delete',
'userManagement.operations.export': 'Export',
'userManagement.operations.import': 'Import',
'userManagement.operations.batch': 'Batch Actions',
'userManagement.role.admin': 'Administrator',
'userManagement.role.user': 'User',
'userManagement.role.guest': 'Guest',
@ -33,11 +38,22 @@ const i18n = {
'userManagement.form.phone.placeholder': 'Please enter phone',
'userManagement.form.department.placeholder': 'Please enter department',
'userManagement.form.all.placeholder': 'All',
'userManagement.page.desc':
'Manage platform users, permissions and lifecycle.',
'userManagement.stat.total': 'Total Users',
'userManagement.stat.enabled': 'Enabled',
'userManagement.stat.disabled': 'Disabled',
'userManagement.resetPassword.title': 'Reset Password',
'userManagement.resetPassword.newPassword': 'New Password',
'userManagement.resetPassword.confirmPassword': 'Confirm Password',
'userManagement.resetPassword.success': 'Password reset successfully',
},
'zh-CN': {
'menu.list.userManagement': '用户管理',
'userManagement.form.search': '查询',
'userManagement.form.reset': '重置',
'userManagement.form.collapse': '收起筛选',
'userManagement.form.expand': '展开筛选',
'userManagement.columns.id': '用户ID',
'userManagement.columns.name': '姓名',
'userManagement.columns.username': '用户名',
@ -50,12 +66,15 @@ const i18n = {
'userManagement.columns.operations': '操作',
'userManagement.columns.operations.view': '查看',
'userManagement.columns.operations.edit': '编辑',
'userManagement.columns.operations.resetPassword': '重置密码',
'userManagement.columns.operations.delete': '删除',
'userManagement.columns.operations.enable': '启用',
'userManagement.columns.operations.disable': '禁用',
'userManagement.operations.add': '新建用户',
'userManagement.operations.batchDelete': '批量删除',
'userManagement.operations.export': '导出',
'userManagement.operations.import': '导入',
'userManagement.operations.batch': '批量操作',
'userManagement.role.admin': '管理员',
'userManagement.role.user': '普通用户',
'userManagement.role.guest': '访客',
@ -68,6 +87,14 @@ const i18n = {
'userManagement.form.phone.placeholder': '请输入手机号',
'userManagement.form.department.placeholder': '请输入部门',
'userManagement.form.all.placeholder': '全部',
'userManagement.page.desc': '统一管理系统用户、权限及生命周期。',
'userManagement.stat.total': '用户总数',
'userManagement.stat.enabled': '启用',
'userManagement.stat.disabled': '禁用',
'userManagement.resetPassword.title': '重置用户密码',
'userManagement.resetPassword.newPassword': '新密码',
'userManagement.resetPassword.confirmPassword': '确认新密码',
'userManagement.resetPassword.success': '密码重置成功',
},
};

View File

@ -1,41 +1,230 @@
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
.page {
background: #f5f6f8;
padding: 0 4px 16px;
min-height: calc(100vh - 120px);
}
.operations {
.filters-card {
background: #fff;
border-radius: 4px;
border: 1px solid #e5e6eb;
padding: 18px 20px;
box-shadow: none;
margin-bottom: 12px;
}
.filters-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 16px;
}
.filters-actions {
display: flex;
gap: 12px;
align-items: center;
}
.page-desc {
margin-top: 4px;
}
.search-form-wrapper {
display: flex;
border-bottom: 1px solid var(--color-border-1);
margin-bottom: 20px;
.right-button {
display: flex;
flex-direction: column;
justify-content: space-between;
padding-left: 20px;
margin-bottom: 20px;
border-left: 1px solid var(--color-border-2);
box-sizing: border-box;
}
}
.button-group {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
width: 100%;
}
.search-form {
padding-right: 20px;
:global(.arco-form-item-label) {
color: #4e5969;
font-weight: 500;
}
:global(.arco-form-label-item-left) {
> label {
white-space: nowrap;
}
:global(.arco-form-item) {
margin-bottom: 12px;
}
}
.field-hidden {
display: none;
}
.search-actions {
display: flex;
justify-content: flex-end;
border-top: 1px dashed #e5e6eb;
padding-top: 12px;
}
.table-card {
background: #fff;
border-radius: 4px;
border: 1px solid #e5e6eb;
padding: 20px;
box-shadow: none;
}
.table-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
:global(.arco-btn-text) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 500;
color: #1d2129;
}
.user-sub {
font-size: 12px;
color: #86909c;
}
.role-tag {
border-radius: 999px;
padding: 2px 10px;
border: 1px solid rgba(22, 93, 255, 20%);
}
.status-cell {
display: flex;
align-items: center;
gap: 6px;
color: #1d2129;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.is-online {
background: #1dc779;
}
.is-offline {
background: #f77234;
}
.detail-modal {
min-width: 620px;
max-width: 680px;
padding: 8px 4px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 8px 20px;
border-bottom: 1px solid #e5e6eb;
}
.detail-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: #165dff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
box-shadow: 0 6px 16px rgba(22, 93, 255, 30%);
}
.detail-names {
flex: 1;
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.detail-sub {
font-size: 13px;
color: #86909c;
margin-top: 2px;
}
.detail-status {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-active {
background: rgba(29, 199, 121, 16%);
color: #00a36a;
}
.status-disabled {
background: rgba(255, 125, 76, 16%);
color: #f86d39;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 20px;
padding: 20px 8px 4px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 8px;
border-bottom: 1px dashed #e5e6eb;
}
.detail-item:nth-last-child(-n + 2) {
border-bottom: none;
}
.item-label {
font-size: 12px;
color: #98a2b3;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.item-value {
font-size: 14px;
color: #1d2129;
font-weight: 600;
word-break: break-all;
}
@media (max-width: 1200px) {
.filters-header {
flex-direction: column;
align-items: flex-start;
}
.filters-actions {
flex-wrap: wrap;
justify-content: flex-start;
}
}

View File

@ -0,0 +1,13 @@
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 20px;
:global(.arco-form-item) {
margin-bottom: 0;
}
}
.full-line {
grid-column: span 2;
}

View File

@ -3,6 +3,6 @@
"navbar": true,
"menu": true,
"footer": false,
"themeColor": "#4834D4",
"themeColor": "#005cb8",
"menuWidth": 220
}

View File

@ -4,12 +4,19 @@ import { TabItem, TabsState } from '@/types/tabs';
export interface GlobalState {
settings?: typeof defaultSettings;
userInfo?: {
// 后端 API 返回的字段(新增)
userId?: number;
username?: string;
nickname?: string;
role?: string;
// 原有字段(保持兼容)
name?: string;
avatar?: string;
job?: string;
organization?: string;
location?: string;
email?: string;
phone?: string;
permissions: Record<string, string[]>;
};
userLoading?: boolean;

91
src/store/userStore.ts Normal file
View File

@ -0,0 +1,91 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { generatePermission } from '@/routes';
export interface UserInfo {
userId?: number;
username?: string;
nickname?: string;
role?: string;
avatar?: string;
name?: string;
email?: string;
phone?: string;
permissions: Record<string, string[]>;
}
interface UserState {
userInfo: UserInfo;
userLoading: boolean;
// Actions
setUserInfo: (info: Partial<UserInfo>) => void;
updateUserInfo: (partial: Partial<UserInfo>) => void;
setUserLoading: (loading: boolean) => void;
clearUserInfo: () => void;
}
const initialUserInfo: UserInfo = {
permissions: {},
};
export const useUserStore = create<UserState>()(
persist(
(set, get) => ({
userInfo: initialUserInfo,
userLoading: false,
// 完全替换用户信息
setUserInfo: (info) => {
const role = info.role || 'user';
set({
userInfo: {
...info,
permissions: info.permissions || generatePermission(role),
},
});
},
// 部分更新用户信息(自动保留其他字段)
updateUserInfo: (partial) => {
const current = get().userInfo;
const newInfo = { ...current, ...partial };
// 如果更新了 role重新生成权限
if (partial.role && partial.role !== current.role) {
newInfo.permissions = generatePermission(partial.role);
}
set({ userInfo: newInfo });
},
setUserLoading: (loading) => set({ userLoading: loading }),
clearUserInfo: () => set({ userInfo: initialUserInfo }),
}),
{
name: 'user-storage', // localStorage key
partialize: (state) => ({
userInfo: {
userId: state.userInfo.userId,
username: state.userInfo.username,
nickname: state.userInfo.nickname,
role: state.userInfo.role,
avatar: state.userInfo.avatar,
name: state.userInfo.name,
email: state.userInfo.email,
phone: state.userInfo.phone,
// 不持久化 permissions每次从 role 生成
},
}),
onRehydrateStorage: () => (state) => {
// 重新加载时,根据 role 重新生成 permissions
if (state && state.userInfo && state.userInfo.role) {
state.userInfo.permissions = generatePermission(state.userInfo.role);
}
},
}
)
);
export default useUserStore;

View File

@ -122,11 +122,7 @@
}
.layout-content-wrapper {
padding: 16px 20px 0;
}
.layout-breadcrumb {
margin-bottom: 16px;
padding: 16px 24px 24px;
}
.spin {

111
src/types/api.ts Normal file
View File

@ -0,0 +1,111 @@
/**
* API
* @description API
*/
// ==================== 通用响应类型 ====================
/**
*
* @template T -
*/
export interface R<T = any> {
/** 响应码:'0'-成功,'1'-失败,其他-业务错误码(兼容后端返回字符串或数字) */
code: string | number;
/** 响应消息 */
msg: string;
/** 响应数据 */
data: T;
/** 时间戳 */
timestamp?: number;
}
/**
*
* @template T -
*/
export interface PageResp<T = any> {
/** 数据列表 */
list: T[];
/** 总记录数 */
total: number;
/** 每页条数 */
size?: number;
/** 当前页码 */
current?: number;
/** 总页数 */
pages?: number;
}
// ==================== 分页查询参数 ====================
/**
*
*/
export interface PageQuery {
/** 当前页码(从 1 开始)*/
current?: number;
/** 每页条数 */
size?: number;
/** 排序字段,格式:字段名,排序方向createTime,desc */
sort?: string;
}
// ==================== HTTP 错误响应 ====================
/**
* HTTP
*/
export interface HttpError {
/** HTTP 状态码 */
status: number;
/** 错误消息 */
message: string;
/** 错误详情 */
error?: any;
}
// ==================== 请求配置扩展 ====================
/**
*
*/
export interface RequestConfig {
/** 是否显示加载提示 */
showLoading?: boolean;
/** 是否显示成功提示 */
showSuccessMsg?: boolean;
/** 是否显示错误提示 */
showErrorMsg?: boolean;
/** 自定义成功提示消息 */
successMsg?: string;
/** 自定义错误提示消息 */
errorMsg?: string;
/** 是否需要认证(默认 true */
requireAuth?: boolean;
}
// ==================== 类型守卫函数 ====================
/**
*
* @param response -
* @returns
*/
export function isSuccessResponse<T>(response: R<T>): boolean {
return response.code == 0; // 使用宽松相等,兼容字符串和数字
}
/**
*
* @param data -
* @returns
*/
export function isPageResponse<T>(data: any): data is PageResp<T> {
return (
data !== null &&
typeof data === 'object' &&
Array.isArray(data.list) &&
typeof data.total === 'number'
);
}

140
src/types/auth.ts Normal file
View File

@ -0,0 +1,140 @@
/**
*
* @description
*/
// ==================== 登录相关 ====================
/**
*
*/
export interface LoginReq {
/** 账号(用户名/邮箱/手机号) */
account: string;
/** 密码 */
password: string;
}
/**
*
*/
export interface LoginResp {
/** 访问令牌(需存储并在后续请求中携带) */
token: string;
/** 用户ID */
userId: number;
/** 用户名 */
username: string;
/** 昵称 */
nickname: string;
/** 角色ADMIN-管理员, USER-普通用户) */
role: string;
/** 头像地址 */
avatar?: string;
}
// ==================== 注册相关 ====================
/**
*
*/
export interface RegisterReq {
/** 用户名4-20位字母、数字或下划线 */
username: string;
/** 密码8-20位包含大小写字母和数字 */
password: string;
/** 确认密码 */
confirmPassword: string;
/** 邮箱 */
email: string;
/** 手机号11位 */
phone: string;
/** 昵称 */
nickname: string;
}
// ==================== 用户信息相关 ====================
/**
*
*/
export interface UserInfoResp {
/** 用户ID */
id: number;
/** 用户名 */
username: string;
/** 昵称 */
nickname: string;
/** 角色ADMIN-管理员, USER-普通用户) */
role: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phone?: string;
/** 头像地址 */
avatar?: string;
/** 状态0-禁用, 1-启用) */
status: number;
/** 创建时间 */
createTime?: string;
}
// ==================== 本地存储的用户信息 ====================
/**
* localStorage
*/
export interface LocalUserInfo {
/** 用户ID */
userId: number;
/** 用户名 */
username: string;
/** 昵称 */
nickname: string;
/** 角色 */
role: string;
/** 头像地址 */
avatar?: string;
}
// ==================== 用户角色枚举 ====================
/**
*
*/
export enum UserRole {
/** 管理员 */
ADMIN = 'ADMIN',
/** 普通用户 */
USER = 'USER',
}
/**
*
*/
export enum UserStatus {
/** 禁用 */
DISABLED = 0,
/** 启用 */
ENABLED = 1,
}
// ==================== 类型守卫函数 ====================
/**
*
* @param role -
* @returns
*/
export function isAdmin(role: string): boolean {
return role === UserRole.ADMIN;
}
/**
*
* @param status -
* @returns
*/
export function isUserEnabled(status: number): boolean {
return status === UserStatus.ENABLED;
}

214
src/types/user.ts Normal file
View File

@ -0,0 +1,214 @@
/**
*
* @description CRUD
*/
import type { PageQuery } from './api';
// ==================== 用户查询参数 ====================
/**
*
*/
export interface UserQuery extends PageQuery {
/** 用户名(模糊搜索) */
username?: string;
/** 角色 */
role?: string;
/** 昵称(模糊搜索) */
nickname?: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phone?: string;
/** 状态0-禁用, 1-启用) */
status?: number;
}
// ==================== 用户响应数据 ====================
/**
*
*/
export interface UserResp {
/** 用户ID */
id: number;
/** 用户名 */
username: string;
/** 角色ADMIN-管理员, USER-普通用户) */
role: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phone?: string;
/** 昵称 */
nickname: string;
/** 头像地址 */
avatar?: string;
/** 状态0-禁用, 1-启用) */
status: number;
/** 创建人ID */
createUser?: number;
/** 创建人名称 */
createUserString?: string;
/** 创建时间 */
createTime?: string;
/** 更新时间 */
updateTime?: string;
}
// ==================== 用户请求参数 ====================
/**
* /
*/
export interface UserReq {
/** 用户名4-20位字母、数字或下划线 */
username: string;
/** 角色ADMIN 或 USER */
role: string;
/** 密码8-20位包含大小写字母和数字- 可选,更新时不传则不修改密码 */
password?: string;
/** 邮箱 */
email?: string;
/** 手机号11位 */
phone?: string;
/** 昵称 */
nickname: string;
/** 头像地址 */
avatar?: string;
/** 状态0-禁用, 1-启用) */
status?: number;
}
// ==================== 批量操作参数 ====================
/**
*
*/
export interface BatchDeleteReq {
/** 用户ID列表 */
ids: number[];
}
/**
*
*/
export interface ResetPasswordReq {
/** 用户ID */
userId: number;
/** 新密码 */
newPassword: string;
}
/**
*
*/
export interface UpdateProfileReq {
/** 昵称 */
nickname?: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phone?: string;
/** 头像地址 */
avatar?: string;
}
// ==================== 表单数据类型 ====================
/**
*
*/
export interface UserFormData {
/** 用户ID编辑时使用 */
id?: number;
/** 用户名 */
username: string;
/** 角色 */
role: string;
/** 密码(创建时必填,更新时可选) */
password?: string;
/** 确认密码(创建时必填,更新时可选) */
confirmPassword?: string;
/** 邮箱 */
email?: string;
/** 手机号 */
phone?: string;
/** 昵称 */
nickname: string;
/** 头像地址 */
avatar?: string;
/** 状态 */
status?: number;
}
// ==================== 用户列表筛选条件 ====================
/**
*
*/
export interface UserFilterForm {
/** 用户名 */
username?: string;
/** 角色 */
role?: string;
/** 昵称 */
nickname?: string;
/** 状态 */
status?: number;
/** 创建时间范围 */
createTimeRange?: [string, string];
}
// ==================== 工具函数 ====================
/**
*
* @param user -
* @returns
*/
export function userRespToFormData(user: UserResp): UserFormData {
return {
id: user.id,
username: user.username,
role: user.role,
email: user.email,
phone: user.phone,
nickname: user.nickname,
avatar: user.avatar,
status: user.status,
};
}
/**
*
* @param formData -
* @returns
*/
export function formDataToUserReq(formData: UserFormData): UserReq {
const req: UserReq = {
username: formData.username,
role: formData.role,
nickname: formData.nickname,
};
// 可选字段
if (formData.password) {
req.password = formData.password;
}
if (formData.email) {
req.email = formData.email;
}
if (formData.phone) {
req.phone = formData.phone;
}
if (formData.avatar) {
req.avatar = formData.avatar;
}
if (formData.status !== undefined) {
req.status = formData.status;
}
return req;
}

216
src/utils/auth.ts Normal file
View File

@ -0,0 +1,216 @@
/**
*
* @description
*/
import { ROLE_ADMIN, UNAUTHORIZED_CODE } from '@/constants';
import type { LocalUserInfo } from '@/types/auth';
import { getToken, getUserInfo, getUserRole, clearAuthInfo } from './storage';
// ==================== 认证状态检查 ====================
/**
*
* @returns
*/
export function isAuthenticated(): boolean {
const token = getToken();
return !!token;
}
/**
*
* @returns
*/
export function requireAuth(): boolean {
return !isAuthenticated();
}
// ==================== 权限检查 ====================
/**
*
* @returns
*/
export function isAdmin(): boolean {
const userInfo = getUserInfo();
if (!userInfo) {
return false;
}
return userInfo.role === ROLE_ADMIN;
}
/**
*
* @param role -
* @returns
*/
export function hasRole(role: string): boolean {
const userInfo = getUserInfo();
if (!userInfo) {
return false;
}
return userInfo.role === role;
}
/**
*
* @param roles -
* @returns
*/
export function hasAnyRole(roles: string[]): boolean {
const userInfo = getUserInfo();
if (!userInfo) {
return false;
}
return roles.includes(userInfo.role);
}
/**
*
* @param roles -
* @returns
*/
export function hasAllRoles(roles: string[]): boolean {
const userInfo = getUserInfo();
if (!userInfo) {
return false;
}
// 注意:由于后端一个用户只有一个角色,这个函数实际上只在 roles 长度为 1 时返回 true
return roles.length === 1 && roles[0] === userInfo.role;
}
/**
* 访
* @param requiredRole -
* @returns
*/
export function hasPermission(requiredRole?: string): boolean {
// 如果未指定角色要求,只需要已登录即可
if (!requiredRole) {
return isAuthenticated();
}
// 检查是否具有指定角色
return hasRole(requiredRole);
}
// ==================== 获取认证信息 ====================
/**
*
* @returns null
*/
export function getCurrentUser(): LocalUserInfo | null {
return getUserInfo();
}
/**
*
* @returns null
*/
export function getCurrentUserRole(): string | null {
const userInfo = getUserInfo();
return userInfo ? userInfo.role : null;
}
/**
* ID
* @returns ID或 null
*/
export function getCurrentUserId(): number | null {
const userInfo = getUserInfo();
return userInfo ? userInfo.userId : null;
}
/**
*
* @returns null
*/
export function getCurrentUsername(): string | null {
const userInfo = getUserInfo();
return userInfo ? userInfo.username : null;
}
// ==================== 认证请求头 ====================
/**
*
* @returns
*/
export function getAuthHeaders(): Record<string, string> {
const token = getToken();
if (!token) {
return {};
}
return {
Authorization: token,
};
}
// ==================== 登出处理 ====================
/**
*
* @param redirectToLogin - true
*/
export function handleLogout(redirectToLogin = true): void {
// 清理所有认证信息
clearAuthInfo();
// 跳转到登录页
if (redirectToLogin) {
// 保存当前路径,登录后可以跳转回来
const currentPath = window.location.pathname + window.location.search;
if (currentPath !== '/login') {
sessionStorage.setItem('redirectPath', currentPath);
}
// 跳转到登录页
window.location.href = '/login';
}
}
/**
* 401
* @param message -
*/
export function handleUnauthorized(message?: string): void {
console.warn('Unauthorized access:', message || 'Token expired or invalid');
// 清理认证信息并跳转登录页
handleLogout(true);
}
/**
* 403
* @param message -
*/
export function handleForbidden(message?: string): void {
console.warn('Forbidden access:', message || 'Insufficient permissions');
// 可以跳转到无权限提示页,或者显示错误提示
// 这里简单处理,直接返回首页
window.location.href = '/';
}
// ==================== 登录后重定向 ====================
/**
*
* @returns
*/
export function getRedirectPath(): string {
const redirectPath = sessionStorage.getItem('redirectPath');
sessionStorage.removeItem('redirectPath');
return redirectPath || '/';
}
/**
*
*/
export function redirectAfterLogin(): void {
const redirectPath = getRedirectPath();
window.location.href = redirectPath;
}

242
src/utils/request.ts Normal file
View File

@ -0,0 +1,242 @@
/**
* Axios HTTP
* @description Axios
*/
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
AxiosError,
} from 'axios';
import { Message } from '@arco-design/web-react';
import {
SUCCESS_CODE,
UNAUTHORIZED_CODE,
FORBIDDEN_CODE,
NOT_FOUND_CODE,
SERVER_ERROR_CODE,
} from '@/constants';
import type { R } from '@/types/api';
import { getToken } from './storage';
import { handleUnauthorized } from './auth';
// ==================== 创建 Axios 实例 ====================
/**
* Axios
*/
const instance: AxiosInstance = axios.create({
// API 基础地址(从环境变量读取)
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
// 请求超时时间(从环境变量读取,默认 30 秒)
timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 30000,
// 默认请求头
headers: {
'Content-Type': 'application/json',
},
});
// ==================== 请求拦截器 ====================
/**
*
*
*/
instance.interceptors.request.use(
(config) => {
// 获取 Token
const token = getToken();
// 如果存在 Token添加到请求头带 Bearer 前缀)
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
// 开发环境打印请求日志
if (import.meta.env.DEV) {
console.log(
`[Request] ${config.method?.toUpperCase()} ${config.url}`,
config.data || config.params
);
}
return config;
},
(error: AxiosError) => {
// 请求错误处理
console.error('[Request Error]', error);
Message.error('请求配置错误');
return Promise.reject(error);
}
);
// ==================== 响应拦截器 ====================
/**
*
*
*/
instance.interceptors.response.use(
(response: AxiosResponse<R>) => {
const { data } = response;
// 开发环境打印响应日志
if (import.meta.env.DEV) {
console.log(
`[Response] ${response.config.method?.toUpperCase()} ${
response.config.url
}`,
data
);
}
// 特殊处理如果是文件下载Blob直接返回完整响应
if (response.config.responseType === 'blob') {
return response;
}
// 判断业务响应码(使用宽松相等,兼容字符串和数字)
if (data.code == SUCCESS_CODE) {
// 业务成功,返回完整的响应数据(包含 code, msg, data
return response;
} else {
// 业务失败,显示错误消息
const errorMsg = data.msg || '请求失败';
Message.error(errorMsg);
// 抛出错误,方便调用方捕获
return Promise.reject(new Error(errorMsg));
}
},
(error: AxiosError<R>) => {
// HTTP 错误处理
if (error.response) {
const { status, data } = error.response;
// 开发环境打印错误日志
if (import.meta.env.DEV) {
console.error('[Response Error]', status, data);
}
// 根据 HTTP 状态码处理
switch (status) {
case UNAUTHORIZED_CODE:
// 401 未认证Token 过期或无效)
Message.error('登录已过期,请重新登录');
handleUnauthorized();
break;
case FORBIDDEN_CODE:
// 403 无权限
Message.error('没有权限访问该资源');
break;
case NOT_FOUND_CODE:
// 404 资源不存在
Message.error('请求的资源不存在');
break;
case SERVER_ERROR_CODE:
// 500 服务器错误
Message.error('服务器错误,请稍后重试');
break;
default:
// 其他错误
const errorMsg = data?.msg || '请求失败';
Message.error(errorMsg);
}
} else if (error.request) {
// 请求已发送但未收到响应(网络错误)
console.error('[Network Error]', error.request);
Message.error('网络连接失败,请检查网络');
} else {
// 请求配置错误
console.error('[Request Setup Error]', error.message);
Message.error('请求配置错误');
}
return Promise.reject(error);
}
);
// ==================== 封装请求方法 ====================
/**
*
*/
const request = {
/**
* GET
* @param url - URL
* @param config -
* @returns Promise<R<T>>
*/
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<R<T>> {
return instance.get<R<T>>(url, config).then((response) => response.data);
},
/**
* POST
* @param url - URL
* @param data -
* @param config -
* @returns Promise<R<T>>
*/
post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<R<T>> {
return instance
.post<R<T>>(url, data, config)
.then((response) => response.data);
},
/**
* PUT
* @param url - URL
* @param data -
* @param config -
* @returns Promise<R<T>>
*/
put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<R<T>> {
return instance
.put<R<T>>(url, data, config)
.then((response) => response.data);
},
/**
* DELETE
* @param url - URL
* @param config -
* @returns Promise<R<T>>
*/
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<R<T>> {
return instance.delete<R<T>>(url, config).then((response) => response.data);
},
/**
*
* @param config -
* @returns Promise<R<T>>
*/
request<T = any>(config: AxiosRequestConfig): Promise<R<T>> {
return instance.request<R<T>>(config).then((response) => response.data);
},
/**
* Axios
*/
instance,
};
// ==================== 导出 ====================
export default request;

258
src/utils/storage.ts Normal file
View File

@ -0,0 +1,258 @@
/**
*
* @description localStorage
*/
import {
TOKEN_KEY,
USERINFO_KEY,
USER_STATUS_KEY,
USER_ROLE_KEY,
LANG_KEY,
THEME_KEY,
} from '@/constants';
import type { LocalUserInfo } from '@/types/auth';
// ==================== 通用存储方法 ====================
/**
* localStorage
* @param key -
* @param value - JSON
*/
export function setItem<T = any>(key: string, value: T): void {
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(key, serializedValue);
} catch (error) {
console.error(`Failed to set item "${key}" to localStorage:`, error);
}
}
/**
* localStorage
* @param key -
* @returns JSON
*/
export function getItem<T = any>(key: string): T | null {
try {
const serializedValue = localStorage.getItem(key);
if (serializedValue === null) {
return null;
}
return JSON.parse(serializedValue) as T;
} catch (error) {
console.error(`Failed to get item "${key}" from localStorage:`, error);
return null;
}
}
/**
* localStorage
* @param key -
*/
export function removeItem(key: string): void {
try {
localStorage.removeItem(key);
} catch (error) {
console.error(`Failed to remove item "${key}" from localStorage:`, error);
}
}
/**
* localStorage
*/
export function clear(): void {
try {
localStorage.clear();
} catch (error) {
console.error('Failed to clear localStorage:', error);
}
}
/**
* localStorage
* @param key -
* @returns
*/
export function hasItem(key: string): boolean {
return localStorage.getItem(key) !== null;
}
// ==================== Token 相关 ====================
/**
* Token
* @param token - Token
*/
export function setToken(token: string): void {
setItem(TOKEN_KEY, token);
}
/**
* Token
* @returns Token
*/
export function getToken(): string | null {
return getItem<string>(TOKEN_KEY);
}
/**
* Token
*/
export function removeToken(): void {
removeItem(TOKEN_KEY);
}
/**
* Token
* @returns
*/
export function hasToken(): boolean {
return hasItem(TOKEN_KEY);
}
// ==================== 用户信息相关 ====================
/**
*
* @param userInfo -
*/
export function setUserInfo(userInfo: LocalUserInfo): void {
setItem(USERINFO_KEY, userInfo);
}
/**
*
* @returns
*/
export function getUserInfo(): LocalUserInfo | null {
return getItem<LocalUserInfo>(USERINFO_KEY);
}
/**
*
*/
export function removeUserInfo(): void {
removeItem(USERINFO_KEY);
}
/**
*
* @returns
*/
export function hasUserInfo(): boolean {
return hasItem(USERINFO_KEY);
}
// ==================== 用户状态相关 ====================
/**
*
* @param status -
*/
export function setUserStatus(status: number): void {
setItem(USER_STATUS_KEY, status);
}
/**
*
* @returns
*/
export function getUserStatus(): number | null {
return getItem<number>(USER_STATUS_KEY);
}
/**
*
*/
export function removeUserStatus(): void {
removeItem(USER_STATUS_KEY);
}
// ==================== 用户角色相关 ====================
/**
*
* @param role -
*/
export function setUserRole(role: string): void {
setItem(USER_ROLE_KEY, role);
}
/**
*
* @returns
*/
export function getUserRole(): string | null {
return getItem<string>(USER_ROLE_KEY);
}
/**
*
*/
export function removeUserRole(): void {
removeItem(USER_ROLE_KEY);
}
// ==================== 语言设置相关 ====================
/**
*
* @param lang - zh-CN, en-US
*/
export function setLang(lang: string): void {
setItem(LANG_KEY, lang);
}
/**
*
* @returns
*/
export function getLang(): string | null {
return getItem<string>(LANG_KEY);
}
/**
*
*/
export function removeLang(): void {
removeItem(LANG_KEY);
}
// ==================== 主题设置相关 ====================
/**
*
* @param theme - light, dark
*/
export function setTheme(theme: string): void {
setItem(THEME_KEY, theme);
}
/**
*
* @returns
*/
export function getTheme(): string | null {
return getItem<string>(THEME_KEY);
}
/**
*
*/
export function removeTheme(): void {
removeItem(THEME_KEY);
}
// ==================== 清理所有认证信息 ====================
/**
*
*/
export function clearAuthInfo(): void {
removeToken();
removeUserInfo();
removeUserStatus();
removeUserRole();
}

View File

@ -28,4 +28,13 @@ export default defineConfig({
},
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});

2933
yarn.lock

File diff suppressed because it is too large Load Diff