Compare commits
10 Commits
e53464eed4
...
05999e0231
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05999e0231 | ||
|
|
4a6a0c2209 | ||
|
|
660ab2eb9c | ||
|
|
555d3b164e | ||
|
|
f20951fb92 | ||
|
|
b3c0a05a3e | ||
|
|
bc70cbf087 | ||
|
|
f312be1aa8 | ||
|
|
3ea18a417d | ||
|
|
c8c20299d9 |
10
.env.development
Normal file
10
.env.development
Normal 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
10
.env.production
Normal 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
6
.gitignore
vendored
@ -48,4 +48,8 @@ yarn-error.log*
|
||||
.vscode
|
||||
|
||||
# build output
|
||||
/dist
|
||||
/dist
|
||||
/docs/
|
||||
|
||||
# IDE and tool configs
|
||||
.serena/
|
||||
|
||||
@ -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
39
AGENTS.md
Normal 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、Stylelint,PR 前必跑。 |
|
||||
|
||||
## 编码风格与命名
|
||||
|
||||
- 语法统一使用 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
517
CLAUDE.md
Normal 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.2(strict: 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
12585
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
117
src/api/auth.ts
Normal 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
274
src/api/user.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
28
src/components/ColumnSetting/index.module.less
Normal file
28
src/components/ColumnSetting/index.module.less
Normal 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;
|
||||
}
|
||||
98
src/components/ColumnSetting/index.tsx
Normal file
98
src/components/ColumnSetting/index.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
71
src/constants/index.ts
Normal 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];
|
||||
172
src/layout.tsx
172
src/layout.tsx
@ -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>
|
||||
|
||||
@ -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': '页面配置',
|
||||
|
||||
16
src/main.tsx
16
src/main.tsx
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
120
src/pages/user-management/ResetPasswordModal.tsx
Normal file
120
src/pages/user-management/ResetPasswordModal.tsx
Normal 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;
|
||||
269
src/pages/user-management/UserFormModal.tsx
Normal file
269
src/pages/user-management/UserFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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': '密码重置成功',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/pages/user-management/style/modal.module.less
Normal file
13
src/pages/user-management/style/modal.module.less
Normal 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;
|
||||
}
|
||||
@ -3,6 +3,6 @@
|
||||
"navbar": true,
|
||||
"menu": true,
|
||||
"footer": false,
|
||||
"themeColor": "#4834D4",
|
||||
"themeColor": "#005cb8",
|
||||
"menuWidth": 220
|
||||
}
|
||||
|
||||
@ -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
91
src/store/userStore.ts
Normal 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;
|
||||
@ -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
111
src/types/api.ts
Normal 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
140
src/types/auth.ts
Normal 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
214
src/types/user.ts
Normal 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
216
src/utils/auth.ts
Normal 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
242
src/utils/request.ts
Normal 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
258
src/utils/storage.ts
Normal 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();
|
||||
}
|
||||
@ -28,4 +28,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user