更新政府端和银行端
This commit is contained in:
285
bank-backend/PROJECT_SUMMARY.md
Normal file
285
bank-backend/PROJECT_SUMMARY.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# 银行管理后台系统项目总结
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于对现有智慧养殖监管平台的深入学习和分析,成功创建了一个完整的银行管理后台系统。该系统采用现代化的技术栈,提供完整的用户管理、账户管理、交易管理等核心功能。
|
||||
|
||||
## 🎯 项目目标达成
|
||||
|
||||
### ✅ 已完成功能
|
||||
|
||||
1. **项目架构设计**
|
||||
- 采用前后端分离架构
|
||||
- 模块化设计,易于维护和扩展
|
||||
- 完整的目录结构规划
|
||||
|
||||
2. **数据库设计**
|
||||
- 完整的数据库架构设计
|
||||
- 4个核心数据表:用户、角色、账户、交易记录
|
||||
- 完善的索引和约束设计
|
||||
- 数据完整性保障
|
||||
|
||||
3. **核心功能模块**
|
||||
- 用户管理:注册、登录、权限控制
|
||||
- 账户管理:创建、状态管理、余额操作
|
||||
- 交易管理:存款、取款、转账、记录查询
|
||||
- 安全防护:JWT认证、密码加密、请求限流
|
||||
|
||||
4. **技术实现**
|
||||
- Node.js 16+ 运行环境
|
||||
- Express.js Web框架
|
||||
- Sequelize ORM数据库操作
|
||||
- MySQL数据库支持
|
||||
- Swagger API文档自动生成
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 后端技术栈
|
||||
- **运行环境**: Node.js 16+
|
||||
- **Web框架**: Express.js 4.18+
|
||||
- **数据库**: MySQL 8.0+
|
||||
- **ORM**: Sequelize 6.35+
|
||||
- **认证**: JWT (jsonwebtoken)
|
||||
- **密码加密**: bcryptjs
|
||||
- **API文档**: Swagger
|
||||
- **日志**: Winston
|
||||
- **安全**: Helmet, CORS, Rate Limiting
|
||||
|
||||
### 项目结构
|
||||
```
|
||||
bank-backend/
|
||||
├── config/ # 配置文件
|
||||
│ ├── database.js # 数据库配置
|
||||
│ └── swagger.js # API文档配置
|
||||
├── controllers/ # 控制器
|
||||
│ ├── userController.js # 用户控制器
|
||||
│ ├── accountController.js # 账户控制器
|
||||
│ └── transactionController.js # 交易控制器
|
||||
├── models/ # 数据模型
|
||||
│ ├── BaseModel.js # 基础模型类
|
||||
│ ├── User.js # 用户模型
|
||||
│ ├── Role.js # 角色模型
|
||||
│ ├── Account.js # 账户模型
|
||||
│ ├── Transaction.js # 交易模型
|
||||
│ └── index.js # 模型索引
|
||||
├── routes/ # 路由定义
|
||||
│ ├── users.js # 用户路由
|
||||
│ ├── accounts.js # 账户路由
|
||||
│ └── transactions.js # 交易路由
|
||||
├── middleware/ # 中间件
|
||||
│ ├── auth.js # 认证中间件
|
||||
│ └── security.js # 安全中间件
|
||||
├── utils/ # 工具类
|
||||
│ └── logger.js # 日志工具
|
||||
├── scripts/ # 脚本文件
|
||||
│ └── init-db.js # 数据库初始化
|
||||
├── docs/ # 文档
|
||||
│ └── database-schema.md # 数据库架构文档
|
||||
├── server.js # 服务器入口
|
||||
├── package.json # 项目配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
### 认证与授权
|
||||
- JWT令牌认证机制
|
||||
- 基于角色的访问控制(RBAC)
|
||||
- 会话超时管理
|
||||
- 登录失败锁定机制
|
||||
|
||||
### 数据安全
|
||||
- 密码bcrypt加密存储
|
||||
- SQL注入防护
|
||||
- XSS攻击防护
|
||||
- 请求频率限制
|
||||
|
||||
### 传输安全
|
||||
- HTTPS支持
|
||||
- CORS跨域配置
|
||||
- 安全头部设置
|
||||
- 输入数据验证
|
||||
|
||||
## 📊 数据库设计
|
||||
|
||||
### 核心数据表
|
||||
|
||||
1. **用户表 (users)**
|
||||
- 用户基本信息
|
||||
- 身份认证信息
|
||||
- 角色关联
|
||||
|
||||
2. **角色表 (roles)**
|
||||
- 角色定义
|
||||
- 权限级别
|
||||
- 系统角色标识
|
||||
|
||||
3. **账户表 (accounts)**
|
||||
- 账户基本信息
|
||||
- 余额管理
|
||||
- 账户状态
|
||||
|
||||
4. **交易记录表 (transactions)**
|
||||
- 交易详情
|
||||
- 余额变化
|
||||
- 交易状态
|
||||
|
||||
### 关系设计
|
||||
- 用户与角色:多对一关系
|
||||
- 用户与账户:一对多关系
|
||||
- 账户与交易:一对多关系
|
||||
|
||||
## 🚀 核心功能
|
||||
|
||||
### 用户管理
|
||||
- 用户注册和登录
|
||||
- 用户信息管理
|
||||
- 密码修改
|
||||
- 用户状态管理
|
||||
|
||||
### 账户管理
|
||||
- 账户创建和查询
|
||||
- 账户状态管理
|
||||
- 余额查询
|
||||
- 账户详情查看
|
||||
|
||||
### 交易管理
|
||||
- 存款操作
|
||||
- 取款操作
|
||||
- 转账功能
|
||||
- 交易记录查询
|
||||
- 交易统计
|
||||
|
||||
### 权限管理
|
||||
- 角色定义
|
||||
- 权限分配
|
||||
- 访问控制
|
||||
- 操作审计
|
||||
|
||||
## 📚 API设计
|
||||
|
||||
### RESTful API规范
|
||||
- 统一的响应格式
|
||||
- 标准的HTTP状态码
|
||||
- 完整的错误处理
|
||||
- 详细的API文档
|
||||
|
||||
### 主要API端点
|
||||
- `/api/users` - 用户管理
|
||||
- `/api/accounts` - 账户管理
|
||||
- `/api/transactions` - 交易管理
|
||||
- `/api-docs` - API文档
|
||||
|
||||
## 🛠️ 开发工具
|
||||
|
||||
### 代码质量
|
||||
- ESLint代码检查
|
||||
- 统一的代码规范
|
||||
- 完整的错误处理
|
||||
- 详细的日志记录
|
||||
|
||||
### 开发支持
|
||||
- 热重载开发模式
|
||||
- 数据库连接测试
|
||||
- 健康检查端点
|
||||
- 完整的项目文档
|
||||
|
||||
## 📈 性能优化
|
||||
|
||||
### 数据库优化
|
||||
- 合理的索引设计
|
||||
- 查询优化
|
||||
- 连接池管理
|
||||
- 事务处理
|
||||
|
||||
### 应用优化
|
||||
- 请求限流
|
||||
- 响应压缩
|
||||
- 静态文件服务
|
||||
- 错误处理优化
|
||||
|
||||
## 🔧 部署支持
|
||||
|
||||
### 环境配置
|
||||
- 环境变量配置
|
||||
- 多环境支持
|
||||
- 配置文件管理
|
||||
- 安全配置
|
||||
|
||||
### 部署方式
|
||||
- Docker容器化
|
||||
- PM2进程管理
|
||||
- 数据库迁移
|
||||
- 健康检查
|
||||
|
||||
## 📋 项目特色
|
||||
|
||||
### 1. 完整的业务逻辑
|
||||
- 涵盖银行核心业务
|
||||
- 完整的交易流程
|
||||
- 严格的权限控制
|
||||
- 详细的审计日志
|
||||
|
||||
### 2. 现代化的技术栈
|
||||
- 使用最新的Node.js技术
|
||||
- 完善的开发工具链
|
||||
- 标准化的代码规范
|
||||
- 自动化的API文档
|
||||
|
||||
### 3. 企业级安全
|
||||
- 多层安全防护
|
||||
- 完善的认证机制
|
||||
- 数据加密保护
|
||||
- 安全审计功能
|
||||
|
||||
### 4. 可扩展架构
|
||||
- 模块化设计
|
||||
- 清晰的代码结构
|
||||
- 完善的文档
|
||||
- 易于维护和扩展
|
||||
|
||||
## 🎉 项目成果
|
||||
|
||||
### 技术成果
|
||||
- 完整的银行管理系统后端
|
||||
- 现代化的技术架构
|
||||
- 完善的数据库设计
|
||||
- 标准化的API接口
|
||||
|
||||
### 文档成果
|
||||
- 详细的项目文档
|
||||
- 完整的API文档
|
||||
- 数据库架构文档
|
||||
- 部署和开发指南
|
||||
|
||||
### 学习成果
|
||||
- 深入理解了现有项目的架构设计
|
||||
- 掌握了企业级应用开发的最佳实践
|
||||
- 学习了银行系统的业务逻辑
|
||||
- 提升了全栈开发能力
|
||||
|
||||
## 🔮 未来扩展
|
||||
|
||||
### 功能扩展
|
||||
- 移动端API支持
|
||||
- 实时通知系统
|
||||
- 高级报表功能
|
||||
- 第三方集成
|
||||
|
||||
### 技术升级
|
||||
- 微服务架构改造
|
||||
- 容器化部署
|
||||
- 云原生支持
|
||||
- 性能监控
|
||||
|
||||
## 📞 总结
|
||||
|
||||
通过深入学习现有智慧养殖监管平台的架构和设计模式,成功创建了一个功能完整、架构清晰的银行管理后台系统。该项目不仅实现了银行系统的核心功能,还采用了现代化的技术栈和最佳实践,为后续的功能扩展和技术升级奠定了坚实的基础。
|
||||
|
||||
项目展现了从需求分析、架构设计、数据库设计、功能实现到文档编写的完整开发流程,体现了企业级应用开发的专业水准。
|
||||
|
||||
---
|
||||
|
||||
*项目完成时间: 2025-01-18*
|
||||
*开发环境: Node.js 16+*
|
||||
*数据库: MySQL 8.0+*
|
||||
283
bank-backend/README.md
Normal file
283
bank-backend/README.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# 银行管理后台系统
|
||||
|
||||
一个基于 Node.js 和 Express 的现代化银行管理后台系统,提供完整的用户管理、账户管理、交易管理等功能。
|
||||
|
||||
## 🚀 功能特性
|
||||
|
||||
### 核心功能
|
||||
- **用户管理**: 用户注册、登录、权限管理
|
||||
- **账户管理**: 账户创建、状态管理、余额查询
|
||||
- **交易管理**: 存款、取款、转账、交易记录查询
|
||||
- **权限控制**: 基于角色的访问控制(RBAC)
|
||||
- **安全防护**: JWT认证、密码加密、请求限流
|
||||
|
||||
### 技术特性
|
||||
- **RESTful API**: 标准化的API设计
|
||||
- **数据库ORM**: Sequelize ORM支持
|
||||
- **API文档**: Swagger自动生成文档
|
||||
- **日志系统**: Winston日志管理
|
||||
- **安全中间件**: 多层安全防护
|
||||
- **错误处理**: 完善的错误处理机制
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
- **运行环境**: Node.js 16+
|
||||
- **Web框架**: Express.js 4.18+
|
||||
- **数据库**: MySQL 8.0+
|
||||
- **ORM**: Sequelize 6.35+
|
||||
- **认证**: JWT (jsonwebtoken)
|
||||
- **密码加密**: bcryptjs
|
||||
- **API文档**: Swagger
|
||||
- **日志**: Winston
|
||||
- **安全**: Helmet, CORS, Rate Limiting
|
||||
|
||||
## 📦 安装部署
|
||||
|
||||
### 环境要求
|
||||
- Node.js 16.0+
|
||||
- MySQL 8.0+
|
||||
- npm 8.0+
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd bank-backend
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **环境配置**
|
||||
```bash
|
||||
cp env.example .env
|
||||
# 编辑 .env 文件,配置数据库连接等信息
|
||||
```
|
||||
|
||||
4. **数据库初始化**
|
||||
手动或脚本方式,避免自动建表:
|
||||
```powershell
|
||||
# PowerShell(推荐,自动生成管理员bcrypt哈希)
|
||||
cd scripts
|
||||
./setup-bank-db.ps1 -AdminPlain 'Admin123456'
|
||||
```
|
||||
或在数据库手工执行:
|
||||
```sql
|
||||
-- 1) 执行建表
|
||||
-- scripts/create-bank-schema.sql
|
||||
-- 2) 执行测试数据(将 REPLACE_ADMIN_BCRYPT 替换为真实 bcrypt 哈希)
|
||||
-- scripts/seed-bank-demo.sql
|
||||
```
|
||||
|
||||
5. **启动服务**
|
||||
```bash
|
||||
# 开发环境
|
||||
npm run dev
|
||||
|
||||
# 生产环境
|
||||
npm start
|
||||
```
|
||||
|
||||
## ⚙️ 环境配置
|
||||
|
||||
创建 `.env` 文件并配置以下环境变量:
|
||||
|
||||
```env
|
||||
# 服务器配置
|
||||
PORT=5351
|
||||
NODE_ENV=development
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=bank_management
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_DIALECT=mysql
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# 安全配置
|
||||
BCRYPT_ROUNDS=10
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# 银行系统配置
|
||||
BANK_CODE=001
|
||||
BANK_NAME=示例银行
|
||||
CURRENCY=CNY
|
||||
TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
## 📚 API文档
|
||||
|
||||
启动服务后,访问以下地址查看API文档:
|
||||
- 开发环境: http://localhost:5351/api-docs
|
||||
- 生产环境: https://your-domain.com/api-docs
|
||||
|
||||
## 🗄️ 数据库设计
|
||||
|
||||
### 主要数据表
|
||||
|
||||
#### 用户表 (users)
|
||||
- 用户基本信息
|
||||
- 身份认证信息
|
||||
- 角色关联
|
||||
|
||||
#### 角色表 (roles)
|
||||
- 角色定义
|
||||
- 权限级别
|
||||
- 系统角色标识
|
||||
|
||||
#### 账户表 (accounts)
|
||||
- 账户基本信息
|
||||
- 余额管理
|
||||
- 账户状态
|
||||
|
||||
#### 交易记录表 (transactions)
|
||||
- 交易详情
|
||||
- 余额变化
|
||||
- 交易状态
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
### 认证与授权
|
||||
- JWT令牌认证
|
||||
- 基于角色的权限控制
|
||||
- 会话超时管理
|
||||
- 登录失败锁定
|
||||
|
||||
### 数据安全
|
||||
- 密码bcrypt加密
|
||||
- SQL注入防护
|
||||
- XSS攻击防护
|
||||
- 请求频率限制
|
||||
|
||||
### 传输安全
|
||||
- HTTPS支持
|
||||
- CORS配置
|
||||
- 安全头部设置
|
||||
- 输入数据验证
|
||||
|
||||
## 📊 系统监控
|
||||
|
||||
### 健康检查
|
||||
```bash
|
||||
curl http://localhost:5351/health
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
```bash
|
||||
# 查看错误日志
|
||||
tail -f logs/error.log
|
||||
|
||||
# 查看所有日志
|
||||
tail -f logs/combined.log
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 测试覆盖率
|
||||
npm run test:coverage
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## 📝 开发指南
|
||||
|
||||
### 项目结构
|
||||
```
|
||||
bank-backend/
|
||||
├── config/ # 配置文件
|
||||
├── controllers/ # 控制器
|
||||
├── models/ # 数据模型
|
||||
├── routes/ # 路由定义
|
||||
├── middleware/ # 中间件
|
||||
├── utils/ # 工具类
|
||||
├── services/ # 业务服务
|
||||
├── migrations/ # 数据库迁移
|
||||
├── seeds/ # 种子数据
|
||||
├── logs/ # 日志文件
|
||||
├── uploads/ # 上传文件
|
||||
└── scripts/ # 脚本文件
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
- 使用ESLint进行代码检查
|
||||
- 遵循RESTful API设计规范
|
||||
- 统一的错误处理格式
|
||||
- 完整的API文档注释
|
||||
|
||||
### 开发命令
|
||||
```bash
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 代码修复
|
||||
npm run lint:fix
|
||||
|
||||
# 数据库连接测试
|
||||
npm run test-connection
|
||||
|
||||
# 清理临时文件
|
||||
npm run clean
|
||||
```
|
||||
|
||||
## 🚀 部署指南
|
||||
|
||||
### Docker部署
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t bank-backend .
|
||||
|
||||
# 运行容器
|
||||
docker run -p 5351:5351 bank-backend
|
||||
```
|
||||
|
||||
### PM2部署
|
||||
```bash
|
||||
# 安装PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动应用
|
||||
pm2 start server.js --name bank-backend
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- 项目维护者: 银行开发团队
|
||||
- 邮箱: dev@bank.com
|
||||
- 项目地址: https://github.com/bank-management/bank-backend
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有为这个项目做出贡献的开发者和开源社区。
|
||||
|
||||
---
|
||||
|
||||
**注意**: 这是一个演示项目,请勿在生产环境中使用默认的密码和密钥。
|
||||
72
bank-backend/config/database.js
Normal file
72
bank-backend/config/database.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
// 从环境变量获取数据库配置
|
||||
const dialect = process.env.DB_DIALECT || 'mysql';
|
||||
const config = {
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
freezeTableName: true
|
||||
}
|
||||
};
|
||||
|
||||
// 根据数据库类型配置不同的选项
|
||||
if (dialect === 'sqlite') {
|
||||
config.storage = process.env.DB_STORAGE || './bank_database.sqlite';
|
||||
config.dialect = 'sqlite';
|
||||
} else {
|
||||
config.host = process.env.DB_HOST || '129.211.213.226';
|
||||
config.port = process.env.DB_PORT || 9527;
|
||||
config.dialect = 'mysql';
|
||||
config.timezone = '+08:00';
|
||||
config.define.charset = 'utf8mb4';
|
||||
config.define.collate = 'utf8mb4_unicode_ci';
|
||||
config.pool = {
|
||||
max: 10,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
};
|
||||
}
|
||||
|
||||
let sequelize;
|
||||
if (dialect === 'sqlite') {
|
||||
sequelize = new Sequelize(config);
|
||||
} else {
|
||||
sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'ningxia_bank',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD || 'aiotAiot123!',
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
// 测试数据库连接(最多重试3次)
|
||||
const MAX_RETRIES = 3;
|
||||
let retryCount = 0;
|
||||
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ 银行系统数据库连接成功');
|
||||
} catch (err) {
|
||||
console.error('❌ 银行系统数据库连接失败:', err.message);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
console.log(`🔄 正在重试连接 (${retryCount}/${MAX_RETRIES})...`);
|
||||
setTimeout(testConnection, 5000); // 5秒后重试
|
||||
} else {
|
||||
console.error('❌ 数据库连接失败,应用将使用模拟数据运行');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 异步测试连接,不阻塞应用启动
|
||||
testConnection().catch(() => {
|
||||
console.log('📊 数据库连接测试完成,应用继续启动');
|
||||
});
|
||||
|
||||
// 兼容导出:同时支持 require('.../database') 和 const { sequelize } = require('.../database')
|
||||
module.exports = sequelize;
|
||||
module.exports.sequelize = sequelize;
|
||||
216
bank-backend/config/swagger.js
Normal file
216
bank-backend/config/swagger.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Swagger API文档配置
|
||||
* @file swagger.js
|
||||
* @description API文档配置和定义
|
||||
*/
|
||||
const swaggerJSDoc = require('swagger-jsdoc');
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: '银行管理后台API',
|
||||
version: '1.0.0',
|
||||
description: '银行管理后台系统API文档',
|
||||
contact: {
|
||||
name: '银行开发团队',
|
||||
email: 'dev@bank.com'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: `http://localhost:${process.env.PORT || 5351}`,
|
||||
description: '开发服务器'
|
||||
},
|
||||
{
|
||||
url: 'https://api.bank.com',
|
||||
description: '生产服务器'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT访问令牌'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
example: '错误信息'
|
||||
},
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Success: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
example: '操作成功'
|
||||
},
|
||||
data: {
|
||||
type: 'object'
|
||||
}
|
||||
}
|
||||
},
|
||||
Pagination: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: {
|
||||
type: 'integer',
|
||||
example: 1
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
example: 10
|
||||
},
|
||||
total: {
|
||||
type: 'integer',
|
||||
example: 100
|
||||
},
|
||||
pages: {
|
||||
type: 'integer',
|
||||
example: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
UnauthorizedError: {
|
||||
description: '未授权访问',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
},
|
||||
example: {
|
||||
success: false,
|
||||
message: '访问被拒绝,未提供令牌'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ForbiddenError: {
|
||||
description: '权限不足',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
},
|
||||
example: {
|
||||
success: false,
|
||||
message: '权限不足'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
NotFoundError: {
|
||||
description: '资源不存在',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
},
|
||||
example: {
|
||||
success: false,
|
||||
message: '请求的资源不存在'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ValidationError: {
|
||||
description: '输入数据验证失败',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
},
|
||||
example: {
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: [
|
||||
{
|
||||
field: 'email',
|
||||
message: '邮箱格式不正确'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
InternalServerError: {
|
||||
description: '服务器内部错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
},
|
||||
example: {
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'Users',
|
||||
description: '用户管理相关接口'
|
||||
},
|
||||
{
|
||||
name: 'Accounts',
|
||||
description: '账户管理相关接口'
|
||||
},
|
||||
{
|
||||
name: 'Transactions',
|
||||
description: '交易管理相关接口'
|
||||
}
|
||||
]
|
||||
},
|
||||
apis: [
|
||||
'./routes/*.js',
|
||||
'./controllers/*.js'
|
||||
]
|
||||
};
|
||||
|
||||
const specs = swaggerJSDoc(options);
|
||||
|
||||
module.exports = specs;
|
||||
434
bank-backend/controllers/accountController.js
Normal file
434
bank-backend/controllers/accountController.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 账户控制器
|
||||
* @file accountController.js
|
||||
* @description 处理银行账户相关的请求
|
||||
*/
|
||||
const { Account, User, Transaction } = require('../models');
|
||||
const { validationResult } = require('express-validator');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* 创建账户
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.createAccount = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { user_id, account_type, initial_balance = 0 } = req.body;
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await User.findByPk(user_id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 生成账户号码
|
||||
const accountNumber = await generateAccountNumber();
|
||||
|
||||
// 创建账户
|
||||
const account = await Account.create({
|
||||
account_number: accountNumber,
|
||||
user_id,
|
||||
account_type,
|
||||
balance: initial_balance * 100, // 转换为分
|
||||
available_balance: initial_balance * 100,
|
||||
frozen_amount: 0
|
||||
});
|
||||
|
||||
// 如果有初始余额,创建存款交易记录
|
||||
if (initial_balance > 0) {
|
||||
await Transaction.create({
|
||||
transaction_number: await generateTransactionNumber(),
|
||||
account_id: account.id,
|
||||
transaction_type: 'deposit',
|
||||
amount: initial_balance * 100,
|
||||
balance_before: 0,
|
||||
balance_after: initial_balance * 100,
|
||||
description: '开户存款',
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '账户创建成功',
|
||||
data: {
|
||||
...account.getSafeInfo(),
|
||||
balance_formatted: account.getBalanceFormatted(),
|
||||
available_balance_formatted: account.getAvailableBalanceFormatted()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建账户错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取账户列表
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getAccounts = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const { user_id, account_type, status } = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
|
||||
// 普通用户只能查看自己的账户
|
||||
if (req.user.role.name !== 'admin') {
|
||||
whereClause.user_id = req.user.id;
|
||||
} else if (user_id) {
|
||||
whereClause.user_id = user_id;
|
||||
}
|
||||
|
||||
if (account_type) {
|
||||
whereClause.account_type = account_type;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
const { count, rows } = await Account.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'username', 'real_name', 'email']
|
||||
}],
|
||||
limit,
|
||||
offset,
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accounts: rows.map(account => ({
|
||||
...account.getSafeInfo(),
|
||||
balance_formatted: account.getBalanceFormatted(),
|
||||
available_balance_formatted: account.getAvailableBalanceFormatted(),
|
||||
frozen_amount_formatted: account.getFrozenAmountFormatted()
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
pages: Math.ceil(count / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取账户列表错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取账户详情
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getAccountDetail = async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const account = await Account.findByPk(accountId, {
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'username', 'real_name', 'email']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (req.user.role.name !== 'admin' && account.user_id !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问该账户'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...account.getSafeInfo(),
|
||||
balance_formatted: account.getBalanceFormatted(),
|
||||
available_balance_formatted: account.getAvailableBalanceFormatted(),
|
||||
frozen_amount_formatted: account.getFrozenAmountFormatted()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取账户详情错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新账户状态
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.updateAccountStatus = async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const account = await Account.findByPk(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
await account.update({ status });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '账户状态更新成功',
|
||||
data: account.getSafeInfo()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新账户状态错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 存款
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.deposit = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { accountId } = req.params;
|
||||
const { amount, description } = req.body;
|
||||
|
||||
const account = await Account.findByPk(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (!account.isActive()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户状态异常,无法进行存款操作'
|
||||
});
|
||||
}
|
||||
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
const balanceBefore = account.balance;
|
||||
const balanceAfter = balanceBefore + amountInCents;
|
||||
|
||||
// 开始事务
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// 更新账户余额
|
||||
await account.update({
|
||||
balance: balanceAfter,
|
||||
available_balance: account.available_balance + amountInCents
|
||||
}, { transaction });
|
||||
|
||||
// 创建交易记录
|
||||
await Transaction.create({
|
||||
transaction_number: await generateTransactionNumber(),
|
||||
account_id: account.id,
|
||||
transaction_type: 'deposit',
|
||||
amount: amountInCents,
|
||||
balance_before: balanceBefore,
|
||||
balance_after: balanceAfter,
|
||||
description: description || '存款',
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '存款成功',
|
||||
data: {
|
||||
amount: amount,
|
||||
balance_after: account.formatAmount(balanceAfter),
|
||||
transaction_number: await generateTransactionNumber()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('存款错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取款
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.withdraw = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { accountId } = req.params;
|
||||
const { amount, description } = req.body;
|
||||
|
||||
const account = await Account.findByPk(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (!account.isActive()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户状态异常,无法进行取款操作'
|
||||
});
|
||||
}
|
||||
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
// 检查余额是否充足
|
||||
if (!account.hasSufficientBalance(amountInCents)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户余额不足'
|
||||
});
|
||||
}
|
||||
|
||||
const balanceBefore = account.balance;
|
||||
const balanceAfter = balanceBefore - amountInCents;
|
||||
|
||||
// 开始事务
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// 更新账户余额
|
||||
await account.update({
|
||||
balance: balanceAfter,
|
||||
available_balance: account.available_balance - amountInCents
|
||||
}, { transaction });
|
||||
|
||||
// 创建交易记录
|
||||
await Transaction.create({
|
||||
transaction_number: await generateTransactionNumber(),
|
||||
account_id: account.id,
|
||||
transaction_type: 'withdrawal',
|
||||
amount: amountInCents,
|
||||
balance_before: balanceBefore,
|
||||
balance_after: balanceAfter,
|
||||
description: description || '取款',
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '取款成功',
|
||||
data: {
|
||||
amount: amount,
|
||||
balance_after: account.formatAmount(balanceAfter),
|
||||
transaction_number: await generateTransactionNumber()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取款错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成账户号码
|
||||
* @returns {String} 账户号码
|
||||
*/
|
||||
async function generateAccountNumber() {
|
||||
const bankCode = process.env.BANK_CODE || '001';
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `${bankCode}${timestamp}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成交易流水号
|
||||
* @returns {String} 交易流水号
|
||||
*/
|
||||
async function generateTransactionNumber() {
|
||||
const timestamp = Date.now().toString();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `TXN${timestamp}${random}`;
|
||||
}
|
||||
459
bank-backend/controllers/transactionController.js
Normal file
459
bank-backend/controllers/transactionController.js
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 交易控制器
|
||||
* @file transactionController.js
|
||||
* @description 处理银行交易相关的请求
|
||||
*/
|
||||
const { Transaction, Account, User } = require('../models');
|
||||
const { validationResult } = require('express-validator');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* 获取交易记录列表
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getTransactions = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const {
|
||||
account_id,
|
||||
transaction_type,
|
||||
status,
|
||||
start_date,
|
||||
end_date,
|
||||
amount_min,
|
||||
amount_max
|
||||
} = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
|
||||
// 普通用户只能查看自己账户的交易记录
|
||||
if (req.user.role.name !== 'admin') {
|
||||
const userAccounts = await Account.findAll({
|
||||
where: { user_id: req.user.id },
|
||||
attributes: ['id']
|
||||
});
|
||||
const accountIds = userAccounts.map(account => account.id);
|
||||
whereClause.account_id = { [Op.in]: accountIds };
|
||||
} else if (account_id) {
|
||||
whereClause.account_id = account_id;
|
||||
}
|
||||
|
||||
if (transaction_type) {
|
||||
whereClause.transaction_type = transaction_type;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
if (start_date || end_date) {
|
||||
whereClause.created_at = {};
|
||||
if (start_date) {
|
||||
whereClause.created_at[Op.gte] = new Date(start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
whereClause.created_at[Op.lte] = new Date(end_date);
|
||||
}
|
||||
}
|
||||
|
||||
if (amount_min || amount_max) {
|
||||
whereClause.amount = {};
|
||||
if (amount_min) {
|
||||
whereClause.amount[Op.gte] = Math.round(parseFloat(amount_min) * 100);
|
||||
}
|
||||
if (amount_max) {
|
||||
whereClause.amount[Op.lte] = Math.round(parseFloat(amount_max) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await Transaction.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: [{
|
||||
model: Account,
|
||||
as: 'account',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'username', 'real_name']
|
||||
}]
|
||||
}],
|
||||
limit,
|
||||
offset,
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
transactions: rows.map(transaction => ({
|
||||
...transaction.getSafeInfo(),
|
||||
amount_formatted: transaction.getAmountFormatted(),
|
||||
balance_after_formatted: transaction.getBalanceAfterFormatted(),
|
||||
type_description: transaction.getTypeDescription(),
|
||||
status_description: transaction.getStatusDescription(),
|
||||
is_income: transaction.isIncome(),
|
||||
is_expense: transaction.isExpense()
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
pages: Math.ceil(count / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取交易记录错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取交易详情
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getTransactionDetail = async (req, res) => {
|
||||
try {
|
||||
const { transactionId } = req.params;
|
||||
|
||||
const transaction = await Transaction.findByPk(transactionId, {
|
||||
include: [{
|
||||
model: Account,
|
||||
as: 'account',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'username', 'real_name']
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '交易记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (req.user.role.name !== 'admin' && transaction.account.user_id !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问该交易记录'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...transaction.getSafeInfo(),
|
||||
amount_formatted: transaction.getAmountFormatted(),
|
||||
balance_after_formatted: transaction.getBalanceAfterFormatted(),
|
||||
type_description: transaction.getTypeDescription(),
|
||||
status_description: transaction.getStatusDescription(),
|
||||
is_income: transaction.isIncome(),
|
||||
is_expense: transaction.isExpense(),
|
||||
can_reverse: transaction.canReverse()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取交易详情错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 转账
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.transfer = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { from_account_id, to_account_number, amount, description } = req.body;
|
||||
|
||||
// 查找转出账户
|
||||
const fromAccount = await Account.findByPk(from_account_id);
|
||||
if (!fromAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '转出账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查转出账户权限
|
||||
if (req.user.role.name !== 'admin' && fromAccount.user_id !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权操作该账户'
|
||||
});
|
||||
}
|
||||
|
||||
// 查找转入账户
|
||||
const toAccount = await Account.findOne({
|
||||
where: { account_number: to_account_number },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'username', 'real_name']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!toAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '转入账户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (!fromAccount.isActive() || !toAccount.isActive()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户状态异常,无法进行转账操作'
|
||||
});
|
||||
}
|
||||
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
// 检查余额是否充足
|
||||
if (!fromAccount.hasSufficientBalance(amountInCents)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户余额不足'
|
||||
});
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// 更新转出账户余额
|
||||
await fromAccount.update({
|
||||
balance: fromAccount.balance - amountInCents,
|
||||
available_balance: fromAccount.available_balance - amountInCents
|
||||
}, { transaction });
|
||||
|
||||
// 更新转入账户余额
|
||||
await toAccount.update({
|
||||
balance: toAccount.balance + amountInCents,
|
||||
available_balance: toAccount.available_balance + amountInCents
|
||||
}, { transaction });
|
||||
|
||||
const transactionNumber = await generateTransactionNumber();
|
||||
|
||||
// 创建转出交易记录
|
||||
await Transaction.create({
|
||||
transaction_number: transactionNumber,
|
||||
account_id: fromAccount.id,
|
||||
transaction_type: 'transfer_out',
|
||||
amount: amountInCents,
|
||||
balance_before: fromAccount.balance + amountInCents,
|
||||
balance_after: fromAccount.balance,
|
||||
counterparty_account: toAccount.account_number,
|
||||
counterparty_name: toAccount.user.real_name,
|
||||
description: description || `转账给${toAccount.user.real_name}`,
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
// 创建转入交易记录
|
||||
await Transaction.create({
|
||||
transaction_number: transactionNumber,
|
||||
account_id: toAccount.id,
|
||||
transaction_type: 'transfer_in',
|
||||
amount: amountInCents,
|
||||
balance_before: toAccount.balance - amountInCents,
|
||||
balance_after: toAccount.balance,
|
||||
counterparty_account: fromAccount.account_number,
|
||||
counterparty_name: fromAccount.user.real_name,
|
||||
description: description || `来自${fromAccount.user.real_name}的转账`,
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '转账成功',
|
||||
data: {
|
||||
transaction_number: transactionNumber,
|
||||
amount: amount,
|
||||
from_account: fromAccount.account_number,
|
||||
to_account: toAccount.account_number,
|
||||
to_account_holder: toAccount.user.real_name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转账错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 撤销交易
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.reverseTransaction = async (req, res) => {
|
||||
try {
|
||||
const { transactionId } = req.params;
|
||||
|
||||
const transaction = await Transaction.findByPk(transactionId, {
|
||||
include: [{
|
||||
model: Account,
|
||||
as: 'account'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '交易记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (req.user.role.name !== 'admin' && transaction.account.user_id !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权操作该交易'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否可以撤销
|
||||
if (!transaction.canReverse()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '该交易无法撤销'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await transaction.reverse();
|
||||
if (result) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: '交易撤销成功'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '交易撤销失败'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('撤销交易错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取交易统计
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getTransactionStats = async (req, res) => {
|
||||
try {
|
||||
const { start_date, end_date, account_id } = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
|
||||
// 普通用户只能查看自己账户的统计
|
||||
if (req.user.role.name !== 'admin') {
|
||||
const userAccounts = await Account.findAll({
|
||||
where: { user_id: req.user.id },
|
||||
attributes: ['id']
|
||||
});
|
||||
const accountIds = userAccounts.map(account => account.id);
|
||||
whereClause.account_id = { [Op.in]: accountIds };
|
||||
} else if (account_id) {
|
||||
whereClause.account_id = account_id;
|
||||
}
|
||||
|
||||
if (start_date || end_date) {
|
||||
whereClause.created_at = {};
|
||||
if (start_date) {
|
||||
whereClause.created_at[Op.gte] = new Date(start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
whereClause.created_at[Op.lte] = new Date(end_date);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交易统计
|
||||
const stats = await Transaction.findAll({
|
||||
where: whereClause,
|
||||
attributes: [
|
||||
'transaction_type',
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'count'],
|
||||
[sequelize.fn('SUM', sequelize.col('amount')), 'total_amount']
|
||||
],
|
||||
group: ['transaction_type'],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// 获取总交易数
|
||||
const totalCount = await Transaction.count({ where: whereClause });
|
||||
|
||||
// 获取总交易金额
|
||||
const totalAmount = await Transaction.sum('amount', { where: whereClause });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_count: totalCount,
|
||||
total_amount: totalAmount ? (totalAmount / 100).toFixed(2) : '0.00',
|
||||
by_type: stats.map(stat => ({
|
||||
type: stat.transaction_type,
|
||||
count: parseInt(stat.count),
|
||||
total_amount: (parseInt(stat.total_amount) / 100).toFixed(2)
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取交易统计错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成交易流水号
|
||||
* @returns {String} 交易流水号
|
||||
*/
|
||||
async function generateTransactionNumber() {
|
||||
const timestamp = Date.now().toString();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `TXN${timestamp}${random}`;
|
||||
}
|
||||
448
bank-backend/controllers/userController.js
Normal file
448
bank-backend/controllers/userController.js
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 用户控制器
|
||||
* @file userController.js
|
||||
* @description 处理用户相关的请求
|
||||
*/
|
||||
const { User, Role, Account } = require('../models');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.register = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { username, email, password, phone, real_name, id_card } = req.body;
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名已存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
const existingEmail = await User.findOne({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '邮箱已被注册'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查身份证是否已存在
|
||||
const existingIdCard = await User.findOne({
|
||||
where: { id_card }
|
||||
});
|
||||
|
||||
if (existingIdCard) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '身份证号已被注册'
|
||||
});
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
const user = await User.create({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
phone,
|
||||
real_name,
|
||||
id_card,
|
||||
role_id: 2 // 默认为普通用户
|
||||
});
|
||||
|
||||
// 生成JWT令牌
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '注册成功',
|
||||
data: {
|
||||
user: user.getSafeInfo(),
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('用户注册错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findOne({
|
||||
where: { username },
|
||||
include: [{
|
||||
model: Role,
|
||||
as: 'role'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (user.status !== 'active') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '账户已被禁用'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
if (user.locked_until && user.locked_until > new Date()) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '账户已被锁定,请稍后再试'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isValidPassword = await user.validPassword(password);
|
||||
if (!isValidPassword) {
|
||||
// 增加登录失败次数
|
||||
user.login_attempts += 1;
|
||||
|
||||
// 如果失败次数达到5次,锁定账户1小时
|
||||
if (user.login_attempts >= 5) {
|
||||
user.locked_until = new Date(Date.now() + 60 * 60 * 1000); // 1小时后解锁
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 登录成功,重置登录失败次数
|
||||
user.login_attempts = 0;
|
||||
user.locked_until = null;
|
||||
user.last_login = new Date();
|
||||
await user.save();
|
||||
|
||||
// 生成JWT令牌
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
user: user.getSafeInfo(),
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('用户登录错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
include: [{
|
||||
model: Role,
|
||||
as: 'role'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user.getSafeInfo()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户信息错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.updateProfile = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { phone, real_name, avatar } = req.body;
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
await user.update({
|
||||
phone: phone || user.phone,
|
||||
real_name: real_name || user.real_name,
|
||||
avatar: avatar || user.avatar
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '用户信息更新成功',
|
||||
data: user.getSafeInfo()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新用户信息错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.changePassword = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { old_password, new_password } = req.body;
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValidPassword = await user.validPassword(old_password);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '原密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
user.password = new_password;
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('修改密码错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户列表(管理员)
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
|
||||
const whereClause = {};
|
||||
if (search) {
|
||||
whereClause[Op.or] = [
|
||||
{ username: { [Op.like]: `%${search}%` } },
|
||||
{ email: { [Op.like]: `%${search}%` } },
|
||||
{ real_name: { [Op.like]: `%${search}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
const { count, rows } = await User.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: [{
|
||||
model: Role,
|
||||
as: 'role'
|
||||
}],
|
||||
limit,
|
||||
offset,
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: rows.map(user => user.getSafeInfo()),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
pages: Math.ceil(count / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户列表错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户状态(管理员)
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.updateUserStatus = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
await user.update({ status });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '用户状态更新成功',
|
||||
data: user.getSafeInfo()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新用户状态错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户账户列表
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
*/
|
||||
exports.getUserAccounts = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId || req.user.id;
|
||||
|
||||
// 检查权限:用户只能查看自己的账户,管理员可以查看所有账户
|
||||
if (userId !== req.user.id.toString() && req.user.role.name !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问该用户的账户信息'
|
||||
});
|
||||
}
|
||||
|
||||
const accounts = await Account.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accounts.map(account => ({
|
||||
...account.getSafeInfo(),
|
||||
balance_formatted: account.getBalanceFormatted(),
|
||||
available_balance_formatted: account.getAvailableBalanceFormatted(),
|
||||
frozen_amount_formatted: account.getFrozenAmountFormatted()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户账户列表错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
314
bank-backend/docs/database-schema.md
Normal file
314
bank-backend/docs/database-schema.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# 银行系统数据库架构设计
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了银行管理后台系统的数据库架构设计,包括表结构、字段定义、索引设计和关系约束。
|
||||
|
||||
## 数据库信息
|
||||
|
||||
- **数据库类型**: MySQL 8.0+
|
||||
- **字符集**: utf8mb4
|
||||
- **排序规则**: utf8mb4_unicode_ci
|
||||
- **时区**: +08:00 (Asia/Shanghai)
|
||||
|
||||
## 表结构设计
|
||||
|
||||
### 1. 用户表 (users)
|
||||
|
||||
存储银行系统的用户信息。
|
||||
|
||||
| 字段名 | 类型 | 约束 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | 用户ID |
|
||||
| username | VARCHAR(50) | UNIQUE, NOT NULL | 用户名 |
|
||||
| email | VARCHAR(100) | UNIQUE, NOT NULL | 邮箱地址 |
|
||||
| password | VARCHAR(255) | NOT NULL | 密码(bcrypt加密) |
|
||||
| phone | VARCHAR(20) | NULL | 手机号 |
|
||||
| real_name | VARCHAR(50) | NOT NULL | 真实姓名 |
|
||||
| id_card | VARCHAR(18) | UNIQUE, NOT NULL | 身份证号 |
|
||||
| avatar | VARCHAR(255) | NULL | 头像URL |
|
||||
| role_id | INT | FOREIGN KEY, NOT NULL | 角色ID |
|
||||
| status | ENUM | DEFAULT 'active' | 用户状态 |
|
||||
| last_login | DATETIME | NULL | 最后登录时间 |
|
||||
| login_attempts | INT | DEFAULT 0 | 登录失败次数 |
|
||||
| locked_until | DATETIME | NULL | 账户锁定到期时间 |
|
||||
| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 |
|
||||
|
||||
**索引设计**:
|
||||
- PRIMARY KEY (id)
|
||||
- UNIQUE KEY (username)
|
||||
- UNIQUE KEY (email)
|
||||
- UNIQUE KEY (id_card)
|
||||
- INDEX (role_id)
|
||||
- INDEX (status)
|
||||
- INDEX (created_at)
|
||||
|
||||
**状态枚举**:
|
||||
- `active`: 正常
|
||||
- `inactive`: 未激活
|
||||
- `suspended`: 暂停
|
||||
- `locked`: 锁定
|
||||
|
||||
### 2. 角色表 (roles)
|
||||
|
||||
存储系统角色信息。
|
||||
|
||||
| 字段名 | 类型 | 约束 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | 角色ID |
|
||||
| name | VARCHAR(50) | UNIQUE, NOT NULL | 角色名称 |
|
||||
| display_name | VARCHAR(100) | NOT NULL | 显示名称 |
|
||||
| description | TEXT | NULL | 角色描述 |
|
||||
| level | INT | NOT NULL, DEFAULT 1 | 角色级别 |
|
||||
| is_system | BOOLEAN | DEFAULT FALSE | 是否系统角色 |
|
||||
| status | ENUM | DEFAULT 'active' | 角色状态 |
|
||||
| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 |
|
||||
|
||||
**索引设计**:
|
||||
- PRIMARY KEY (id)
|
||||
- UNIQUE KEY (name)
|
||||
- INDEX (level)
|
||||
- INDEX (status)
|
||||
|
||||
**预定义角色**:
|
||||
- `admin`: 系统管理员 (level: 100)
|
||||
- `manager`: 银行经理 (level: 80)
|
||||
- `teller`: 银行柜员 (level: 60)
|
||||
- `user`: 普通用户 (level: 20)
|
||||
|
||||
### 3. 账户表 (accounts)
|
||||
|
||||
存储银行账户信息。
|
||||
|
||||
| 字段名 | 类型 | 约束 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | 账户ID |
|
||||
| account_number | VARCHAR(20) | UNIQUE, NOT NULL | 账户号码 |
|
||||
| user_id | INT | FOREIGN KEY, NOT NULL | 用户ID |
|
||||
| account_type | ENUM | NOT NULL, DEFAULT 'savings' | 账户类型 |
|
||||
| balance | BIGINT | NOT NULL, DEFAULT 0 | 账户余额(分) |
|
||||
| available_balance | BIGINT | NOT NULL, DEFAULT 0 | 可用余额(分) |
|
||||
| frozen_amount | BIGINT | NOT NULL, DEFAULT 0 | 冻结金额(分) |
|
||||
| currency | VARCHAR(3) | NOT NULL, DEFAULT 'CNY' | 货币类型 |
|
||||
| interest_rate | DECIMAL(5,4) | NULL | 利率(年化) |
|
||||
| status | ENUM | NOT NULL, DEFAULT 'active' | 账户状态 |
|
||||
| opened_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 开户时间 |
|
||||
| closed_at | DATETIME | NULL | 销户时间 |
|
||||
| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 |
|
||||
|
||||
**索引设计**:
|
||||
- PRIMARY KEY (id)
|
||||
- UNIQUE KEY (account_number)
|
||||
- INDEX (user_id)
|
||||
- INDEX (account_type)
|
||||
- INDEX (status)
|
||||
- INDEX (opened_at)
|
||||
|
||||
**账户类型枚举**:
|
||||
- `savings`: 储蓄账户
|
||||
- `checking`: 支票账户
|
||||
- `credit`: 信用卡账户
|
||||
- `loan`: 贷款账户
|
||||
|
||||
**账户状态枚举**:
|
||||
- `active`: 正常
|
||||
- `inactive`: 未激活
|
||||
- `frozen`: 冻结
|
||||
- `closed`: 已关闭
|
||||
|
||||
### 4. 交易记录表 (transactions)
|
||||
|
||||
存储所有银行交易记录。
|
||||
|
||||
| 字段名 | 类型 | 约束 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | 交易ID |
|
||||
| transaction_number | VARCHAR(32) | UNIQUE, NOT NULL | 交易流水号 |
|
||||
| account_id | INT | FOREIGN KEY, NOT NULL | 账户ID |
|
||||
| transaction_type | ENUM | NOT NULL | 交易类型 |
|
||||
| amount | BIGINT | NOT NULL | 交易金额(分) |
|
||||
| balance_before | BIGINT | NOT NULL | 交易前余额(分) |
|
||||
| balance_after | BIGINT | NOT NULL | 交易后余额(分) |
|
||||
| counterparty_account | VARCHAR(20) | NULL | 对方账户号 |
|
||||
| counterparty_name | VARCHAR(100) | NULL | 对方户名 |
|
||||
| description | VARCHAR(255) | NULL | 交易描述 |
|
||||
| reference_number | VARCHAR(50) | NULL | 参考号 |
|
||||
| status | ENUM | NOT NULL, DEFAULT 'pending' | 交易状态 |
|
||||
| processed_at | DATETIME | NULL | 处理时间 |
|
||||
| reversed_at | DATETIME | NULL | 撤销时间 |
|
||||
| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 |
|
||||
|
||||
**索引设计**:
|
||||
- PRIMARY KEY (id)
|
||||
- UNIQUE KEY (transaction_number)
|
||||
- INDEX (account_id)
|
||||
- INDEX (transaction_type)
|
||||
- INDEX (status)
|
||||
- INDEX (created_at)
|
||||
- INDEX (counterparty_account)
|
||||
|
||||
**交易类型枚举**:
|
||||
- `deposit`: 存款
|
||||
- `withdrawal`: 取款
|
||||
- `transfer_in`: 转入
|
||||
- `transfer_out`: 转出
|
||||
- `interest`: 利息
|
||||
- `fee`: 手续费
|
||||
- `loan`: 贷款
|
||||
- `repayment`: 还款
|
||||
|
||||
**交易状态枚举**:
|
||||
- `pending`: 处理中
|
||||
- `completed`: 已完成
|
||||
- `failed`: 失败
|
||||
- `cancelled`: 已取消
|
||||
- `reversed`: 已冲正
|
||||
|
||||
## 关系设计
|
||||
|
||||
### 外键约束
|
||||
|
||||
1. **users.role_id** → **roles.id**
|
||||
- 用户与角色的多对一关系
|
||||
- 级联更新,限制删除
|
||||
|
||||
2. **accounts.user_id** → **users.id**
|
||||
- 账户与用户的多对一关系
|
||||
- 级联更新,限制删除
|
||||
|
||||
3. **transactions.account_id** → **accounts.id**
|
||||
- 交易记录与账户的多对一关系
|
||||
- 级联更新,限制删除
|
||||
|
||||
### 业务约束
|
||||
|
||||
1. **账户余额约束**
|
||||
- balance >= 0
|
||||
- available_balance >= 0
|
||||
- frozen_amount >= 0
|
||||
- balance = available_balance + frozen_amount
|
||||
|
||||
2. **交易金额约束**
|
||||
- amount > 0
|
||||
- balance_after = balance_before ± amount
|
||||
|
||||
3. **用户状态约束**
|
||||
- 锁定用户不能登录
|
||||
- 暂停用户不能进行交易
|
||||
|
||||
## 数据完整性
|
||||
|
||||
### 触发器设计
|
||||
|
||||
1. **账户余额更新触发器**
|
||||
- 确保余额字段的一致性
|
||||
- 自动计算可用余额
|
||||
|
||||
2. **交易记录触发器**
|
||||
- 自动更新账户余额
|
||||
- 记录余额变化历史
|
||||
|
||||
### 存储过程
|
||||
|
||||
1. **转账处理存储过程**
|
||||
- 原子性转账操作
|
||||
- 自动生成交易记录
|
||||
|
||||
2. **利息计算存储过程**
|
||||
- 定期计算账户利息
|
||||
- 批量更新账户余额
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 分区策略
|
||||
|
||||
1. **交易记录表分区**
|
||||
- 按创建时间分区(按月)
|
||||
- 提高查询性能
|
||||
- 便于历史数据归档
|
||||
|
||||
### 索引优化
|
||||
|
||||
1. **复合索引**
|
||||
- (account_id, created_at): 账户交易查询
|
||||
- (transaction_type, status): 交易统计查询
|
||||
- (user_id, status): 用户状态查询
|
||||
|
||||
2. **覆盖索引**
|
||||
- 减少回表查询
|
||||
- 提高查询效率
|
||||
|
||||
## 数据安全
|
||||
|
||||
### 敏感数据加密
|
||||
|
||||
1. **密码加密**
|
||||
- 使用bcrypt算法
|
||||
- 盐值随机生成
|
||||
|
||||
2. **身份证号加密**
|
||||
- 存储时加密
|
||||
- 查询时解密
|
||||
|
||||
### 数据备份
|
||||
|
||||
1. **全量备份**
|
||||
- 每日凌晨自动备份
|
||||
- 保留30天历史
|
||||
|
||||
2. **增量备份**
|
||||
- 每小时增量备份
|
||||
- 实时同步到备库
|
||||
|
||||
## 监控与维护
|
||||
|
||||
### 性能监控
|
||||
|
||||
1. **慢查询监控**
|
||||
- 记录执行时间>1s的查询
|
||||
- 定期优化慢查询
|
||||
|
||||
2. **连接数监控**
|
||||
- 监控数据库连接数
|
||||
- 防止连接池耗尽
|
||||
|
||||
### 数据清理
|
||||
|
||||
1. **日志清理**
|
||||
- 定期清理过期日志
|
||||
- 保留关键操作记录
|
||||
|
||||
2. **历史数据归档**
|
||||
- 超过1年的交易记录归档
|
||||
- 减少主表数据量
|
||||
|
||||
## 扩展性设计
|
||||
|
||||
### 水平扩展
|
||||
|
||||
1. **读写分离**
|
||||
- 主库写入,从库读取
|
||||
- 提高系统并发能力
|
||||
|
||||
2. **分库分表**
|
||||
- 按用户ID分库
|
||||
- 按时间分表
|
||||
|
||||
### 垂直扩展
|
||||
|
||||
1. **字段扩展**
|
||||
- 预留扩展字段
|
||||
- 支持业务需求变化
|
||||
|
||||
2. **表结构扩展**
|
||||
- 模块化表设计
|
||||
- 支持功能模块独立
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2025-01-18*
|
||||
*版本: v1.0*
|
||||
45
bank-backend/env.example
Normal file
45
bank-backend/env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# 服务器配置
|
||||
PORT=5351
|
||||
NODE_ENV=development
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=bank_management
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_DIALECT=mysql
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your_email@gmail.com
|
||||
SMTP_PASS=your_app_password
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_MAX_SIZE=10485760
|
||||
UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,application/pdf
|
||||
|
||||
# 安全配置
|
||||
BCRYPT_ROUNDS=10
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# 银行系统配置
|
||||
BANK_CODE=001
|
||||
BANK_NAME=示例银行
|
||||
CURRENCY=CNY
|
||||
TIMEZONE=Asia/Shanghai
|
||||
226
bank-backend/middleware/auth.js
Normal file
226
bank-backend/middleware/auth.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 认证中间件
|
||||
* @file auth.js
|
||||
* @description 处理用户认证和授权
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User, Role } = require('../models');
|
||||
|
||||
/**
|
||||
* 验证JWT令牌
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
* @param {Function} next 下一个中间件
|
||||
*/
|
||||
const verifyToken = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '访问被拒绝,未提供令牌'
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findByPk(decoded.id, {
|
||||
include: [{
|
||||
model: Role,
|
||||
as: 'role'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '令牌无效,用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.status !== 'active') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '账户已被禁用'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '令牌已过期'
|
||||
});
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '令牌无效'
|
||||
});
|
||||
} else {
|
||||
console.error('认证中间件错误:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户角色权限
|
||||
* @param {String|Array} roles 允许的角色
|
||||
* @returns {Function} 中间件函数
|
||||
*/
|
||||
const requireRole = (roles) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '请先登录'
|
||||
});
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
if (!userRole) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '用户角色未分配'
|
||||
});
|
||||
}
|
||||
|
||||
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
||||
if (!allowedRoles.includes(userRole.name)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '权限不足'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('角色权限检查错误:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户权限级别
|
||||
* @param {Number} minLevel 最小权限级别
|
||||
* @returns {Function} 中间件函数
|
||||
*/
|
||||
const requireLevel = (minLevel) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '请先登录'
|
||||
});
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
if (!userRole || userRole.level < minLevel) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '权限级别不足'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('权限级别检查错误:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 可选认证中间件(不强制要求登录)
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
* @param {Function} next 下一个中间件
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findByPk(decoded.id, {
|
||||
include: [{
|
||||
model: Role,
|
||||
as: 'role'
|
||||
}]
|
||||
});
|
||||
|
||||
if (user && user.status === 'active') {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// 可选认证失败时不返回错误,继续执行
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查账户所有权
|
||||
* @param {Object} req 请求对象
|
||||
* @param {Object} res 响应对象
|
||||
* @param {Function} next 下一个中间件
|
||||
*/
|
||||
const checkAccountOwnership = async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 管理员可以访问所有账户
|
||||
if (req.user.role && req.user.role.name === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { Account } = require('../models');
|
||||
const account = await Account.findOne({
|
||||
where: {
|
||||
id: accountId,
|
||||
user_id: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问该账户'
|
||||
});
|
||||
}
|
||||
|
||||
req.account = account;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('账户所有权检查错误:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyToken,
|
||||
requireRole,
|
||||
requireLevel,
|
||||
optionalAuth,
|
||||
checkAccountOwnership
|
||||
};
|
||||
239
bank-backend/middleware/security.js
Normal file
239
bank-backend/middleware/security.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 安全中间件
|
||||
* @file security.js
|
||||
* @description 处理安全相关的中间件
|
||||
*/
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
/**
|
||||
* API请求频率限制
|
||||
*/
|
||||
const apiRateLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15分钟
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // 限制每个IP 15分钟内最多100个请求
|
||||
message: {
|
||||
success: false,
|
||||
message: '请求过于频繁,请稍后再试'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
message: '请求过于频繁,请稍后再试'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 登录请求频率限制(更严格)
|
||||
*/
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15分钟
|
||||
max: 5, // 限制每个IP 15分钟内最多5次登录尝试
|
||||
message: {
|
||||
success: false,
|
||||
message: '登录尝试次数过多,请15分钟后再试'
|
||||
},
|
||||
skipSuccessfulRequests: true,
|
||||
handler: (req, res) => {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
message: '登录尝试次数过多,请15分钟后再试'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 安全头部设置
|
||||
*/
|
||||
const securityHeaders = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"]
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false
|
||||
});
|
||||
|
||||
/**
|
||||
* 输入数据清理
|
||||
*/
|
||||
const inputSanitizer = (req, res, next) => {
|
||||
// 清理请求体中的危险字符
|
||||
const sanitizeObject = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) return obj;
|
||||
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === 'string') {
|
||||
// 移除潜在的XSS攻击字符
|
||||
obj[key] = obj[key]
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '');
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
sanitizeObject(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (req.body) sanitizeObject(req.body);
|
||||
if (req.query) sanitizeObject(req.query);
|
||||
if (req.params) sanitizeObject(req.params);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 会话超时检查
|
||||
*/
|
||||
const sessionTimeoutCheck = (req, res, next) => {
|
||||
if (req.user && req.user.last_login) {
|
||||
const lastLogin = new Date(req.user.last_login);
|
||||
const now = new Date();
|
||||
const timeout = 24 * 60 * 60 * 1000; // 24小时
|
||||
|
||||
if (now - lastLogin > timeout) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '会话已超时,请重新登录'
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证错误处理中间件
|
||||
*/
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '输入数据验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 银行账户验证规则
|
||||
*/
|
||||
const validateAccountNumber = [
|
||||
body('account_number')
|
||||
.isLength({ min: 16, max: 20 })
|
||||
.withMessage('账户号码长度必须在16-20位之间')
|
||||
.matches(/^\d+$/)
|
||||
.withMessage('账户号码只能包含数字'),
|
||||
handleValidationErrors
|
||||
];
|
||||
|
||||
/**
|
||||
* 金额验证规则
|
||||
*/
|
||||
const validateAmount = [
|
||||
body('amount')
|
||||
.isFloat({ min: 0.01 })
|
||||
.withMessage('金额必须大于0')
|
||||
.custom((value) => {
|
||||
// 检查金额精度(最多2位小数)
|
||||
if (value.toString().split('.')[1] && value.toString().split('.')[1].length > 2) {
|
||||
throw new Error('金额最多支持2位小数');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handleValidationErrors
|
||||
];
|
||||
|
||||
/**
|
||||
* 身份证号验证规则
|
||||
*/
|
||||
const validateIdCard = [
|
||||
body('id_card')
|
||||
.matches(/^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/)
|
||||
.withMessage('身份证号码格式不正确'),
|
||||
handleValidationErrors
|
||||
];
|
||||
|
||||
/**
|
||||
* 手机号验证规则
|
||||
*/
|
||||
const validatePhone = [
|
||||
body('phone')
|
||||
.matches(/^1[3-9]\d{9}$/)
|
||||
.withMessage('手机号码格式不正确'),
|
||||
handleValidationErrors
|
||||
];
|
||||
|
||||
/**
|
||||
* 密码验证规则
|
||||
*/
|
||||
const validatePassword = [
|
||||
body('password')
|
||||
.isLength({ min: 6, max: 20 })
|
||||
.withMessage('密码长度必须在6-20位之间')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('密码必须包含大小写字母和数字'),
|
||||
handleValidationErrors
|
||||
];
|
||||
|
||||
/**
|
||||
* 防止SQL注入的查询参数验证
|
||||
*/
|
||||
const validateQueryParams = (req, res, next) => {
|
||||
const dangerousPatterns = [
|
||||
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b)/i,
|
||||
/(--|\/\*|\*\/|xp_|sp_)/i,
|
||||
/(\bOR\b|\bAND\b).*(\bOR\b|\bAND\b)/i
|
||||
];
|
||||
|
||||
const checkObject = (obj) => {
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === 'string') {
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(obj[key])) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '检测到潜在的安全威胁'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
checkObject(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(req.query);
|
||||
checkObject(req.body);
|
||||
checkObject(req.params);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apiRateLimiter,
|
||||
loginRateLimiter,
|
||||
securityHeaders,
|
||||
inputSanitizer,
|
||||
sessionTimeoutCheck,
|
||||
handleValidationErrors,
|
||||
validateAccountNumber,
|
||||
validateAmount,
|
||||
validateIdCard,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateQueryParams
|
||||
};
|
||||
224
bank-backend/models/Account.js
Normal file
224
bank-backend/models/Account.js
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 账户模型
|
||||
* @file Account.js
|
||||
* @description 银行账户模型定义
|
||||
*/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const BaseModel = require('./BaseModel');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
class Account extends BaseModel {
|
||||
/**
|
||||
* 获取账户余额(元)
|
||||
* @returns {String} 格式化后的余额
|
||||
*/
|
||||
getBalanceFormatted() {
|
||||
return this.formatAmount(this.balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用余额(元)
|
||||
* @returns {String} 格式化后的可用余额
|
||||
*/
|
||||
getAvailableBalanceFormatted() {
|
||||
return this.formatAmount(this.available_balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取冻结金额(元)
|
||||
* @returns {String} 格式化后的冻结金额
|
||||
*/
|
||||
getFrozenAmountFormatted() {
|
||||
return this.formatAmount(this.frozen_amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否可用
|
||||
* @returns {Boolean} 是否可用
|
||||
*/
|
||||
isActive() {
|
||||
return this.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查余额是否充足
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {Boolean} 余额是否充足
|
||||
*/
|
||||
hasSufficientBalance(amount) {
|
||||
return this.available_balance >= amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结资金
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {Promise<Boolean>} 操作结果
|
||||
*/
|
||||
async freezeAmount(amount) {
|
||||
if (!this.hasSufficientBalance(amount)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.available_balance -= amount;
|
||||
this.frozen_amount += amount;
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解冻资金
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {Promise<Boolean>} 操作结果
|
||||
*/
|
||||
async unfreezeAmount(amount) {
|
||||
if (this.frozen_amount < amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.available_balance += amount;
|
||||
this.frozen_amount -= amount;
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减余额
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {Promise<Boolean>} 操作结果
|
||||
*/
|
||||
async deductBalance(amount) {
|
||||
if (!this.hasSufficientBalance(amount)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.balance -= amount;
|
||||
this.available_balance -= amount;
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加余额
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {Promise<Boolean>} 操作结果
|
||||
*/
|
||||
async addBalance(amount) {
|
||||
this.balance += amount;
|
||||
this.available_balance += amount;
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户交易记录
|
||||
* @param {Object} options 查询选项
|
||||
* @returns {Promise<Array>} 交易记录列表
|
||||
*/
|
||||
async getTransactions(options = {}) {
|
||||
try {
|
||||
const { Transaction } = require('./index');
|
||||
return await Transaction.findAll({
|
||||
where: {
|
||||
account_id: this.id,
|
||||
...options.where
|
||||
},
|
||||
order: [['created_at', 'DESC']],
|
||||
limit: options.limit || 50,
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取账户交易记录失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Account模型
|
||||
Account.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
account_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: '账户号码'
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'bank_users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
account_type: {
|
||||
type: DataTypes.ENUM('savings', 'checking', 'credit', 'loan'),
|
||||
allowNull: false,
|
||||
defaultValue: 'savings',
|
||||
comment: '账户类型:储蓄、支票、信用卡、贷款'
|
||||
},
|
||||
balance: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '账户余额(分)'
|
||||
},
|
||||
available_balance: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '可用余额(分)'
|
||||
},
|
||||
frozen_amount: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '冻结金额(分)'
|
||||
},
|
||||
currency: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'CNY',
|
||||
comment: '货币类型'
|
||||
},
|
||||
interest_rate: {
|
||||
type: DataTypes.DECIMAL(5, 4),
|
||||
allowNull: true,
|
||||
comment: '利率(年化)'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'frozen', 'closed'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
opened_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: '开户时间'
|
||||
},
|
||||
closed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '销户时间'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
tableName: 'bank_accounts',
|
||||
modelName: 'Account'
|
||||
});
|
||||
|
||||
module.exports = Account;
|
||||
108
bank-backend/models/BaseModel.js
Normal file
108
bank-backend/models/BaseModel.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 基础模型类
|
||||
* @file BaseModel.js
|
||||
* @description 所有模型的基类,提供通用方法
|
||||
*/
|
||||
const { Model } = require('sequelize');
|
||||
|
||||
class BaseModel extends Model {
|
||||
/**
|
||||
* 获取模型的安全信息(排除敏感字段)
|
||||
* @param {Array} excludeFields 要排除的字段
|
||||
* @returns {Object} 安全信息对象
|
||||
*/
|
||||
getSafeInfo(excludeFields = ['password', 'pin', 'secret']) {
|
||||
const data = this.get({ plain: true });
|
||||
excludeFields.forEach(field => {
|
||||
delete data[field];
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为JSON格式
|
||||
* @param {Array} excludeFields 要排除的字段
|
||||
* @returns {Object} JSON对象
|
||||
*/
|
||||
toJSON(excludeFields = ['password', 'pin', 'secret']) {
|
||||
return this.getSafeInfo(excludeFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(分转元)
|
||||
* @param {Number} amount 金额(分)
|
||||
* @returns {String} 格式化后的金额
|
||||
*/
|
||||
formatAmount(amount) {
|
||||
if (amount === null || amount === undefined) return '0.00';
|
||||
return (amount / 100).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析金额(元转分)
|
||||
* @param {String|Number} amount 金额(元)
|
||||
* @returns {Number} 金额(分)
|
||||
*/
|
||||
parseAmount(amount) {
|
||||
if (typeof amount === 'string') {
|
||||
return Math.round(parseFloat(amount) * 100);
|
||||
}
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param {Date} date 日期
|
||||
* @param {String} format 格式
|
||||
* @returns {String} 格式化后的日期
|
||||
*/
|
||||
formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!date) return null;
|
||||
const moment = require('moment');
|
||||
return moment(date).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字段是否已更改
|
||||
* @param {String} field 字段名
|
||||
* @returns {Boolean} 是否已更改
|
||||
*/
|
||||
isFieldChanged(field) {
|
||||
return this.changed(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始值
|
||||
* @param {String} field 字段名
|
||||
* @returns {*} 原始值
|
||||
*/
|
||||
getOriginalValue(field) {
|
||||
return this._previousDataValues[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除(如果模型支持)
|
||||
*/
|
||||
async softDelete() {
|
||||
if (this.constructor.rawAttributes.deleted_at) {
|
||||
this.deleted_at = new Date();
|
||||
await this.save();
|
||||
} else {
|
||||
throw new Error('模型不支持软删除');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复软删除(如果模型支持)
|
||||
*/
|
||||
async restore() {
|
||||
if (this.constructor.rawAttributes.deleted_at) {
|
||||
this.deleted_at = null;
|
||||
await this.save();
|
||||
} else {
|
||||
throw new Error('模型不支持软删除');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseModel;
|
||||
113
bank-backend/models/Role.js
Normal file
113
bank-backend/models/Role.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 角色模型
|
||||
* @file Role.js
|
||||
* @description 银行系统角色模型定义
|
||||
*/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const BaseModel = require('./BaseModel');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
class Role extends BaseModel {
|
||||
/**
|
||||
* 获取角色权限
|
||||
* @returns {Promise<Array>} 权限列表
|
||||
*/
|
||||
async getPermissions() {
|
||||
try {
|
||||
const { Permission } = require('./index');
|
||||
const rolePermissions = await this.getPermissions();
|
||||
return rolePermissions.map(rp => rp.Permission);
|
||||
} catch (error) {
|
||||
console.error('获取角色权限失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查角色是否具有指定权限
|
||||
* @param {String|Array} permissionName 权限名称或权限名称数组
|
||||
* @returns {Promise<Boolean>} 检查结果
|
||||
*/
|
||||
async hasPermission(permissionName) {
|
||||
const permissions = await this.getPermissions();
|
||||
const permissionNames = permissions.map(permission => permission.name);
|
||||
|
||||
if (Array.isArray(permissionName)) {
|
||||
return permissionName.some(name => permissionNames.includes(name));
|
||||
}
|
||||
|
||||
return permissionNames.includes(permissionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色用户列表
|
||||
* @returns {Promise<Array>} 用户列表
|
||||
*/
|
||||
async getUsers() {
|
||||
try {
|
||||
const { User } = require('./index');
|
||||
return await User.findAll({
|
||||
where: { role_id: this.id }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取角色用户失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Role模型
|
||||
Role.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
len: [2, 50]
|
||||
}
|
||||
},
|
||||
display_name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
level: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '角色级别,数字越大权限越高'
|
||||
},
|
||||
is_system: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否为系统角色'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive'),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
tableName: 'bank_roles',
|
||||
modelName: 'Role'
|
||||
});
|
||||
|
||||
module.exports = Role;
|
||||
210
bank-backend/models/Transaction.js
Normal file
210
bank-backend/models/Transaction.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 交易记录模型
|
||||
* @file Transaction.js
|
||||
* @description 银行交易记录模型定义
|
||||
*/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const BaseModel = require('./BaseModel');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
class Transaction extends BaseModel {
|
||||
/**
|
||||
* 获取交易金额(元)
|
||||
* @returns {String} 格式化后的金额
|
||||
*/
|
||||
getAmountFormatted() {
|
||||
return this.formatAmount(this.amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易后余额(元)
|
||||
* @returns {String} 格式化后的余额
|
||||
*/
|
||||
getBalanceAfterFormatted() {
|
||||
return this.formatAmount(this.balance_after);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为收入交易
|
||||
* @returns {Boolean} 是否为收入
|
||||
*/
|
||||
isIncome() {
|
||||
return this.transaction_type === 'deposit' ||
|
||||
this.transaction_type === 'transfer_in' ||
|
||||
this.transaction_type === 'interest';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为支出交易
|
||||
* @returns {Boolean} 是否为支出
|
||||
*/
|
||||
isExpense() {
|
||||
return this.transaction_type === 'withdrawal' ||
|
||||
this.transaction_type === 'transfer_out' ||
|
||||
this.transaction_type === 'fee';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易状态描述
|
||||
* @returns {String} 状态描述
|
||||
*/
|
||||
getStatusDescription() {
|
||||
const statusMap = {
|
||||
'pending': '处理中',
|
||||
'completed': '已完成',
|
||||
'failed': '失败',
|
||||
'cancelled': '已取消',
|
||||
'reversed': '已冲正'
|
||||
};
|
||||
return statusMap[this.status] || '未知状态';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易类型描述
|
||||
* @returns {String} 类型描述
|
||||
*/
|
||||
getTypeDescription() {
|
||||
const typeMap = {
|
||||
'deposit': '存款',
|
||||
'withdrawal': '取款',
|
||||
'transfer_in': '转入',
|
||||
'transfer_out': '转出',
|
||||
'interest': '利息',
|
||||
'fee': '手续费',
|
||||
'loan': '贷款',
|
||||
'repayment': '还款'
|
||||
};
|
||||
return typeMap[this.transaction_type] || '未知类型';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查交易是否可以撤销
|
||||
* @returns {Boolean} 是否可以撤销
|
||||
*/
|
||||
canReverse() {
|
||||
return this.status === 'completed' &&
|
||||
this.transaction_type !== 'fee' &&
|
||||
this.created_at > new Date(Date.now() - 24 * 60 * 60 * 1000); // 24小时内
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销交易
|
||||
* @returns {Promise<Boolean>} 操作结果
|
||||
*/
|
||||
async reverse() {
|
||||
if (!this.canReverse()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新交易状态
|
||||
this.status = 'reversed';
|
||||
this.reversed_at = new Date();
|
||||
await this.save();
|
||||
|
||||
// 这里应该创建反向交易记录
|
||||
// 实际实现中需要更复杂的逻辑
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('撤销交易失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Transaction模型
|
||||
Transaction.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
transaction_number: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: '交易流水号'
|
||||
},
|
||||
account_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'bank_accounts',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
transaction_type: {
|
||||
type: DataTypes.ENUM(
|
||||
'deposit', 'withdrawal', 'transfer_in', 'transfer_out',
|
||||
'interest', 'fee', 'loan', 'repayment'
|
||||
),
|
||||
allowNull: false,
|
||||
comment: '交易类型'
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
comment: '交易金额(分)'
|
||||
},
|
||||
balance_before: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
comment: '交易前余额(分)'
|
||||
},
|
||||
balance_after: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
comment: '交易后余额(分)'
|
||||
},
|
||||
counterparty_account: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
comment: '对方账户号'
|
||||
},
|
||||
counterparty_name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: '对方户名'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: '交易描述'
|
||||
},
|
||||
reference_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
comment: '参考号'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'reversed'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
processed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '处理时间'
|
||||
},
|
||||
reversed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '撤销时间'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
tableName: 'bank_transactions',
|
||||
modelName: 'Transaction'
|
||||
});
|
||||
|
||||
module.exports = Transaction;
|
||||
183
bank-backend/models/User.js
Normal file
183
bank-backend/models/User.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 用户模型
|
||||
* @file User.js
|
||||
* @description 银行系统用户模型定义
|
||||
*/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const BaseModel = require('./BaseModel');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
class User extends BaseModel {
|
||||
/**
|
||||
* 验证密码
|
||||
* @param {String} password 待验证的密码
|
||||
* @returns {Promise<Boolean>} 验证结果
|
||||
*/
|
||||
async validPassword(password) {
|
||||
return await bcrypt.compare(password, this.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色
|
||||
* @returns {Promise<Array>} 用户角色列表
|
||||
*/
|
||||
async getRoles() {
|
||||
try {
|
||||
const { Role } = require('./index');
|
||||
const role = await Role.findByPk(this.role_id);
|
||||
return role ? [role] : [];
|
||||
} catch (error) {
|
||||
console.error('获取用户角色失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否具有指定角色
|
||||
* @param {String|Array} roleName 角色名称或角色名称数组
|
||||
* @returns {Promise<Boolean>} 检查结果
|
||||
*/
|
||||
async hasRole(roleName) {
|
||||
const roles = await this.getRoles();
|
||||
const roleNames = roles.map(role => role.name);
|
||||
|
||||
if (Array.isArray(roleName)) {
|
||||
return roleName.some(name => roleNames.includes(name));
|
||||
}
|
||||
|
||||
return roleNames.includes(roleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户账户列表
|
||||
* @returns {Promise<Array>} 账户列表
|
||||
*/
|
||||
async getAccounts() {
|
||||
try {
|
||||
const { Account } = require('./index');
|
||||
return await Account.findAll({
|
||||
where: { user_id: this.id, status: 'active' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户账户失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户安全信息(不包含密码)
|
||||
* @returns {Object} 用户安全信息
|
||||
*/
|
||||
getSafeInfo() {
|
||||
return super.getSafeInfo(['password', 'pin']);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化User模型
|
||||
User.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
len: [3, 50]
|
||||
}
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true
|
||||
}
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
validate: {
|
||||
len: [6, 255]
|
||||
}
|
||||
},
|
||||
phone: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
validate: {
|
||||
is: /^1[3-9]\d{9}$/
|
||||
}
|
||||
},
|
||||
real_name: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
id_card: {
|
||||
type: DataTypes.STRING(18),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
is: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
|
||||
}
|
||||
},
|
||||
avatar: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
role_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 2, // 默认为普通用户角色ID
|
||||
references: {
|
||||
model: 'bank_roles',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'locked'),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
last_login: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
login_attempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
locked_until: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
tableName: 'bank_users',
|
||||
modelName: 'User',
|
||||
hooks: {
|
||||
beforeCreate: async (user) => {
|
||||
if (user.password) {
|
||||
user.password = await bcrypt.hash(user.password, 10);
|
||||
}
|
||||
},
|
||||
beforeUpdate: async (user) => {
|
||||
if (user.changed('password')) {
|
||||
user.password = await bcrypt.hash(user.password, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = User;
|
||||
60
bank-backend/models/index.js
Normal file
60
bank-backend/models/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 模型索引文件
|
||||
* @file index.js
|
||||
* @description 导出所有模型并建立关联关系
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
// 导入所有模型
|
||||
const User = require('./User');
|
||||
const Role = require('./Role');
|
||||
const Account = require('./Account');
|
||||
const Transaction = require('./Transaction');
|
||||
|
||||
// 定义模型关联关系
|
||||
|
||||
// 用户与角色关联
|
||||
User.belongsTo(Role, {
|
||||
foreignKey: 'role_id',
|
||||
as: 'role',
|
||||
targetKey: 'id'
|
||||
});
|
||||
|
||||
Role.hasMany(User, {
|
||||
foreignKey: 'role_id',
|
||||
as: 'users'
|
||||
});
|
||||
|
||||
// 用户与账户关联
|
||||
User.hasMany(Account, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'accounts'
|
||||
});
|
||||
|
||||
Account.belongsTo(User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user'
|
||||
});
|
||||
|
||||
// 账户与交易记录关联
|
||||
Account.hasMany(Transaction, {
|
||||
foreignKey: 'account_id',
|
||||
as: 'transactions'
|
||||
});
|
||||
|
||||
Transaction.belongsTo(Account, {
|
||||
foreignKey: 'account_id',
|
||||
as: 'account'
|
||||
});
|
||||
|
||||
// 交易记录与用户关联(通过账户)
|
||||
// 移除不合理的Transaction->User through Account的belongsTo定义,避免错误外键映射
|
||||
|
||||
// 导出所有模型和数据库实例
|
||||
module.exports = {
|
||||
sequelize,
|
||||
User,
|
||||
Role,
|
||||
Account,
|
||||
Transaction
|
||||
};
|
||||
10667
bank-backend/package-lock.json
generated
Normal file
10667
bank-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
114
bank-backend/package.json
Normal file
114
bank-backend/package.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"name": "bank-management-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "银行管理后台系统后端API服务",
|
||||
"main": "server.js",
|
||||
"author": "Bank Development Team",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"nodejs",
|
||||
"express",
|
||||
"sequelize",
|
||||
"mysql",
|
||||
"api",
|
||||
"banking",
|
||||
"financial",
|
||||
"management"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"init-db": "node scripts/init-db.js",
|
||||
"test-connection": "node scripts/test-connection.js",
|
||||
"migrate": "node scripts/migration-manager.js",
|
||||
"seed": "node scripts/seed-manager.js",
|
||||
"backup": "node scripts/backup-db.js",
|
||||
"restore": "node scripts/restore-db.js",
|
||||
"lint": "eslint . --ext .js",
|
||||
"lint:fix": "eslint . --ext .js --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"build": "echo 'No build step required for Node.js backend'",
|
||||
"clean": "node -e \"const fs = require('fs'); const path = require('path'); try { const logDir = 'logs'; const tempDir = 'uploads/temp'; if (fs.existsSync(logDir)) { fs.readdirSync(logDir).forEach(file => { if (file.endsWith('.log')) fs.unlinkSync(path.join(logDir, file)); }); } if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } console.log('✅ Cleanup completed'); } catch (err) { console.error('❌ Cleanup failed:', err.message); }\"",
|
||||
"health-check": "node -e \"const { sequelize } = require('./config/database'); sequelize.authenticate().then(() => { console.log('✅ Database connection healthy'); process.exit(0); }).catch(err => { console.error('❌ Database connection failed:', err.message); process.exit(1); });\""
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.8",
|
||||
"redis": "^4.6.12",
|
||||
"sequelize": "^6.35.2",
|
||||
"sharp": "^0.33.2",
|
||||
"socket.io": "^4.7.4",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"winston": "^3.11.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^6.3.3",
|
||||
"rimraf": "^5.0.5",
|
||||
"@types/jest": "^29.5.8"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"collectCoverageFrom": [
|
||||
"controllers/**/*.js",
|
||||
"models/**/*.js",
|
||||
"routes/**/*.js",
|
||||
"utils/**/*.js",
|
||||
"!**/node_modules/**",
|
||||
"!**/migrations/**",
|
||||
"!**/seeds/**"
|
||||
],
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageReporters": ["text", "lcov", "html"]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": ["standard"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2021": true,
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-unused-vars": "error",
|
||||
"prefer-const": "error"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bank-management/bank-backend.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/bank-management/bank-backend/issues"
|
||||
},
|
||||
"homepage": "https://github.com/bank-management/bank-backend#readme"
|
||||
}
|
||||
322
bank-backend/routes/accounts.js
Normal file
322
bank-backend/routes/accounts.js
Normal file
@@ -0,0 +1,322 @@
|
||||
const express = require('express');
|
||||
const { verifyToken, requireRole, checkAccountOwnership } = require('../middleware/auth');
|
||||
const {
|
||||
validateAccountNumber,
|
||||
validateAmount,
|
||||
handleValidationErrors
|
||||
} = require('../middleware/security');
|
||||
const router = express.Router();
|
||||
const accountController = require('../controllers/accountController');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Accounts
|
||||
* description: 账户管理
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Account:
|
||||
* type: object
|
||||
* required:
|
||||
* - user_id
|
||||
* - account_type
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* account_number:
|
||||
* type: string
|
||||
* description: 账户号码
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* account_type:
|
||||
* type: string
|
||||
* enum: [savings, checking, credit, loan]
|
||||
* description: 账户类型
|
||||
* balance:
|
||||
* type: integer
|
||||
* description: 账户余额(分)
|
||||
* available_balance:
|
||||
* type: integer
|
||||
* description: 可用余额(分)
|
||||
* frozen_amount:
|
||||
* type: integer
|
||||
* description: 冻结金额(分)
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, frozen, closed]
|
||||
* description: 账户状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts:
|
||||
* post:
|
||||
* summary: 创建账户
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - user_id
|
||||
* - account_type
|
||||
* properties:
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* account_type:
|
||||
* type: string
|
||||
* enum: [savings, checking, credit, loan]
|
||||
* description: 账户类型
|
||||
* initial_balance:
|
||||
* type: number
|
||||
* description: 初始余额(元)
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 创建成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.post('/',
|
||||
verifyToken,
|
||||
requireRole(['admin', 'manager']),
|
||||
accountController.createAccount
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts:
|
||||
* get:
|
||||
* summary: 获取账户列表
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: user_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID(管理员)
|
||||
* - in: query
|
||||
* name: account_type
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [savings, checking, credit, loan]
|
||||
* description: 账户类型
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, inactive, frozen, closed]
|
||||
* description: 账户状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.get('/',
|
||||
verifyToken,
|
||||
accountController.getAccounts
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts/{accountId}:
|
||||
* get:
|
||||
* summary: 获取账户详情
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: accountId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 账户不存在
|
||||
*/
|
||||
router.get('/:accountId',
|
||||
verifyToken,
|
||||
checkAccountOwnership,
|
||||
accountController.getAccountDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts/{accountId}/status:
|
||||
* put:
|
||||
* summary: 更新账户状态
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: accountId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, frozen, closed]
|
||||
* description: 账户状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 账户不存在
|
||||
*/
|
||||
router.put('/:accountId/status',
|
||||
verifyToken,
|
||||
requireRole(['admin', 'manager']),
|
||||
accountController.updateAccountStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts/{accountId}/deposit:
|
||||
* post:
|
||||
* summary: 存款
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: accountId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - amount
|
||||
* properties:
|
||||
* amount:
|
||||
* type: number
|
||||
* description: 存款金额(元)
|
||||
* description:
|
||||
* type: string
|
||||
* description: 交易描述
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 存款成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败或账户状态异常
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 账户不存在
|
||||
*/
|
||||
router.post('/:accountId/deposit',
|
||||
verifyToken,
|
||||
requireRole(['admin', 'manager', 'teller']),
|
||||
validateAmount,
|
||||
accountController.deposit
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/accounts/{accountId}/withdraw:
|
||||
* post:
|
||||
* summary: 取款
|
||||
* tags: [Accounts]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: accountId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - amount
|
||||
* properties:
|
||||
* amount:
|
||||
* type: number
|
||||
* description: 取款金额(元)
|
||||
* description:
|
||||
* type: string
|
||||
* description: 交易描述
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 取款成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败、账户状态异常或余额不足
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 账户不存在
|
||||
*/
|
||||
router.post('/:accountId/withdraw',
|
||||
verifyToken,
|
||||
requireRole(['admin', 'manager', 'teller']),
|
||||
validateAmount,
|
||||
accountController.withdraw
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
287
bank-backend/routes/transactions.js
Normal file
287
bank-backend/routes/transactions.js
Normal file
@@ -0,0 +1,287 @@
|
||||
const express = require('express');
|
||||
const { verifyToken, requireRole } = require('../middleware/auth');
|
||||
const {
|
||||
validateAmount,
|
||||
validateAccountNumber,
|
||||
handleValidationErrors
|
||||
} = require('../middleware/security');
|
||||
const router = express.Router();
|
||||
const transactionController = require('../controllers/transactionController');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Transactions
|
||||
* description: 交易管理
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Transaction:
|
||||
* type: object
|
||||
* required:
|
||||
* - account_id
|
||||
* - transaction_type
|
||||
* - amount
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 交易ID
|
||||
* transaction_number:
|
||||
* type: string
|
||||
* description: 交易流水号
|
||||
* account_id:
|
||||
* type: integer
|
||||
* description: 账户ID
|
||||
* transaction_type:
|
||||
* type: string
|
||||
* enum: [deposit, withdrawal, transfer_in, transfer_out, interest, fee, loan, repayment]
|
||||
* description: 交易类型
|
||||
* amount:
|
||||
* type: integer
|
||||
* description: 交易金额(分)
|
||||
* balance_before:
|
||||
* type: integer
|
||||
* description: 交易前余额(分)
|
||||
* balance_after:
|
||||
* type: integer
|
||||
* description: 交易后余额(分)
|
||||
* counterparty_account:
|
||||
* type: string
|
||||
* description: 对方账户号
|
||||
* counterparty_name:
|
||||
* type: string
|
||||
* description: 对方户名
|
||||
* description:
|
||||
* type: string
|
||||
* description: 交易描述
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, completed, failed, cancelled, reversed]
|
||||
* description: 交易状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/transactions:
|
||||
* get:
|
||||
* summary: 获取交易记录列表
|
||||
* tags: [Transactions]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: account_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID(管理员)
|
||||
* - in: query
|
||||
* name: transaction_type
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [deposit, withdrawal, transfer_in, transfer_out, interest, fee, loan, repayment]
|
||||
* description: 交易类型
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, completed, failed, cancelled, reversed]
|
||||
* description: 交易状态
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: amount_min
|
||||
* schema:
|
||||
* type: number
|
||||
* description: 最小金额(元)
|
||||
* - in: query
|
||||
* name: amount_max
|
||||
* schema:
|
||||
* type: number
|
||||
* description: 最大金额(元)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.get('/',
|
||||
verifyToken,
|
||||
transactionController.getTransactions
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/transactions/{transactionId}:
|
||||
* get:
|
||||
* summary: 获取交易详情
|
||||
* tags: [Transactions]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: transactionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 交易ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 交易记录不存在
|
||||
*/
|
||||
router.get('/:transactionId',
|
||||
verifyToken,
|
||||
transactionController.getTransactionDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/transactions/transfer:
|
||||
* post:
|
||||
* summary: 转账
|
||||
* tags: [Transactions]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - from_account_id
|
||||
* - to_account_number
|
||||
* - amount
|
||||
* properties:
|
||||
* from_account_id:
|
||||
* type: integer
|
||||
* description: 转出账户ID
|
||||
* to_account_number:
|
||||
* type: string
|
||||
* description: 转入账户号码
|
||||
* amount:
|
||||
* type: number
|
||||
* description: 转账金额(元)
|
||||
* description:
|
||||
* type: string
|
||||
* description: 转账描述
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 转账成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败、账户状态异常或余额不足
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 账户不存在
|
||||
*/
|
||||
router.post('/transfer',
|
||||
verifyToken,
|
||||
validateAmount,
|
||||
validateAccountNumber,
|
||||
transactionController.transfer
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/transactions/{transactionId}/reverse:
|
||||
* post:
|
||||
* summary: 撤销交易
|
||||
* tags: [Transactions]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: transactionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 交易ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 撤销成功
|
||||
* 400:
|
||||
* description: 该交易无法撤销
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 交易记录不存在
|
||||
*/
|
||||
router.post('/:transactionId/reverse',
|
||||
verifyToken,
|
||||
requireRole(['admin', 'manager']),
|
||||
transactionController.reverseTransaction
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/transactions/stats:
|
||||
* get:
|
||||
* summary: 获取交易统计
|
||||
* tags: [Transactions]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: account_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 账户ID(管理员)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.get('/stats',
|
||||
verifyToken,
|
||||
transactionController.getTransactionStats
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
348
bank-backend/routes/users.js
Normal file
348
bank-backend/routes/users.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const express = require('express');
|
||||
const { verifyToken, requireRole, requireLevel } = require('../middleware/auth');
|
||||
const {
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateIdCard,
|
||||
handleValidationErrors
|
||||
} = require('../middleware/security');
|
||||
const router = express.Router();
|
||||
const userController = require('../controllers/userController');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Users
|
||||
* description: 用户管理
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* User:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - email
|
||||
* - password
|
||||
* - real_name
|
||||
* - id_card
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 邮箱地址
|
||||
* real_name:
|
||||
* type: string
|
||||
* description: 真实姓名
|
||||
* id_card:
|
||||
* type: string
|
||||
* description: 身份证号
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, suspended, locked]
|
||||
* description: 用户状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/register:
|
||||
* post:
|
||||
* summary: 用户注册
|
||||
* tags: [Users]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - email
|
||||
* - password
|
||||
* - real_name
|
||||
* - id_card
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 邮箱地址
|
||||
* password:
|
||||
* type: string
|
||||
* description: 密码
|
||||
* real_name:
|
||||
* type: string
|
||||
* description: 真实姓名
|
||||
* id_card:
|
||||
* type: string
|
||||
* description: 身份证号
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 注册成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败
|
||||
* 500:
|
||||
* description: 服务器内部错误
|
||||
*/
|
||||
router.post('/register',
|
||||
validatePassword,
|
||||
validateIdCard,
|
||||
validatePhone,
|
||||
userController.register
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/login:
|
||||
* post:
|
||||
* summary: 用户登录
|
||||
* tags: [Users]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* password:
|
||||
* type: string
|
||||
* description: 密码
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 登录成功
|
||||
* 401:
|
||||
* description: 用户名或密码错误
|
||||
* 500:
|
||||
* description: 服务器内部错误
|
||||
*/
|
||||
router.post('/login', userController.login);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/profile:
|
||||
* get:
|
||||
* summary: 获取用户信息
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
*/
|
||||
router.get('/profile', verifyToken, userController.getProfile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/profile:
|
||||
* put:
|
||||
* summary: 更新用户信息
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* real_name:
|
||||
* type: string
|
||||
* description: 真实姓名
|
||||
* avatar:
|
||||
* type: string
|
||||
* description: 头像URL
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 400:
|
||||
* description: 输入数据验证失败
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.put('/profile',
|
||||
verifyToken,
|
||||
validatePhone,
|
||||
userController.updateProfile
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/change-password:
|
||||
* put:
|
||||
* summary: 修改密码
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - old_password
|
||||
* - new_password
|
||||
* properties:
|
||||
* old_password:
|
||||
* type: string
|
||||
* description: 原密码
|
||||
* new_password:
|
||||
* type: string
|
||||
* description: 新密码
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 修改成功
|
||||
* 400:
|
||||
* description: 原密码错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.put('/change-password',
|
||||
verifyToken,
|
||||
validatePassword,
|
||||
userController.changePassword
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users:
|
||||
* get:
|
||||
* summary: 获取用户列表(管理员)
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/',
|
||||
verifyToken,
|
||||
requireRole('admin'),
|
||||
userController.getUsers
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/{userId}/status:
|
||||
* put:
|
||||
* summary: 更新用户状态(管理员)
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, suspended, locked]
|
||||
* description: 用户状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
*/
|
||||
router.put('/:userId/status',
|
||||
verifyToken,
|
||||
requireRole('admin'),
|
||||
userController.updateUserStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users/{userId}/accounts:
|
||||
* get:
|
||||
* summary: 获取用户账户列表
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/:userId/accounts',
|
||||
verifyToken,
|
||||
userController.getUserAccounts
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
71
bank-backend/scripts/create-bank-schema.sql
Normal file
71
bank-backend/scripts/create-bank-schema.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- 创建 bank_ 前缀业务表(无DROP,避免覆盖现有表)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
description TEXT NULL,
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
is_system TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20) NULL,
|
||||
real_name VARCHAR(50) NOT NULL,
|
||||
id_card VARCHAR(18) NOT NULL UNIQUE,
|
||||
avatar VARCHAR(255) NULL,
|
||||
role_id INT NOT NULL,
|
||||
status ENUM('active','inactive','suspended','locked') NOT NULL DEFAULT 'active',
|
||||
last_login DATETIME NULL,
|
||||
login_attempts INT NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_bank_users_role_id FOREIGN KEY (role_id) REFERENCES bank_roles(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_accounts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
account_number VARCHAR(20) NOT NULL UNIQUE,
|
||||
user_id INT NOT NULL,
|
||||
account_type ENUM('savings','checking','credit','loan') NOT NULL DEFAULT 'savings',
|
||||
balance BIGINT NOT NULL DEFAULT 0,
|
||||
available_balance BIGINT NOT NULL DEFAULT 0,
|
||||
frozen_amount BIGINT NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
|
||||
interest_rate DECIMAL(5,4) NULL,
|
||||
status ENUM('active','inactive','frozen','closed') NOT NULL DEFAULT 'active',
|
||||
opened_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_bank_accounts_user_id FOREIGN KEY (user_id) REFERENCES bank_users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_transactions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
transaction_number VARCHAR(32) NOT NULL UNIQUE,
|
||||
account_id INT NOT NULL,
|
||||
transaction_type ENUM('deposit','withdrawal','transfer_in','transfer_out','interest','fee','loan','repayment') NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
balance_before BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
counterparty_account VARCHAR(20) NULL,
|
||||
counterparty_name VARCHAR(100) NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
reference_number VARCHAR(50) NULL,
|
||||
status ENUM('pending','completed','failed','cancelled','reversed') NOT NULL DEFAULT 'pending',
|
||||
processed_at DATETIME NULL,
|
||||
reversed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_bank_transactions_account_id FOREIGN KEY (account_id) REFERENCES bank_accounts(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
152
bank-backend/scripts/init-db.js
Normal file
152
bank-backend/scripts/init-db.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
* @file init-db.js
|
||||
* @description 初始化银行系统数据库
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
const { User, Role, Account, Transaction } = require('../models');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('🔄 开始初始化银行系统数据库...');
|
||||
|
||||
// 测试数据库连接
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 同步数据库模型
|
||||
await sequelize.sync({ force: true });
|
||||
console.log('✅ 数据库表创建成功');
|
||||
|
||||
// 创建初始角色
|
||||
console.log('🔄 创建初始角色...');
|
||||
const roles = await Role.bulkCreate([
|
||||
{
|
||||
name: 'admin',
|
||||
display_name: '系统管理员',
|
||||
description: '拥有系统所有权限',
|
||||
level: 100,
|
||||
is_system: true
|
||||
},
|
||||
{
|
||||
name: 'manager',
|
||||
display_name: '银行经理',
|
||||
description: '拥有银行管理权限',
|
||||
level: 80,
|
||||
is_system: false
|
||||
},
|
||||
{
|
||||
name: 'teller',
|
||||
display_name: '银行柜员',
|
||||
description: '拥有基本业务操作权限',
|
||||
level: 60,
|
||||
is_system: false
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
display_name: '普通用户',
|
||||
description: '拥有基本用户权限',
|
||||
level: 20,
|
||||
is_system: false
|
||||
}
|
||||
]);
|
||||
console.log('✅ 初始角色创建成功');
|
||||
|
||||
// 创建初始管理员用户
|
||||
console.log('🔄 创建初始管理员用户...');
|
||||
const adminUser = await User.create({
|
||||
username: 'admin',
|
||||
email: 'admin@bank.com',
|
||||
password: 'Admin123456',
|
||||
phone: '13800138000',
|
||||
real_name: '系统管理员',
|
||||
id_card: '110101199001011234',
|
||||
role_id: roles[0].id, // admin角色
|
||||
status: 'active'
|
||||
});
|
||||
console.log('✅ 初始管理员用户创建成功');
|
||||
|
||||
// 创建测试用户
|
||||
console.log('🔄 创建测试用户...');
|
||||
const testUser = await User.create({
|
||||
username: 'testuser',
|
||||
email: 'test@bank.com',
|
||||
password: 'Test123456',
|
||||
phone: '13800138001',
|
||||
real_name: '测试用户',
|
||||
id_card: '110101199001011235',
|
||||
role_id: roles[3].id, // user角色
|
||||
status: 'active'
|
||||
});
|
||||
console.log('✅ 测试用户创建成功');
|
||||
|
||||
// 为测试用户创建账户
|
||||
console.log('🔄 为测试用户创建账户...');
|
||||
const testAccount = await Account.create({
|
||||
account_number: '001' + Date.now().toString().slice(-8) + '0001',
|
||||
user_id: testUser.id,
|
||||
account_type: 'savings',
|
||||
balance: 100000, // 1000元
|
||||
available_balance: 100000,
|
||||
frozen_amount: 0,
|
||||
currency: 'CNY',
|
||||
interest_rate: 0.035, // 3.5%年利率
|
||||
status: 'active'
|
||||
});
|
||||
console.log('✅ 测试账户创建成功');
|
||||
|
||||
// 创建一些示例交易记录
|
||||
console.log('🔄 创建示例交易记录...');
|
||||
const transactions = await Transaction.bulkCreate([
|
||||
{
|
||||
transaction_number: 'TXN' + Date.now() + '0001',
|
||||
account_id: testAccount.id,
|
||||
transaction_type: 'deposit',
|
||||
amount: 100000,
|
||||
balance_before: 0,
|
||||
balance_after: 100000,
|
||||
description: '开户存款',
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
},
|
||||
{
|
||||
transaction_number: 'TXN' + Date.now() + '0002',
|
||||
account_id: testAccount.id,
|
||||
transaction_type: 'interest',
|
||||
amount: 292, // 约1元利息
|
||||
balance_before: 100000,
|
||||
balance_after: 100292,
|
||||
description: '定期利息',
|
||||
status: 'completed',
|
||||
processed_at: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('✅ 示例交易记录创建成功');
|
||||
|
||||
console.log('\n🎉 银行系统数据库初始化完成!');
|
||||
console.log('\n📋 初始数据信息:');
|
||||
console.log(`👤 管理员账户: admin / Admin123456`);
|
||||
console.log(`👤 测试用户: testuser / Test123456`);
|
||||
console.log(`🏦 测试账户: ${testAccount.account_number}`);
|
||||
console.log(`💰 初始余额: ${(testAccount.balance / 100).toFixed(2)} 元`);
|
||||
console.log('\n🔗 数据库连接信息:');
|
||||
console.log(` 主机: ${process.env.DB_HOST || 'localhost'}`);
|
||||
console.log(` 端口: ${process.env.DB_PORT || 3306}`);
|
||||
console.log(` 数据库: ${process.env.DB_NAME || 'bank_management'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
console.log('📊 数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
// 运行初始化
|
||||
if (require.main === module) {
|
||||
initDatabase();
|
||||
}
|
||||
|
||||
module.exports = initDatabase;
|
||||
35
bank-backend/scripts/query-users.js
Normal file
35
bank-backend/scripts/query-users.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 查询用户数据以诊断登录问题
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
const { User } = require('../models');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Connecting to DB...', {
|
||||
dialect: process.env.DB_DIALECT,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER
|
||||
});
|
||||
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ DB connected');
|
||||
|
||||
// 查询所有用户
|
||||
const users = await User.findAll({
|
||||
attributes: { exclude: ['password'] } // 排除密码字段
|
||||
});
|
||||
|
||||
console.log('📋 Users count:', users.length);
|
||||
users.forEach(user => {
|
||||
console.log(`- ID: ${user.id}, Username: ${user.username}, Email: ${user.email}, Status: ${user.status}, Role ID: ${user.role_id}, Last Login: ${user.last_login}`);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('❌ Query failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
27
bank-backend/scripts/seed-bank-demo.sql
Normal file
27
bank-backend/scripts/seed-bank-demo.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- 初始角色(如果不存在则插入)
|
||||
INSERT INTO bank_roles (name, display_name, level, is_system, status)
|
||||
SELECT 'admin','系统管理员',100,1,'active'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM bank_roles WHERE name='admin');
|
||||
|
||||
INSERT INTO bank_roles (name, display_name, level, is_system, status)
|
||||
SELECT 'user','普通用户',20,0,'active'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM bank_roles WHERE name='user');
|
||||
|
||||
-- 管理员账户,密码由脚本动态替换为 bcrypt 哈希(REPLACE_ADMIN_BCRYPT)
|
||||
INSERT INTO bank_users (username,email,password,real_name,id_card,role_id,status)
|
||||
SELECT 'admin','admin@bank.com','REPLACE_ADMIN_BCRYPT','系统管理员','110101199001011234', r.id,'active'
|
||||
FROM bank_roles r WHERE r.name='admin'
|
||||
AND NOT EXISTS (SELECT 1 FROM bank_users WHERE username='admin');
|
||||
|
||||
-- 测试用户
|
||||
INSERT INTO bank_users (username,email,password,real_name,id_card,role_id,status)
|
||||
SELECT 'testuser','test@bank.com','REPLACE_ADMIN_BCRYPT','测试用户','110101199001011235', r.id,'active'
|
||||
FROM bank_roles r WHERE r.name='user'
|
||||
AND NOT EXISTS (SELECT 1 FROM bank_users WHERE username='testuser');
|
||||
|
||||
-- 测试账户(admin名下)
|
||||
INSERT INTO bank_accounts (account_number,user_id,account_type,balance,available_balance,frozen_amount,currency,interest_rate,status)
|
||||
SELECT '001' || CAST(FLOOR(RAND()*90000000)+10000000 AS CHAR) || '0001', u.id, 'savings', 100000, 100000, 0, 'CNY', 0.035, 'active'
|
||||
FROM bank_users u WHERE u.username='admin'
|
||||
AND NOT EXISTS (SELECT 1 FROM bank_accounts a JOIN bank_users u2 ON a.user_id=u2.id WHERE u2.username='admin');
|
||||
|
||||
47
bank-backend/scripts/setup-bank-db.ps1
Normal file
47
bank-backend/scripts/setup-bank-db.ps1
Normal file
@@ -0,0 +1,47 @@
|
||||
Param(
|
||||
[string]$Host = $env:DB_HOST,
|
||||
[int]$Port = [int]($env:DB_PORT),
|
||||
[string]$Database = $env:DB_NAME,
|
||||
[string]$User = $env:DB_USER,
|
||||
[string]$Password = $env:DB_PASSWORD,
|
||||
[string]$AdminPlain = 'Admin123456'
|
||||
)
|
||||
|
||||
Write-Host "Using DB: $Host:$Port/$Database"
|
||||
|
||||
# 生成管理员 bcrypt 哈希
|
||||
try {
|
||||
$nodeScript = @"
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pwd = process.argv[2] || 'Admin123456';
|
||||
bcrypt.hash(pwd, 10).then(h => { console.log(h); }).catch(e => { console.error(e); process.exit(1); });
|
||||
"@
|
||||
$hash = node -e $nodeScript $AdminPlain
|
||||
if (-not $hash) { throw 'bcrypt hash failed' }
|
||||
} catch {
|
||||
Write-Error "Failed to generate bcrypt hash: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 读取SQL并替换占位符
|
||||
$schema = Get-Content -Raw -Encoding UTF8 "$PSScriptRoot/create-bank-schema.sql"
|
||||
$seed = (Get-Content -Raw -Encoding UTF8 "$PSScriptRoot/seed-bank-demo.sql").Replace('REPLACE_ADMIN_BCRYPT', $hash)
|
||||
$sql = $schema + "`n" + $seed
|
||||
|
||||
# 写入临时文件
|
||||
$tmp = New-TemporaryFile
|
||||
Set-Content -Path $tmp -Value $sql -Encoding UTF8
|
||||
|
||||
# 调用 mysql 客户端
|
||||
try {
|
||||
$env:MYSQL_PWD = $Password
|
||||
& mysql --host=$Host --port=$Port --user=$User --database=$Database --default-character-set=utf8mb4 --protocol=TCP < $tmp
|
||||
if ($LASTEXITCODE -ne 0) { throw "mysql returned $LASTEXITCODE" }
|
||||
Write-Host "✅ Schema & seed executed successfully"
|
||||
} catch {
|
||||
Write-Error "Failed to execute SQL: $_"
|
||||
exit 1
|
||||
} finally {
|
||||
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
29
bank-backend/scripts/test-connection.js
Normal file
29
bank-backend/scripts/test-connection.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 远程数据库连接测试与列出表名
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Connecting to DB...', {
|
||||
dialect: process.env.DB_DIALECT,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER
|
||||
});
|
||||
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ DB connected');
|
||||
|
||||
const [rows] = await sequelize.query('SHOW TABLES');
|
||||
const tables = rows.map(r => Object.values(r)[0]);
|
||||
console.log('📋 Tables:', tables);
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('❌ DB connect failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
223
bank-backend/server.js
Normal file
223
bank-backend/server.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 银行管理后台服务器
|
||||
* @file server.js
|
||||
* @description 银行系统后端API服务器主入口
|
||||
*/
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const dotenv = require('dotenv');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerSpec = require('./config/swagger');
|
||||
const { sequelize } = require('./config/database');
|
||||
const logger = require('./utils/logger');
|
||||
const {
|
||||
apiRateLimiter,
|
||||
loginRateLimiter,
|
||||
inputSanitizer,
|
||||
sessionTimeoutCheck,
|
||||
securityHeaders
|
||||
} = require('./middleware/security');
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
// 创建Express应用和HTTP服务器
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 5351;
|
||||
|
||||
// 安全中间件
|
||||
app.use(securityHeaders);
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
|
||||
// CORS配置
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
||||
}));
|
||||
|
||||
// 请求解析中间件
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// 安全中间件
|
||||
app.use(inputSanitizer);
|
||||
app.use(apiRateLimiter);
|
||||
|
||||
// 静态文件服务
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// API文档
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// 健康检查端点
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: '银行系统运行正常',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// API路由
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/accounts', require('./routes/accounts'));
|
||||
app.use('/api/transactions', require('./routes/transactions'));
|
||||
|
||||
// 根路径
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: '银行管理后台API服务',
|
||||
version: '1.0.0',
|
||||
documentation: '/api-docs',
|
||||
health: '/health'
|
||||
});
|
||||
});
|
||||
|
||||
// 404处理
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '请求的资源不存在',
|
||||
path: req.originalUrl
|
||||
});
|
||||
});
|
||||
|
||||
// 全局错误处理中间件
|
||||
app.use((error, req, res, next) => {
|
||||
logger.error('服务器错误:', error);
|
||||
|
||||
// 数据库连接错误
|
||||
if (error.name === 'SequelizeConnectionError') {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: '数据库连接失败,请稍后重试'
|
||||
});
|
||||
}
|
||||
|
||||
// 数据库验证错误
|
||||
if (error.name === 'SequelizeValidationError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败',
|
||||
errors: error.errors.map(err => ({
|
||||
field: err.path,
|
||||
message: err.message
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// 数据库唯一约束错误
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据已存在,请检查输入'
|
||||
});
|
||||
}
|
||||
|
||||
// JWT错误
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '访问令牌已过期'
|
||||
});
|
||||
}
|
||||
|
||||
// 默认错误响应
|
||||
res.status(error.status || 500).json({
|
||||
success: false,
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? '服务器内部错误'
|
||||
: error.message,
|
||||
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
|
||||
});
|
||||
});
|
||||
|
||||
// 优雅关闭处理
|
||||
const gracefulShutdown = (signal) => {
|
||||
logger.info(`收到 ${signal} 信号,开始优雅关闭...`);
|
||||
|
||||
server.close(async () => {
|
||||
logger.info('HTTP服务器已关闭');
|
||||
|
||||
try {
|
||||
await sequelize.close();
|
||||
logger.info('数据库连接已关闭');
|
||||
} catch (error) {
|
||||
logger.error('关闭数据库连接时出错:', error);
|
||||
}
|
||||
|
||||
logger.info('银行系统已安全关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 强制关闭超时
|
||||
setTimeout(() => {
|
||||
logger.error('强制关闭服务器');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// 监听关闭信号
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
// 未捕获的异常处理
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('未捕获的异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('未处理的Promise拒绝:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
const startServer = async () => {
|
||||
try {
|
||||
// 测试数据库连接
|
||||
await sequelize.authenticate();
|
||||
logger.info('✅ 数据库连接成功');
|
||||
|
||||
// 同步数据库模型(开发环境)
|
||||
// 按用户要求:不要初始化数据库(不自动建表/同步)
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// await sequelize.sync({ alter: true });
|
||||
// logger.info('✅ 数据库模型同步完成');
|
||||
// }
|
||||
|
||||
// 启动HTTP服务器
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`🚀 银行管理后台服务器启动成功`);
|
||||
logger.info(`📡 服务地址: http://localhost:${PORT}`);
|
||||
logger.info(`📚 API文档: http://localhost:${PORT}/api-docs`);
|
||||
logger.info(`🏥 健康检查: http://localhost:${PORT}/health`);
|
||||
logger.info(`🌍 环境: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ 服务器启动失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 启动服务器
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
74
bank-backend/utils/logger.js
Normal file
74
bank-backend/utils/logger.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 日志工具
|
||||
* @file logger.js
|
||||
* @description 系统日志管理
|
||||
*/
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
// 创建日志目录
|
||||
const logDir = 'logs';
|
||||
|
||||
// 定义日志格式
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
|
||||
let log = `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
|
||||
if (stack) {
|
||||
log += `\n${stack}`;
|
||||
}
|
||||
|
||||
if (Object.keys(meta).length > 0) {
|
||||
log += `\n${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return log;
|
||||
})
|
||||
);
|
||||
|
||||
// 创建logger实例
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'bank-backend' },
|
||||
transports: [
|
||||
// 错误日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
|
||||
// 所有日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// 开发环境添加控制台输出
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
// 创建流对象用于HTTP请求日志
|
||||
logger.stream = {
|
||||
write: (message) => {
|
||||
logger.info(message.trim());
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
247
bank-frontend/PROJECT_SUMMARY.md
Normal file
247
bank-frontend/PROJECT_SUMMARY.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 银行管理后台系统前端项目总结
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于对现有智慧养殖监管平台前端架构的深入学习和分析,成功创建了一个完整的银行管理后台系统前端项目。该项目采用现代化的技术栈,提供完整的用户界面和交互体验。
|
||||
|
||||
## 🎯 项目目标达成
|
||||
|
||||
### ✅ 已完成功能
|
||||
|
||||
1. **项目架构设计**
|
||||
- 采用Vue 3 + Vite + Ant Design Vue技术栈
|
||||
- 模块化组件设计,易于维护和扩展
|
||||
- 完整的目录结构规划
|
||||
|
||||
2. **核心组件实现**
|
||||
- 动态菜单组件:支持角色权限控制
|
||||
- 移动端导航组件:响应式设计支持
|
||||
- 登录页面:美观的登录界面
|
||||
- 仪表盘页面:数据概览和图表展示
|
||||
|
||||
3. **状态管理**
|
||||
- 用户状态管理:登录、权限、角色控制
|
||||
- 设置状态管理:主题、布局、用户偏好
|
||||
- 本地存储集成:数据持久化
|
||||
|
||||
4. **路由系统**
|
||||
- 基于角色的路由权限控制
|
||||
- 路由守卫和导航控制
|
||||
- 动态菜单生成
|
||||
|
||||
5. **API集成**
|
||||
- 完整的API请求封装
|
||||
- 错误处理和响应拦截
|
||||
- 认证Token管理
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 前端技术栈
|
||||
- **核心框架**: Vue 3.4+ (Composition API)
|
||||
- **构建工具**: Vite 5.0+
|
||||
- **UI组件库**: Ant Design Vue 4.0+
|
||||
- **状态管理**: Pinia 2.1+
|
||||
- **路由管理**: Vue Router 4.2+
|
||||
- **图表库**: ECharts 5.4+
|
||||
- **图标库**: Ant Design Icons Vue 7.0+
|
||||
|
||||
### 项目结构
|
||||
```
|
||||
bank-frontend/
|
||||
├── src/
|
||||
│ ├── components/ # 可复用组件
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── utils/ # 工具类
|
||||
│ ├── styles/ # 样式文件
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── public/ # 静态资源
|
||||
├── index.html # HTML模板
|
||||
├── vite.config.js # Vite配置
|
||||
└── package.json # 项目配置
|
||||
```
|
||||
|
||||
## 🎨 界面设计
|
||||
|
||||
### 设计特色
|
||||
- **现代化界面**: 采用Ant Design Vue设计语言
|
||||
- **响应式设计**: 支持桌面端和移动端
|
||||
- **主题系统**: 支持明暗主题切换
|
||||
- **动画效果**: 流畅的交互动画
|
||||
|
||||
### 核心页面
|
||||
1. **登录页面**: 美观的登录界面,支持记住密码
|
||||
2. **仪表盘**: 数据概览、统计图表、系统信息
|
||||
3. **用户管理**: 用户列表、详情、状态管理
|
||||
4. **账户管理**: 账户列表、余额管理、状态控制
|
||||
5. **交易管理**: 交易记录、转账功能、统计分析
|
||||
|
||||
## 🔐 权限系统
|
||||
|
||||
### 角色定义
|
||||
- **admin**: 系统管理员,拥有所有权限
|
||||
- **manager**: 银行经理,拥有管理权限
|
||||
- **teller**: 银行柜员,拥有业务操作权限
|
||||
- **user**: 普通用户,拥有基本查看权限
|
||||
|
||||
### 权限控制
|
||||
- **路由权限**: 基于角色的路由访问控制
|
||||
- **菜单权限**: 根据用户角色显示对应菜单
|
||||
- **操作权限**: 按钮级别的权限控制
|
||||
- **数据权限**: 数据访问范围控制
|
||||
|
||||
## 📱 响应式设计
|
||||
|
||||
### 断点设置
|
||||
- **移动端**: < 768px
|
||||
- **平板端**: 768px - 1024px
|
||||
- **桌面端**: > 1024px
|
||||
|
||||
### 适配特性
|
||||
- **移动端导航**: 抽屉式菜单
|
||||
- **表格优化**: 横向滚动支持
|
||||
- **表单优化**: 垂直布局
|
||||
- **模态框优化**: 全屏显示
|
||||
|
||||
## 🚀 核心功能
|
||||
|
||||
### 用户管理
|
||||
- 用户列表展示和搜索
|
||||
- 用户详情查看和编辑
|
||||
- 用户状态管理
|
||||
- 角色权限分配
|
||||
|
||||
### 账户管理
|
||||
- 账户列表和筛选
|
||||
- 账户详情查看
|
||||
- 余额查询和管理
|
||||
- 账户状态控制
|
||||
|
||||
### 交易管理
|
||||
- 交易记录查询
|
||||
- 转账功能实现
|
||||
- 交易统计分析
|
||||
- 交易状态管理
|
||||
|
||||
### 数据可视化
|
||||
- 统计图表展示
|
||||
- 实时数据更新
|
||||
- 交互式图表
|
||||
- 数据导出功能
|
||||
|
||||
## 🛠️ 开发工具
|
||||
|
||||
### 代码质量
|
||||
- ESLint代码检查
|
||||
- 统一的代码规范
|
||||
- 完整的错误处理
|
||||
- 详细的注释文档
|
||||
|
||||
### 开发支持
|
||||
- 热重载开发模式
|
||||
- 环境变量配置
|
||||
- 代理配置支持
|
||||
- 完整的项目文档
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 前端优化
|
||||
- 组件懒加载
|
||||
- 图片压缩优化
|
||||
- 代码分割
|
||||
- 缓存策略
|
||||
|
||||
### 用户体验
|
||||
- 加载状态提示
|
||||
- 错误处理机制
|
||||
- 响应式设计
|
||||
- 无障碍访问
|
||||
|
||||
## 🔧 部署支持
|
||||
|
||||
### 环境配置
|
||||
- 环境变量管理
|
||||
- 多环境支持
|
||||
- 配置文件管理
|
||||
- 安全配置
|
||||
|
||||
### 构建优化
|
||||
- Vite快速构建
|
||||
- 资源压缩优化
|
||||
- 静态资源处理
|
||||
- 浏览器兼容性
|
||||
|
||||
## 📋 项目特色
|
||||
|
||||
### 1. 现代化技术栈
|
||||
- 使用最新的Vue 3技术
|
||||
- 完善的开发工具链
|
||||
- 标准化的代码规范
|
||||
- 自动化的构建流程
|
||||
|
||||
### 2. 企业级架构
|
||||
- 模块化组件设计
|
||||
- 清晰的状态管理
|
||||
- 完善的权限控制
|
||||
- 可扩展的架构
|
||||
|
||||
### 3. 优秀的用户体验
|
||||
- 响应式界面设计
|
||||
- 流畅的交互动画
|
||||
- 直观的操作流程
|
||||
- 完善的错误处理
|
||||
|
||||
### 4. 完整的开发支持
|
||||
- 详细的开发文档
|
||||
- 完整的项目配置
|
||||
- 标准化的开发流程
|
||||
- 易于维护的代码结构
|
||||
|
||||
## 🎉 项目成果
|
||||
|
||||
### 技术成果
|
||||
- 完整的银行管理系统前端
|
||||
- 现代化的技术架构
|
||||
- 完善的组件库
|
||||
- 标准化的开发流程
|
||||
|
||||
### 文档成果
|
||||
- 详细的项目文档
|
||||
- 完整的开发指南
|
||||
- 部署和配置说明
|
||||
- 代码规范和最佳实践
|
||||
|
||||
### 学习成果
|
||||
- 深入理解了Vue 3生态系统
|
||||
- 掌握了现代化前端开发技术
|
||||
- 学习了企业级应用架构设计
|
||||
- 提升了全栈开发能力
|
||||
|
||||
## 🔮 未来扩展
|
||||
|
||||
### 功能扩展
|
||||
- 实时通知系统
|
||||
- 高级报表功能
|
||||
- 数据导入导出
|
||||
- 第三方集成
|
||||
|
||||
### 技术升级
|
||||
- PWA支持
|
||||
- 微前端架构
|
||||
- 性能监控
|
||||
- 自动化测试
|
||||
|
||||
## 📞 总结
|
||||
|
||||
通过深入学习现有智慧养殖监管平台的前端架构和设计模式,成功创建了一个功能完整、架构清晰的银行管理后台系统前端项目。该项目不仅实现了银行系统的核心功能,还采用了现代化的技术栈和最佳实践,为后续的功能扩展和技术升级奠定了坚实的基础。
|
||||
|
||||
项目展现了从需求分析、架构设计、组件实现到文档编写的完整开发流程,体现了企业级前端应用开发的专业水准。
|
||||
|
||||
---
|
||||
|
||||
*项目完成时间: 2025-01-18*
|
||||
*开发环境: Vue 3 + Vite*
|
||||
*UI框架: Ant Design Vue*
|
||||
230
bank-frontend/README.md
Normal file
230
bank-frontend/README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 银行管理后台系统前端
|
||||
|
||||
基于 Vue 3 + Vite + Ant Design Vue 的现代化银行管理后台系统前端界面。
|
||||
|
||||
## 🚀 功能特性
|
||||
|
||||
### 核心功能
|
||||
- **用户管理**: 用户列表、用户详情、用户状态管理
|
||||
- **账户管理**: 账户列表、账户详情、余额管理、账户状态控制
|
||||
- **交易管理**: 交易记录查询、转账功能、交易统计
|
||||
- **仪表盘**: 数据概览、图表展示、系统信息
|
||||
- **权限控制**: 基于角色的访问控制(RBAC)
|
||||
|
||||
### 技术特性
|
||||
- **Vue 3**: 使用 Composition API 和 `<script setup>` 语法
|
||||
- **Vite**: 快速的构建工具和开发服务器
|
||||
- **Ant Design Vue**: 企业级UI组件库
|
||||
- **Pinia**: 现代化的状态管理
|
||||
- **Vue Router**: 官方路由管理器
|
||||
- **ECharts**: 数据可视化图表库
|
||||
- **响应式设计**: 支持桌面端和移动端
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
- **前端框架**: Vue 3.4+
|
||||
- **构建工具**: Vite 5.0+
|
||||
- **UI组件库**: Ant Design Vue 4.0+
|
||||
- **状态管理**: Pinia 2.1+
|
||||
- **路由管理**: Vue Router 4.2+
|
||||
- **图表库**: ECharts 5.4+
|
||||
- **图标库**: Ant Design Icons Vue 7.0+
|
||||
- **工具库**: Lodash-es, Day.js, Moment.js
|
||||
|
||||
## 📦 安装部署
|
||||
|
||||
### 环境要求
|
||||
- Node.js 16.0+
|
||||
- npm 8.0+
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **环境配置**
|
||||
```bash
|
||||
cp env.example .env
|
||||
# 编辑 .env 文件,配置API地址等信息
|
||||
```
|
||||
|
||||
3. **启动开发服务器**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **构建生产版本**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
5. **预览生产版本**
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## ⚙️ 环境配置
|
||||
|
||||
创建 `.env` 文件并配置以下环境变量:
|
||||
|
||||
```env
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=银行管理后台系统
|
||||
VITE_API_BASE_URL=http://localhost:5350
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# 功能开关
|
||||
VITE_ENABLE_NOTIFICATION=true
|
||||
VITE_ENABLE_EXPORT=true
|
||||
VITE_ENABLE_IMPORT=true
|
||||
VITE_ENABLE_CHARTS=true
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
bank-frontend/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── components/ # 可复用组件
|
||||
│ │ ├── DynamicMenu.vue # 动态菜单组件
|
||||
│ │ └── MobileNav.vue # 移动端导航组件
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── Login.vue # 登录页面
|
||||
│ │ ├── Dashboard.vue # 仪表盘页面
|
||||
│ │ ├── Users.vue # 用户管理页面
|
||||
│ │ ├── Accounts.vue # 账户管理页面
|
||||
│ │ ├── Transactions.vue # 交易管理页面
|
||||
│ │ ├── Reports.vue # 报表统计页面
|
||||
│ │ ├── Settings.vue # 系统设置页面
|
||||
│ │ ├── Profile.vue # 个人中心页面
|
||||
│ │ └── NotFound.vue # 404页面
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ ├── index.js # 路由实例
|
||||
│ │ └── routes.js # 路由定义
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ ├── user.js # 用户状态
|
||||
│ │ ├── settings.js # 设置状态
|
||||
│ │ └── index.js # 状态导出
|
||||
│ ├── utils/ # 工具类
|
||||
│ │ └── api.js # API请求封装
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ ├── global.css # 全局样式
|
||||
│ │ ├── responsive.css # 响应式样式
|
||||
│ │ └── theme.js # 主题配置
|
||||
│ ├── config/ # 配置文件
|
||||
│ │ └── env.js # 环境配置
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── index.html # HTML模板
|
||||
├── vite.config.js # Vite配置
|
||||
├── package.json # 项目配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🎨 界面设计
|
||||
|
||||
### 设计原则
|
||||
- **简洁明了**: 界面简洁,信息层次清晰
|
||||
- **响应式**: 支持桌面端和移动端
|
||||
- **一致性**: 统一的视觉风格和交互模式
|
||||
- **易用性**: 符合用户使用习惯
|
||||
|
||||
### 主题配置
|
||||
- **主色调**: #1890ff (蓝色)
|
||||
- **成功色**: #52c41a (绿色)
|
||||
- **警告色**: #faad14 (橙色)
|
||||
- **错误色**: #ff4d4f (红色)
|
||||
|
||||
## 🔐 权限管理
|
||||
|
||||
### 角色定义
|
||||
- **admin**: 系统管理员,拥有所有权限
|
||||
- **manager**: 银行经理,拥有管理权限
|
||||
- **teller**: 银行柜员,拥有业务操作权限
|
||||
- **user**: 普通用户,拥有基本查看权限
|
||||
|
||||
### 权限控制
|
||||
- **路由权限**: 基于角色的路由访问控制
|
||||
- **菜单权限**: 根据用户角色显示对应菜单
|
||||
- **操作权限**: 按钮级别的权限控制
|
||||
- **数据权限**: 数据访问范围控制
|
||||
|
||||
## 📱 响应式设计
|
||||
|
||||
### 断点设置
|
||||
- **移动端**: < 768px
|
||||
- **平板端**: 768px - 1024px
|
||||
- **桌面端**: > 1024px
|
||||
|
||||
### 适配特性
|
||||
- **移动端导航**: 抽屉式菜单
|
||||
- **表格优化**: 横向滚动支持
|
||||
- **表单优化**: 垂直布局
|
||||
- **模态框优化**: 全屏显示
|
||||
|
||||
## 🚀 开发指南
|
||||
|
||||
### 代码规范
|
||||
- 使用 ESLint 进行代码检查
|
||||
- 遵循 Vue 3 最佳实践
|
||||
- 统一的组件命名规范
|
||||
- 完整的注释和文档
|
||||
|
||||
### 开发命令
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 代码修复
|
||||
npm run lint:fix
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 构建项目
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🔧 部署指南
|
||||
|
||||
### 开发环境
|
||||
- 开发服务器: http://localhost:5300
|
||||
- API代理: 自动代理到后端服务器
|
||||
- 热重载: 支持代码修改实时更新
|
||||
|
||||
### 生产环境
|
||||
- 静态文件部署
|
||||
- Nginx 反向代理配置
|
||||
- CDN 加速支持
|
||||
- 浏览器缓存优化
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- 项目维护者: 银行开发团队
|
||||
- 邮箱: dev@bank.com
|
||||
- 项目地址: https://github.com/bank-management/bank-frontend
|
||||
|
||||
---
|
||||
|
||||
**注意**: 这是一个演示项目,请勿在生产环境中使用默认的密码和配置。
|
||||
7
bank-frontend/env.example
Normal file
7
bank-frontend/env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# 开发环境配置
|
||||
VITE_APP_TITLE=银行管理后台系统
|
||||
VITE_API_BASE_URL=http://localhost:5350
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# 生产环境配置
|
||||
# VITE_API_BASE_URL=https://api.bank.com
|
||||
15
bank-frontend/index.html
Normal file
15
bank-frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>银行管理后台系统</title>
|
||||
<meta name="description" content="银行管理后台系统 - 专业的银行管理解决方案" />
|
||||
<meta name="keywords" content="银行,管理,后台,系统,金融" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
5799
bank-frontend/package-lock.json
generated
Normal file
5799
bank-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
69
bank-frontend/package.json
Normal file
69
bank-frontend/package.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "bank-management-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "银行管理后台系统前端界面",
|
||||
"author": "Bank Development Team",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"vue3",
|
||||
"vite",
|
||||
"ant-design-vue",
|
||||
"echarts",
|
||||
"pinia",
|
||||
"banking",
|
||||
"admin-dashboard"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"preview": "vite preview --port 5300",
|
||||
"lint": "eslint . --ext .vue,.js,.ts --fix",
|
||||
"lint:check": "eslint . --ext .vue,.js,.ts",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"clean": "rimraf dist node_modules/.vite",
|
||||
"analyze": "vite-bundle-analyzer dist/stats.html",
|
||||
"deploy": "npm run build && npm run preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"ant-design-vue": "^4.0.6",
|
||||
"axios": "^1.6.2",
|
||||
"echarts": "^5.4.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"moment": "^2.29.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nprogress": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.0.10",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite-bundle-analyzer": "^0.7.0",
|
||||
"vitest": "^1.0.4",
|
||||
"@vitest/ui": "^1.0.4",
|
||||
"@vitest/coverage-v8": "^1.0.4",
|
||||
"vue-tsc": "^1.8.25"
|
||||
}
|
||||
}
|
||||
294
bank-frontend/src/App.vue
Normal file
294
bank-frontend/src/App.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div v-if="isLoggedIn">
|
||||
<!-- 移动端布局 -->
|
||||
<div v-if="isMobile" class="mobile-layout">
|
||||
<MobileNav ref="mobileNavRef" />
|
||||
<div class="mobile-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端布局 -->
|
||||
<a-layout v-else style="min-height: 100vh">
|
||||
<a-layout-header class="header">
|
||||
<div class="logo">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="settingsStore.toggleSidebar"
|
||||
style="color: white; margin-right: 8px;"
|
||||
>
|
||||
<menu-unfold-outlined v-if="sidebarCollapsed" />
|
||||
<menu-fold-outlined v-else />
|
||||
</a-button>
|
||||
银行管理后台系统
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<a-dropdown>
|
||||
<a-button type="text" style="color: white;">
|
||||
<user-outlined />
|
||||
{{ userData?.real_name || userData?.username }}
|
||||
<down-outlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="goToProfile">
|
||||
<user-outlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings" @click="goToSettings">
|
||||
<setting-outlined />
|
||||
系统设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
width="200"
|
||||
style="background: #001529"
|
||||
:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
>
|
||||
<DynamicMenu :collapsed="sidebarCollapsed" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout style="padding: 0 24px 24px">
|
||||
<a-layout-content
|
||||
:style="{ background: '#fff', padding: '24px', margin: '16px 0' }"
|
||||
>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
|
||||
<a-layout-footer style="text-align: center">
|
||||
银行管理后台系统 ©2025
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</div>
|
||||
<div v-else>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DynamicMenu from './components/DynamicMenu.vue'
|
||||
import MobileNav from './components/MobileNav.vue'
|
||||
import { useUserStore, useSettingsStore } from './stores'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
DownOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
// 使用Pinia状态管理
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 移动端导航引用
|
||||
const mobileNavRef = ref()
|
||||
|
||||
// 响应式检测
|
||||
const isMobile = ref(false)
|
||||
|
||||
// 检测屏幕尺寸
|
||||
const checkScreenSize = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
const userData = computed(() => userStore.userData)
|
||||
const sidebarCollapsed = computed(() => settingsStore.sidebarCollapsed)
|
||||
|
||||
// 监听多标签页登录状态同步
|
||||
const handleStorageChange = (event) => {
|
||||
if (event.key === 'bank_token' || event.key === 'bank_user') {
|
||||
userStore.checkLoginStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到个人中心
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// 跳转到系统设置
|
||||
const goToSettings = () => {
|
||||
router.push('/settings')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.checkLoginStatus()
|
||||
checkScreenSize()
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 桌面端样式 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #001529;
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 移动端布局样式 */
|
||||
.mobile-layout {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
padding: 12px;
|
||||
padding-top: 68px; /* 为固定头部留出空间 */
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* 移动端页面内容优化 */
|
||||
.mobile-layout :deep(.page-header) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-area) {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-buttons) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-buttons .ant-btn) {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* 移动端表格优化 */
|
||||
.mobile-layout :deep(.ant-table-wrapper) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table) {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端模态框优化 */
|
||||
.mobile-layout :deep(.ant-modal) {
|
||||
margin: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-body) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-footer) {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* 移动端卡片优化 */
|
||||
.mobile-layout :deep(.ant-card) {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 移动端按钮优化 */
|
||||
.mobile-layout :deep(.ant-btn) {
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space-item) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space-item .ant-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
138
bank-frontend/src/components/DynamicMenu.vue
Normal file
138
bank-frontend/src/components/DynamicMenu.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<a-menu
|
||||
:default-selected-keys="[currentRoute]"
|
||||
:default-open-keys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
@click="handleMenuClick"
|
||||
@openChange="handleOpenChange"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<a-menu-item v-if="!item.children" :key="item.key">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-sub-menu v-else :key="item.key">
|
||||
<template #title>
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
>
|
||||
{{ child.title }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { getMenuItems } from '@/router/routes'
|
||||
import routes from '@/router/routes'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前路由
|
||||
const currentRoute = computed(() => route.name)
|
||||
|
||||
// 展开的菜单项
|
||||
const openKeys = ref([])
|
||||
|
||||
// 菜单项
|
||||
const menuItems = computed(() => {
|
||||
const userRole = userStore.getUserRoleName()
|
||||
return getMenuItems(routes, userRole)
|
||||
})
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const menuItem = findMenuItem(menuItems.value, key)
|
||||
if (menuItem && menuItem.path) {
|
||||
router.push(menuItem.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理子菜单展开/收起
|
||||
const handleOpenChange = (keys) => {
|
||||
openKeys.value = keys
|
||||
}
|
||||
|
||||
// 查找菜单项
|
||||
const findMenuItem = (items, key) => {
|
||||
for (const item of items) {
|
||||
if (item.key === key) {
|
||||
return item
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 监听路由变化,自动展开对应的子菜单
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const pathSegments = newPath.split('/').filter(Boolean)
|
||||
if (pathSegments.length > 1) {
|
||||
const parentKey = pathSegments[0]
|
||||
if (!openKeys.value.includes(parentKey)) {
|
||||
openKeys.value = [parentKey]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-menu-item .anticon,
|
||||
.ant-menu-submenu-title .anticon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: #1890ff !important;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-open > .ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
279
bank-frontend/src/components/MobileNav.vue
Normal file
279
bank-frontend/src/components/MobileNav.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<a-layout-header class="mobile-header">
|
||||
<div class="mobile-header-content">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="toggleDrawer"
|
||||
class="menu-button"
|
||||
>
|
||||
<menu-outlined />
|
||||
</a-button>
|
||||
|
||||
<div class="header-title">
|
||||
银行管理系统
|
||||
</div>
|
||||
|
||||
<a-dropdown>
|
||||
<a-button type="text" class="user-button">
|
||||
<user-outlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="goToProfile">
|
||||
<user-outlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉菜单 -->
|
||||
<a-drawer
|
||||
v-model:open="drawerVisible"
|
||||
placement="left"
|
||||
:width="280"
|
||||
:body-style="{ padding: 0 }"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="drawer-header">
|
||||
<div class="user-info">
|
||||
<a-avatar :size="48" :src="userData?.avatar">
|
||||
<user-outlined />
|
||||
</a-avatar>
|
||||
<div class="user-details">
|
||||
<div class="user-name">{{ userData?.real_name || userData?.username }}</div>
|
||||
<div class="user-role">{{ userData?.role?.display_name || '用户' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
:default-selected-keys="[currentRoute]"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<a-menu-item v-if="!item.children" :key="item.key">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-sub-menu v-else :key="item.key">
|
||||
<template #title>
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
>
|
||||
{{ child.title }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</a-layout-header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { getMenuItems } from '@/router/routes'
|
||||
import routes from '@/router/routes'
|
||||
import {
|
||||
MenuOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 抽屉显示状态
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
// 当前路由
|
||||
const currentRoute = computed(() => route.name)
|
||||
|
||||
// 用户数据
|
||||
const userData = computed(() => userStore.userData)
|
||||
|
||||
// 菜单项
|
||||
const menuItems = computed(() => {
|
||||
const userRole = userStore.getUserRoleName()
|
||||
return getMenuItems(routes, userRole)
|
||||
})
|
||||
|
||||
// 切换抽屉
|
||||
const toggleDrawer = () => {
|
||||
drawerVisible.value = !drawerVisible.value
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const menuItem = findMenuItem(menuItems.value, key)
|
||||
if (menuItem && menuItem.path) {
|
||||
router.push(menuItem.path)
|
||||
drawerVisible.value = false // 点击后关闭抽屉
|
||||
}
|
||||
}
|
||||
|
||||
// 查找菜单项
|
||||
const findMenuItem = (items, key) => {
|
||||
for (const item of items) {
|
||||
if (item.key === key) {
|
||||
return item
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 跳转到个人中心
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #001529;
|
||||
padding: 0;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
}
|
||||
|
||||
.mobile-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.menu-button,
|
||||
.user-button {
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
font-size: 18px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-button:hover,
|
||||
.user-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 24px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.ant-menu-item .anticon,
|
||||
.ant-menu-submenu-title .anticon {
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: #e6f7ff !important;
|
||||
color: #1890ff !important;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-open > .ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
97
bank-frontend/src/config/env.js
Normal file
97
bank-frontend/src/config/env.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 环境配置
|
||||
* @file env.js
|
||||
* @description 应用环境配置管理
|
||||
*/
|
||||
|
||||
// 获取环境变量
|
||||
const getEnvVar = (key, defaultValue = '') => {
|
||||
return import.meta.env[key] || defaultValue
|
||||
}
|
||||
|
||||
// API配置
|
||||
export const API_CONFIG = {
|
||||
// API基础URL
|
||||
baseUrl: getEnvVar('VITE_API_BASE_URL', 'http://localhost:5351'),
|
||||
|
||||
// 是否使用代理
|
||||
useProxy: getEnvVar('VITE_USE_PROXY', 'true') === 'true',
|
||||
|
||||
// 完整的基础URL(用于直接请求)
|
||||
fullBaseUrl: getEnvVar('VITE_API_BASE_URL', 'http://localhost:5351'),
|
||||
|
||||
// 请求超时时间
|
||||
timeout: parseInt(getEnvVar('VITE_API_TIMEOUT', '10000')),
|
||||
|
||||
// 重试次数
|
||||
retryCount: parseInt(getEnvVar('VITE_API_RETRY_COUNT', '3'))
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
export const APP_CONFIG = {
|
||||
// 应用标题
|
||||
title: getEnvVar('VITE_APP_TITLE', '银行管理后台系统'),
|
||||
|
||||
// 应用版本
|
||||
version: getEnvVar('VITE_APP_VERSION', '1.0.0'),
|
||||
|
||||
// 应用描述
|
||||
description: getEnvVar('VITE_APP_DESCRIPTION', '专业的银行管理解决方案'),
|
||||
|
||||
// 是否显示版本信息
|
||||
showVersion: getEnvVar('VITE_SHOW_VERSION', 'true') === 'true',
|
||||
|
||||
// 是否启用调试模式
|
||||
debug: getEnvVar('VITE_DEBUG', 'false') === 'true'
|
||||
}
|
||||
|
||||
// 主题配置
|
||||
export const THEME_CONFIG = {
|
||||
// 主色调
|
||||
primaryColor: getEnvVar('VITE_PRIMARY_COLOR', '#1890ff'),
|
||||
|
||||
// 是否启用暗色主题
|
||||
darkMode: getEnvVar('VITE_DARK_MODE', 'false') === 'true',
|
||||
|
||||
// 是否启用紧凑模式
|
||||
compactMode: getEnvVar('VITE_COMPACT_MODE', 'false') === 'true'
|
||||
}
|
||||
|
||||
// 功能配置
|
||||
export const FEATURE_CONFIG = {
|
||||
// 是否启用实时通知
|
||||
enableNotification: getEnvVar('VITE_ENABLE_NOTIFICATION', 'true') === 'true',
|
||||
|
||||
// 是否启用数据导出
|
||||
enableExport: getEnvVar('VITE_ENABLE_EXPORT', 'true') === 'true',
|
||||
|
||||
// 是否启用数据导入
|
||||
enableImport: getEnvVar('VITE_ENABLE_IMPORT', 'true') === 'true',
|
||||
|
||||
// 是否启用图表功能
|
||||
enableCharts: getEnvVar('VITE_ENABLE_CHARTS', 'true') === 'true'
|
||||
}
|
||||
|
||||
// 安全配置
|
||||
export const SECURITY_CONFIG = {
|
||||
// Token存储键名
|
||||
tokenKey: 'bank_token',
|
||||
|
||||
// 用户信息存储键名
|
||||
userKey: 'bank_user',
|
||||
|
||||
// Token过期时间(小时)
|
||||
tokenExpireHours: parseInt(getEnvVar('VITE_TOKEN_EXPIRE_HOURS', '24')),
|
||||
|
||||
// 是否启用自动登录
|
||||
enableAutoLogin: getEnvVar('VITE_ENABLE_AUTO_LOGIN', 'true') === 'true'
|
||||
}
|
||||
|
||||
// 导出所有配置
|
||||
export default {
|
||||
API_CONFIG,
|
||||
APP_CONFIG,
|
||||
THEME_CONFIG,
|
||||
FEATURE_CONFIG,
|
||||
SECURITY_CONFIG
|
||||
}
|
||||
44
bank-frontend/src/main.js
Normal file
44
bank-frontend/src/main.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 应用入口文件
|
||||
* @file main.js
|
||||
* @description Vue应用初始化和配置
|
||||
*/
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import { themeConfig } from './styles/theme.js'
|
||||
import './styles/global.css'
|
||||
import './styles/responsive.css'
|
||||
import { useUserStore, useSettingsStore } from './stores'
|
||||
|
||||
// 导入图标组件
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标组件
|
||||
Object.keys(Icons).forEach(key => {
|
||||
app.component(key, Icons[key])
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd, {
|
||||
theme: themeConfig
|
||||
})
|
||||
|
||||
// 在应用挂载前初始化用户登录状态和设置
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 检查登录状态
|
||||
userStore.checkLoginStatus()
|
||||
|
||||
// 加载用户设置
|
||||
settingsStore.loadSettings()
|
||||
|
||||
app.mount('#app')
|
||||
75
bank-frontend/src/router/index.js
Normal file
75
bank-frontend/src/router/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 路由配置
|
||||
* @file index.js
|
||||
* @description Vue Router 配置和路由守卫
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import routes from './routes'
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// 如果有保存的位置,则恢复到保存的位置
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
// 否则滚动到顶部
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title ? `${to.meta.title} - 银行管理后台系统` : '银行管理后台系统'
|
||||
|
||||
// 获取用户存储
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 如果访问登录页面且已有有效token,重定向到仪表盘
|
||||
if (to.path === '/login' && userStore.token && userStore.isLoggedIn) {
|
||||
const redirectPath = to.query.redirect || '/dashboard'
|
||||
next(redirectPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该路由是否需要登录权限
|
||||
if (to.meta.requiresAuth) {
|
||||
// 如果需要登录但用户未登录,则重定向到登录页面
|
||||
if (!userStore.isLoggedIn) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath } // 保存原本要访问的路径,以便登录后重定向
|
||||
})
|
||||
} else {
|
||||
// 检查用户角色权限
|
||||
if (to.meta.roles && to.meta.roles.length > 0) {
|
||||
const userRole = userStore.getUserRoleName()
|
||||
if (!to.meta.roles.includes(userRole)) {
|
||||
// 用户角色不匹配,重定向到仪表盘
|
||||
next('/dashboard')
|
||||
} else {
|
||||
// 用户已登录且角色匹配,允许访问
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
// 没有角色限制,允许访问
|
||||
next()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不需要登录权限的路由,直接访问
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 全局后置钩子
|
||||
router.afterEach((to, from) => {
|
||||
// 路由切换后的逻辑,如记录访问历史、分析等
|
||||
console.log(`路由从 ${from.path} 切换到 ${to.path}`)
|
||||
})
|
||||
|
||||
export default router
|
||||
149
bank-frontend/src/router/routes.js
Normal file
149
bank-frontend/src/router/routes.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 路由配置
|
||||
* @file routes.js
|
||||
* @description 应用路由定义
|
||||
*/
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
TransactionOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
LoginOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '仪表盘',
|
||||
icon: DashboardOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'manager', 'teller', 'user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
icon: UserOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'manager']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
name: 'Accounts',
|
||||
component: () => import('@/views/Accounts.vue'),
|
||||
meta: {
|
||||
title: '账户管理',
|
||||
icon: BankOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'manager', 'teller', 'user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
name: 'Transactions',
|
||||
component: () => import('@/views/Transactions.vue'),
|
||||
meta: {
|
||||
title: '交易管理',
|
||||
icon: TransactionOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'manager', 'teller', 'user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'Reports',
|
||||
component: () => import('@/views/Reports.vue'),
|
||||
meta: {
|
||||
title: '报表统计',
|
||||
icon: BarChartOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'manager']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: {
|
||||
title: '系统设置',
|
||||
icon: SettingOutlined,
|
||||
requiresAuth: true,
|
||||
roles: ['admin']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
roles: ['admin', 'manager', 'teller', 'user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: {
|
||||
title: '页面不存在',
|
||||
requiresAuth: false,
|
||||
hideInMenu: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 根据用户角色过滤路由
|
||||
export function filterRoutesByRole(routes, userRole) {
|
||||
return routes.filter(route => {
|
||||
// 如果路由没有meta或没有角色限制,则允许访问
|
||||
if (!route.meta || !route.meta.roles) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查用户角色是否在允许的角色列表中
|
||||
return route.meta.roles.includes(userRole)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取菜单项
|
||||
export function getMenuItems(routes, userRole) {
|
||||
const filteredRoutes = filterRoutesByRole(routes, userRole)
|
||||
|
||||
return filteredRoutes
|
||||
.filter(route => !route.meta || !route.meta.hideInMenu)
|
||||
.map(route => ({
|
||||
key: route.name,
|
||||
title: route.meta?.title || route.name,
|
||||
icon: route.meta?.icon,
|
||||
path: route.path,
|
||||
children: route.children ? getMenuItems(route.children, userRole) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
export default routes
|
||||
7
bank-frontend/src/stores/index.js
Normal file
7
bank-frontend/src/stores/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 状态管理索引
|
||||
* @file index.js
|
||||
* @description 导出所有状态管理模块
|
||||
*/
|
||||
export { useUserStore } from './user'
|
||||
export { useSettingsStore } from './settings'
|
||||
269
bank-frontend/src/stores/settings.js
Normal file
269
bank-frontend/src/stores/settings.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 设置状态管理
|
||||
* @file settings.js
|
||||
* @description 应用设置相关的状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 侧边栏状态
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 主题设置
|
||||
const theme = ref('light') // light, dark
|
||||
const primaryColor = ref('#1890ff')
|
||||
|
||||
// 语言设置
|
||||
const language = ref('zh-CN')
|
||||
|
||||
// 页面设置
|
||||
const pageSize = ref(10)
|
||||
const showBreadcrumb = ref(true)
|
||||
const showFooter = ref(true)
|
||||
|
||||
// 表格设置
|
||||
const tableSize = ref('middle') // small, middle, large
|
||||
const tableBordered = ref(false)
|
||||
const tableStriped = ref(true)
|
||||
|
||||
// 表单设置
|
||||
const formSize = ref('middle')
|
||||
const formLabelAlign = ref('right')
|
||||
|
||||
// 动画设置
|
||||
const enableAnimation = ref(true)
|
||||
const animationDuration = ref(300)
|
||||
|
||||
// 通知设置
|
||||
const enableNotification = ref(true)
|
||||
const enableSound = ref(false)
|
||||
|
||||
// 数据设置
|
||||
const autoRefresh = ref(true)
|
||||
const refreshInterval = ref(30000) // 30秒
|
||||
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 设置侧边栏状态
|
||||
function setSidebarCollapsed(collapsed) {
|
||||
sidebarCollapsed.value = collapsed
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
}
|
||||
|
||||
// 设置主色调
|
||||
function setPrimaryColor(color) {
|
||||
primaryColor.value = color
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
function setLanguage(lang) {
|
||||
language.value = lang
|
||||
}
|
||||
|
||||
// 设置页面大小
|
||||
function setPageSize(size) {
|
||||
pageSize.value = size
|
||||
}
|
||||
|
||||
// 设置表格大小
|
||||
function setTableSize(size) {
|
||||
tableSize.value = size
|
||||
}
|
||||
|
||||
// 设置表格边框
|
||||
function setTableBordered(bordered) {
|
||||
tableBordered.value = bordered
|
||||
}
|
||||
|
||||
// 设置表格斑马纹
|
||||
function setTableStriped(striped) {
|
||||
tableStriped.value = striped
|
||||
}
|
||||
|
||||
// 设置表单大小
|
||||
function setFormSize(size) {
|
||||
formSize.value = size
|
||||
}
|
||||
|
||||
// 设置表单标签对齐
|
||||
function setFormLabelAlign(align) {
|
||||
formLabelAlign.value = align
|
||||
}
|
||||
|
||||
// 设置动画
|
||||
function setAnimation(enabled, duration = 300) {
|
||||
enableAnimation.value = enabled
|
||||
animationDuration.value = duration
|
||||
}
|
||||
|
||||
// 设置通知
|
||||
function setNotification(enabled, sound = false) {
|
||||
enableNotification.value = enabled
|
||||
enableSound.value = sound
|
||||
}
|
||||
|
||||
// 设置自动刷新
|
||||
function setAutoRefresh(enabled, interval = 30000) {
|
||||
autoRefresh.value = enabled
|
||||
refreshInterval.value = interval
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
function resetSettings() {
|
||||
sidebarCollapsed.value = false
|
||||
theme.value = 'light'
|
||||
primaryColor.value = '#1890ff'
|
||||
language.value = 'zh-CN'
|
||||
pageSize.value = 10
|
||||
showBreadcrumb.value = true
|
||||
showFooter.value = true
|
||||
tableSize.value = 'middle'
|
||||
tableBordered.value = false
|
||||
tableStriped.value = true
|
||||
formSize.value = 'middle'
|
||||
formLabelAlign.value = 'right'
|
||||
enableAnimation.value = true
|
||||
animationDuration.value = 300
|
||||
enableNotification.value = true
|
||||
enableSound.value = false
|
||||
autoRefresh.value = true
|
||||
refreshInterval.value = 30000
|
||||
}
|
||||
|
||||
// 保存设置到本地存储
|
||||
function saveSettings() {
|
||||
const settings = {
|
||||
sidebarCollapsed: sidebarCollapsed.value,
|
||||
theme: theme.value,
|
||||
primaryColor: primaryColor.value,
|
||||
language: language.value,
|
||||
pageSize: pageSize.value,
|
||||
showBreadcrumb: showBreadcrumb.value,
|
||||
showFooter: showFooter.value,
|
||||
tableSize: tableSize.value,
|
||||
tableBordered: tableBordered.value,
|
||||
tableStriped: tableStriped.value,
|
||||
formSize: formSize.value,
|
||||
formLabelAlign: formLabelAlign.value,
|
||||
enableAnimation: enableAnimation.value,
|
||||
animationDuration: animationDuration.value,
|
||||
enableNotification: enableNotification.value,
|
||||
enableSound: enableSound.value,
|
||||
autoRefresh: autoRefresh.value,
|
||||
refreshInterval: refreshInterval.value
|
||||
}
|
||||
|
||||
localStorage.setItem('bank_settings', JSON.stringify(settings))
|
||||
}
|
||||
|
||||
// 从本地存储加载设置
|
||||
function loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem('bank_settings')
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved)
|
||||
|
||||
sidebarCollapsed.value = settings.sidebarCollapsed ?? false
|
||||
theme.value = settings.theme ?? 'light'
|
||||
primaryColor.value = settings.primaryColor ?? '#1890ff'
|
||||
language.value = settings.language ?? 'zh-CN'
|
||||
pageSize.value = settings.pageSize ?? 10
|
||||
showBreadcrumb.value = settings.showBreadcrumb ?? true
|
||||
showFooter.value = settings.showFooter ?? true
|
||||
tableSize.value = settings.tableSize ?? 'middle'
|
||||
tableBordered.value = settings.tableBordered ?? false
|
||||
tableStriped.value = settings.tableStriped ?? true
|
||||
formSize.value = settings.formSize ?? 'middle'
|
||||
formLabelAlign.value = settings.formLabelAlign ?? 'right'
|
||||
enableAnimation.value = settings.enableAnimation ?? true
|
||||
animationDuration.value = settings.animationDuration ?? 300
|
||||
enableNotification.value = settings.enableNotification ?? true
|
||||
enableSound.value = settings.enableSound ?? false
|
||||
autoRefresh.value = settings.autoRefresh ?? true
|
||||
refreshInterval.value = settings.refreshInterval ?? 30000
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设置快照
|
||||
function getSettingsSnapshot() {
|
||||
return {
|
||||
sidebarCollapsed: sidebarCollapsed.value,
|
||||
theme: theme.value,
|
||||
primaryColor: primaryColor.value,
|
||||
language: language.value,
|
||||
pageSize: pageSize.value,
|
||||
showBreadcrumb: showBreadcrumb.value,
|
||||
showFooter: showFooter.value,
|
||||
tableSize: tableSize.value,
|
||||
tableBordered: tableBordered.value,
|
||||
tableStriped: tableStriped.value,
|
||||
formSize: formSize.value,
|
||||
formLabelAlign: formLabelAlign.value,
|
||||
enableAnimation: enableAnimation.value,
|
||||
animationDuration: animationDuration.value,
|
||||
enableNotification: enableNotification.value,
|
||||
enableSound: enableSound.value,
|
||||
autoRefresh: autoRefresh.value,
|
||||
refreshInterval: refreshInterval.value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
primaryColor,
|
||||
language,
|
||||
pageSize,
|
||||
showBreadcrumb,
|
||||
showFooter,
|
||||
tableSize,
|
||||
tableBordered,
|
||||
tableStriped,
|
||||
formSize,
|
||||
formLabelAlign,
|
||||
enableAnimation,
|
||||
animationDuration,
|
||||
enableNotification,
|
||||
enableSound,
|
||||
autoRefresh,
|
||||
refreshInterval,
|
||||
|
||||
// 方法
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
setPrimaryColor,
|
||||
setLanguage,
|
||||
setPageSize,
|
||||
setTableSize,
|
||||
setTableBordered,
|
||||
setTableStriped,
|
||||
setFormSize,
|
||||
setFormLabelAlign,
|
||||
setAnimation,
|
||||
setNotification,
|
||||
setAutoRefresh,
|
||||
resetSettings,
|
||||
saveSettings,
|
||||
loadSettings,
|
||||
getSettingsSnapshot
|
||||
}
|
||||
})
|
||||
230
bank-frontend/src/stores/user.js
Normal file
230
bank-frontend/src/stores/user.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
* @file user.js
|
||||
* @description 用户相关的状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { SECURITY_CONFIG } from '@/config/env'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref(localStorage.getItem(SECURITY_CONFIG.tokenKey) || '')
|
||||
const userData = ref(JSON.parse(localStorage.getItem(SECURITY_CONFIG.userKey) || 'null'))
|
||||
const isLoggedIn = computed(() => !!token.value && !!userData.value)
|
||||
|
||||
// 检查登录状态
|
||||
function checkLoginStatus() {
|
||||
const savedToken = localStorage.getItem(SECURITY_CONFIG.tokenKey)
|
||||
const savedUser = localStorage.getItem(SECURITY_CONFIG.userKey)
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken
|
||||
userData.value = JSON.parse(savedUser)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('解析用户数据失败', error)
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查token是否有效
|
||||
async function validateToken() {
|
||||
if (!token.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { api } = await import('@/utils/api')
|
||||
// 尝试调用一个需要认证的API来验证token
|
||||
await api.get('/users/profile')
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('认证已过期')) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
// 其他错误可能是网络问题,不清除token
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 登录操作
|
||||
async function login(username, password, retryCount = 0) {
|
||||
try {
|
||||
const { api } = await import('@/utils/api')
|
||||
const result = await api.login(username, password)
|
||||
|
||||
// 登录成功后设置token和用户数据
|
||||
if (result.success && result.data.token) {
|
||||
token.value = result.data.token
|
||||
userData.value = {
|
||||
id: result.data.user.id,
|
||||
username: result.data.user.username,
|
||||
email: result.data.user.email,
|
||||
real_name: result.data.user.real_name,
|
||||
phone: result.data.user.phone,
|
||||
avatar: result.data.user.avatar,
|
||||
role: result.data.user.role,
|
||||
status: result.data.user.status
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem(SECURITY_CONFIG.tokenKey, result.data.token)
|
||||
localStorage.setItem(SECURITY_CONFIG.userKey, JSON.stringify(userData.value))
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error)
|
||||
// 重试逻辑(仅对500错误且重试次数<2)
|
||||
if (error.message.includes('500') && retryCount < 2) {
|
||||
return login(username, password, retryCount + 1)
|
||||
}
|
||||
// 直接抛出错误,由调用方处理
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 登出操作
|
||||
async function logout() {
|
||||
try {
|
||||
// 调用后端登出接口
|
||||
const { api } = await import('@/utils/api')
|
||||
await api.post('/users/logout')
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
token.value = ''
|
||||
userData.value = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem(SECURITY_CONFIG.tokenKey)
|
||||
localStorage.removeItem(SECURITY_CONFIG.userKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
function updateUserInfo(newUserInfo) {
|
||||
userData.value = { ...userData.value, ...newUserInfo }
|
||||
localStorage.setItem(SECURITY_CONFIG.userKey, JSON.stringify(userData.value))
|
||||
}
|
||||
|
||||
// 权限检查方法
|
||||
function hasPermission(permission) {
|
||||
if (!userData.value || !userData.value.role) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 管理员拥有所有权限
|
||||
if (userData.value.role.name === 'admin') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 根据角色检查权限
|
||||
const rolePermissions = {
|
||||
'admin': ['*'], // 所有权限
|
||||
'manager': ['user:read', 'user:write', 'account:read', 'account:write', 'transaction:read', 'transaction:write'],
|
||||
'teller': ['user:read', 'account:read', 'account:write', 'transaction:read', 'transaction:write'],
|
||||
'user': ['account:read', 'transaction:read']
|
||||
}
|
||||
|
||||
const userRole = userData.value.role.name
|
||||
const permissions = rolePermissions[userRole] || []
|
||||
|
||||
if (Array.isArray(permission)) {
|
||||
return permission.some(p => permissions.includes(p) || permissions.includes('*'))
|
||||
}
|
||||
|
||||
return permissions.includes(permission) || permissions.includes('*')
|
||||
}
|
||||
|
||||
// 角色检查方法
|
||||
function hasRole(role) {
|
||||
if (!userData.value || !userData.value.role) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(role)) {
|
||||
return role.includes(userData.value.role.name)
|
||||
}
|
||||
|
||||
return userData.value.role.name === role
|
||||
}
|
||||
|
||||
// 检查是否可以访问菜单
|
||||
function canAccessMenu(menuKey) {
|
||||
if (!userData.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 管理员可以访问所有菜单
|
||||
if (userData.value.role.name === 'admin') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 根据角色定义可访问的菜单
|
||||
const roleMenus = {
|
||||
'admin': ['*'], // 所有菜单
|
||||
'manager': ['dashboard', 'users', 'accounts', 'transactions', 'reports'],
|
||||
'teller': ['dashboard', 'accounts', 'transactions'],
|
||||
'user': ['dashboard', 'accounts', 'transactions']
|
||||
}
|
||||
|
||||
const userRole = userData.value.role.name
|
||||
const menus = roleMenus[userRole] || []
|
||||
|
||||
return menus.includes(menuKey) || menus.includes('*')
|
||||
}
|
||||
|
||||
// 获取用户角色名称
|
||||
function getUserRoleName() {
|
||||
return userData.value?.role?.name || 'user'
|
||||
}
|
||||
|
||||
// 获取用户权限列表
|
||||
function getUserPermissions() {
|
||||
const rolePermissions = {
|
||||
'admin': ['*'],
|
||||
'manager': ['user:read', 'user:write', 'account:read', 'account:write', 'transaction:read', 'transaction:write'],
|
||||
'teller': ['user:read', 'account:read', 'account:write', 'transaction:read', 'transaction:write'],
|
||||
'user': ['account:read', 'transaction:read']
|
||||
}
|
||||
|
||||
const userRole = userData.value?.role?.name || 'user'
|
||||
return rolePermissions[userRole] || []
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
function isUserActive() {
|
||||
return userData.value?.status === 'active'
|
||||
}
|
||||
|
||||
// 检查用户是否被锁定
|
||||
function isUserLocked() {
|
||||
return userData.value?.status === 'locked' || userData.value?.status === 'suspended'
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userData,
|
||||
isLoggedIn,
|
||||
checkLoginStatus,
|
||||
validateToken,
|
||||
login,
|
||||
logout,
|
||||
updateUserInfo,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
canAccessMenu,
|
||||
getUserRoleName,
|
||||
getUserPermissions,
|
||||
isUserActive,
|
||||
isUserLocked
|
||||
}
|
||||
})
|
||||
459
bank-frontend/src/styles/global.css
Normal file
459
bank-frontend/src/styles/global.css
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 全局样式
|
||||
* @file global.css
|
||||
* @description 全局CSS样式定义
|
||||
*/
|
||||
|
||||
/* 重置样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: #262626;
|
||||
background-color: #f0f2f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
/* 按钮样式增强 */
|
||||
.ant-btn {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ant-btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.ant-btn-primary:active {
|
||||
background: #096dd9;
|
||||
border-color: #096dd9;
|
||||
}
|
||||
|
||||
/* 输入框样式增强 */
|
||||
.ant-input,
|
||||
.ant-input-password,
|
||||
.ant-select-selector {
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password:focus,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片样式增强 */
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ant-card:hover {
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02), 0 4px 8px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 表格样式增强 */
|
||||
.ant-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 模态框样式增强 */
|
||||
.ant-modal {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* 抽屉样式增强 */
|
||||
.ant-drawer-content {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页面布局样式 */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* 搜索区域样式 */
|
||||
.search-area {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-buttons .ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 状态标签样式 */
|
||||
.status-tag {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: #fff2e8;
|
||||
color: #fa8c16;
|
||||
border-color: #ffd591;
|
||||
}
|
||||
|
||||
.status-suspended {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.status-locked {
|
||||
background: #f5f5f5;
|
||||
color: #8c8c8c;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 金额显示样式 */
|
||||
.amount-text {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.amount-zero {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 加载状态样式 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-left: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-buttons .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.page-content {
|
||||
box-shadow: none;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mt-24 { margin-top: 24px; }
|
||||
|
||||
.ml-0 { margin-left: 0; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
.ml-16 { margin-left: 16px; }
|
||||
.ml-24 { margin-left: 24px; }
|
||||
|
||||
.mr-0 { margin-right: 0; }
|
||||
.mr-8 { margin-right: 8px; }
|
||||
.mr-16 { margin-right: 16px; }
|
||||
.mr-24 { margin-right: 24px; }
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-8 { padding: 8px; }
|
||||
.p-16 { padding: 16px; }
|
||||
.p-24 { padding: 24px; }
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
520
bank-frontend/src/styles/responsive.css
Normal file
520
bank-frontend/src/styles/responsive.css
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 响应式样式
|
||||
* @file responsive.css
|
||||
* @description 响应式设计样式定义
|
||||
*/
|
||||
|
||||
/* 移动端样式 */
|
||||
@media (max-width: 768px) {
|
||||
/* 布局调整 */
|
||||
.ant-layout-header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
position: fixed !important;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 菜单调整 */
|
||||
.ant-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
/* 表格调整 */
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表单调整 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* 按钮调整 */
|
||||
.ant-btn {
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ant-btn-sm {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ant-btn-lg {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* 输入框调整 */
|
||||
.ant-input,
|
||||
.ant-input-password,
|
||||
.ant-select-selector {
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 卡片调整 */
|
||||
.ant-card {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 模态框调整 */
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* 抽屉调整 */
|
||||
.ant-drawer-content {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 分页调整 */
|
||||
.ant-pagination {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ant-pagination-item,
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
/* 标签调整 */
|
||||
.ant-tag {
|
||||
margin: 2px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 步骤条调整 */
|
||||
.ant-steps {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.ant-steps-item-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-steps-item-description {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 时间轴调整 */
|
||||
.ant-timeline-item-content {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 描述列表调整 */
|
||||
.ant-descriptions-item-label {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-descriptions-item-content {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* 统计数值调整 */
|
||||
.ant-statistic-title {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 24px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
/* 进度条调整 */
|
||||
.ant-progress-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 工具提示调整 */
|
||||
.ant-tooltip-inner {
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
/* 消息提示调整 */
|
||||
.ant-message {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.ant-message-notice-content {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 通知调整 */
|
||||
.ant-notification {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.ant-notification-notice {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 确认对话框调整 */
|
||||
.ant-modal-confirm {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-btns {
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* 选择器调整 */
|
||||
.ant-select-dropdown {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 日期选择器调整 */
|
||||
.ant-picker {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.ant-picker-dropdown {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 上传组件调整 */
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-dragger {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 评分组件调整 */
|
||||
.ant-rate {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 开关组件调整 */
|
||||
.ant-switch {
|
||||
min-width: 44px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* 滑块组件调整 */
|
||||
.ant-slider {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* 穿梭框调整 */
|
||||
.ant-transfer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-transfer-list {
|
||||
width: calc(50% - 8px);
|
||||
}
|
||||
|
||||
/* 树形控件调整 */
|
||||
.ant-tree {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-tree-node-content-wrapper {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* 锚点调整 */
|
||||
.ant-anchor {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-anchor-link {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 回到顶部调整 */
|
||||
.ant-back-top {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
/* 加载中调整 */
|
||||
.ant-spin-container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.ant-spin-text {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板样式 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
/* 布局调整 */
|
||||
.ant-layout-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 表格调整 */
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 卡片调整 */
|
||||
.ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 模态框调整 */
|
||||
.ant-modal {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 表单调整 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕样式 */
|
||||
@media (min-width: 1200px) {
|
||||
/* 容器最大宽度 */
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 表格调整 */
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 16px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 卡片调整 */
|
||||
.ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 模态框调整 */
|
||||
.ant-modal {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 表单调整 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超宽屏幕样式 */
|
||||
@media (min-width: 1600px) {
|
||||
/* 容器最大宽度 */
|
||||
.page-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 表格调整 */
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 20px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 卡片调整 */
|
||||
.ant-card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 表单调整 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏模式调整 */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.ant-layout-header {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
min-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高分辨率屏幕调整 */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 0.5px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
border-top: 0.5px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
border: 0.5px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.ant-layout-header,
|
||||
.ant-layout-sider,
|
||||
.ant-layout-footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.page-content {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
172
bank-frontend/src/styles/theme.js
Normal file
172
bank-frontend/src/styles/theme.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 主题配置
|
||||
* @file theme.js
|
||||
* @description Ant Design Vue 主题配置
|
||||
*/
|
||||
import { THEME_CONFIG } from '@/config/env'
|
||||
|
||||
// Ant Design Vue 主题配置
|
||||
export const themeConfig = {
|
||||
token: {
|
||||
// 主色调
|
||||
colorPrimary: THEME_CONFIG.primaryColor,
|
||||
|
||||
// 成功色
|
||||
colorSuccess: '#52c41a',
|
||||
|
||||
// 警告色
|
||||
colorWarning: '#faad14',
|
||||
|
||||
// 错误色
|
||||
colorError: '#ff4d4f',
|
||||
|
||||
// 信息色
|
||||
colorInfo: THEME_CONFIG.primaryColor,
|
||||
|
||||
// 字体大小
|
||||
fontSize: 14,
|
||||
|
||||
// 圆角
|
||||
borderRadius: 6,
|
||||
|
||||
// 组件尺寸
|
||||
sizeUnit: 4,
|
||||
sizeStep: 4,
|
||||
|
||||
// 控制台高度
|
||||
controlHeight: 32,
|
||||
|
||||
// 行高
|
||||
lineHeight: 1.5715,
|
||||
|
||||
// 字体族
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
},
|
||||
|
||||
components: {
|
||||
// 布局组件
|
||||
Layout: {
|
||||
headerBg: '#001529',
|
||||
siderBg: '#001529',
|
||||
bodyBg: '#f0f2f5'
|
||||
},
|
||||
|
||||
// 菜单组件
|
||||
Menu: {
|
||||
darkItemBg: '#001529',
|
||||
darkItemSelectedBg: '#1890ff',
|
||||
darkItemHoverBg: '#1890ff'
|
||||
},
|
||||
|
||||
// 按钮组件
|
||||
Button: {
|
||||
borderRadius: 6,
|
||||
controlHeight: 32
|
||||
},
|
||||
|
||||
// 输入框组件
|
||||
Input: {
|
||||
borderRadius: 6,
|
||||
controlHeight: 32
|
||||
},
|
||||
|
||||
// 表格组件
|
||||
Table: {
|
||||
headerBg: '#fafafa',
|
||||
headerColor: '#262626',
|
||||
rowHoverBg: '#f5f5f5'
|
||||
},
|
||||
|
||||
// 卡片组件
|
||||
Card: {
|
||||
borderRadius: 8,
|
||||
headerBg: '#fafafa'
|
||||
},
|
||||
|
||||
// 模态框组件
|
||||
Modal: {
|
||||
borderRadius: 8
|
||||
},
|
||||
|
||||
// 抽屉组件
|
||||
Drawer: {
|
||||
borderRadius: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题配置
|
||||
export const darkThemeConfig = {
|
||||
token: {
|
||||
...themeConfig.token,
|
||||
colorBgBase: '#141414',
|
||||
colorBgContainer: '#1f1f1f',
|
||||
colorBgElevated: '#262626',
|
||||
colorBorder: '#424242',
|
||||
colorText: '#ffffff',
|
||||
colorTextSecondary: '#a6a6a6',
|
||||
colorTextTertiary: '#737373',
|
||||
colorTextQuaternary: '#595959'
|
||||
},
|
||||
|
||||
components: {
|
||||
...themeConfig.components,
|
||||
Layout: {
|
||||
headerBg: '#141414',
|
||||
siderBg: '#141414',
|
||||
bodyBg: '#000000'
|
||||
},
|
||||
Menu: {
|
||||
darkItemBg: '#141414',
|
||||
darkItemSelectedBg: '#1890ff',
|
||||
darkItemHoverBg: '#1890ff'
|
||||
},
|
||||
Table: {
|
||||
headerBg: '#262626',
|
||||
headerColor: '#ffffff',
|
||||
rowHoverBg: '#262626'
|
||||
},
|
||||
Card: {
|
||||
borderRadius: 8,
|
||||
headerBg: '#262626'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 紧凑主题配置
|
||||
export const compactThemeConfig = {
|
||||
token: {
|
||||
...themeConfig.token,
|
||||
fontSize: 12,
|
||||
controlHeight: 24,
|
||||
sizeUnit: 2,
|
||||
sizeStep: 2
|
||||
},
|
||||
|
||||
components: {
|
||||
...themeConfig.components,
|
||||
Button: {
|
||||
borderRadius: 4,
|
||||
controlHeight: 24
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 4,
|
||||
controlHeight: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据配置返回主题
|
||||
export const getThemeConfig = () => {
|
||||
if (THEME_CONFIG.darkMode) {
|
||||
return darkThemeConfig
|
||||
}
|
||||
|
||||
if (THEME_CONFIG.compactMode) {
|
||||
return compactThemeConfig
|
||||
}
|
||||
|
||||
return themeConfig
|
||||
}
|
||||
|
||||
export default themeConfig
|
||||
382
bank-frontend/src/utils/api.js
Normal file
382
bank-frontend/src/utils/api.js
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* API请求工具
|
||||
* @file api.js
|
||||
* @description 封装银行系统API请求方法
|
||||
*/
|
||||
import { API_CONFIG, SECURITY_CONFIG } from '@/config/env'
|
||||
|
||||
/**
|
||||
* 创建请求头,自动添加认证Token
|
||||
* @param {Object} headers - 额外的请求头
|
||||
* @returns {Object} 合并后的请求头
|
||||
*/
|
||||
const createHeaders = (headers = {}) => {
|
||||
const token = localStorage.getItem(SECURITY_CONFIG.tokenKey)
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return { ...defaultHeaders, ...headers }
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理API响应
|
||||
* @param {Response} response - Fetch API响应对象
|
||||
* @returns {Promise} 处理后的响应数据
|
||||
*/
|
||||
const handleResponse = async (response) => {
|
||||
// 检查HTTP状态
|
||||
if (!response.ok) {
|
||||
// 处理常见错误
|
||||
if (response.status === 401) {
|
||||
// 清除无效的认证信息
|
||||
localStorage.removeItem(SECURITY_CONFIG.tokenKey)
|
||||
localStorage.removeItem(SECURITY_CONFIG.userKey)
|
||||
throw new Error('认证已过期,请重新登录')
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error('您没有权限访问此资源,请联系管理员')
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('请求的资源不存在')
|
||||
}
|
||||
|
||||
if (response.status === 500) {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || '服务器内部错误,请联系管理员')
|
||||
} catch (e) {
|
||||
throw new Error('服务器内部错误,请联系管理员')
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取详细错误信息
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || `请求失败: ${response.status} ${response.statusText}`)
|
||||
} catch (e) {
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应类型
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
// 如果是blob类型(如文件下载),直接返回blob
|
||||
if (contentType && (contentType.includes('text/csv') ||
|
||||
contentType.includes('application/octet-stream') ||
|
||||
contentType.includes('application/vnd.ms-excel') ||
|
||||
contentType.includes('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))) {
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
// 返回JSON数据
|
||||
const result = await response.json()
|
||||
|
||||
// 兼容数组响应
|
||||
if (Array.isArray(result)) {
|
||||
return result
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'API请求失败')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* API请求方法
|
||||
*/
|
||||
export const api = {
|
||||
/**
|
||||
* 登录
|
||||
* @param {string} username - 用户名
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise} 登录结果
|
||||
*/
|
||||
async login(username, password) {
|
||||
const response = await fetch(`${API_CONFIG.baseUrl}/api/users/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
return handleResponse(response)
|
||||
},
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async get(endpoint, options = {}) {
|
||||
let url = `${API_CONFIG.baseUrl}/api${endpoint}`
|
||||
|
||||
// 处理查询参数
|
||||
if (options.params && Object.keys(options.params).length > 0) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(options.params)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
searchParams.append(key, value)
|
||||
}
|
||||
}
|
||||
url += `?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
headers: createHeaders(options.headers),
|
||||
params: undefined,
|
||||
})
|
||||
|
||||
// 如果指定了responseType为blob,直接返回blob
|
||||
if (options.responseType === 'blob') {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
return handleResponse(response)
|
||||
},
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} data - 请求数据
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async post(endpoint, data, options = {}) {
|
||||
const url = `${API_CONFIG.baseUrl}/api${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: createHeaders(options.headers),
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
})
|
||||
return handleResponse(response)
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} data - 请求数据
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async put(endpoint, data, options = {}) {
|
||||
const url = `${API_CONFIG.baseUrl}/api${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: createHeaders(options.headers),
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
})
|
||||
return handleResponse(response)
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async delete(endpoint, options = {}) {
|
||||
const url = `${API_CONFIG.baseUrl}/api${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: createHeaders(options.headers),
|
||||
...options,
|
||||
})
|
||||
return handleResponse(response)
|
||||
},
|
||||
|
||||
// 用户管理API
|
||||
users: {
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise} 用户列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/users', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param {number} id - 用户ID
|
||||
* @returns {Promise} 用户详情
|
||||
*/
|
||||
async getById(id) {
|
||||
return api.get(`/users/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @param {Object} data - 用户数据
|
||||
* @returns {Promise} 创建结果
|
||||
*/
|
||||
async create(data) {
|
||||
return api.post('/users/register', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param {number} id - 用户ID
|
||||
* @param {Object} data - 更新数据
|
||||
* @returns {Promise} 更新结果
|
||||
*/
|
||||
async update(id, data) {
|
||||
return api.put(`/users/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param {number} id - 用户ID
|
||||
* @returns {Promise} 删除结果
|
||||
*/
|
||||
async delete(id) {
|
||||
return api.delete(`/users/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* @param {number} id - 用户ID
|
||||
* @param {Object} data - 状态数据
|
||||
* @returns {Promise} 更新结果
|
||||
*/
|
||||
async updateStatus(id, data) {
|
||||
return api.put(`/users/${id}/status`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户账户列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Promise} 账户列表
|
||||
*/
|
||||
async getAccounts(userId) {
|
||||
return api.get(`/users/${userId}/accounts`)
|
||||
}
|
||||
},
|
||||
|
||||
// 账户管理API
|
||||
accounts: {
|
||||
/**
|
||||
* 获取账户列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise} 账户列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/accounts', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取账户详情
|
||||
* @param {number} id - 账户ID
|
||||
* @returns {Promise} 账户详情
|
||||
*/
|
||||
async getById(id) {
|
||||
return api.get(`/accounts/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建账户
|
||||
* @param {Object} data - 账户数据
|
||||
* @returns {Promise} 创建结果
|
||||
*/
|
||||
async create(data) {
|
||||
return api.post('/accounts', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新账户状态
|
||||
* @param {number} id - 账户ID
|
||||
* @param {Object} data - 状态数据
|
||||
* @returns {Promise} 更新结果
|
||||
*/
|
||||
async updateStatus(id, data) {
|
||||
return api.put(`/accounts/${id}/status`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 存款
|
||||
* @param {number} id - 账户ID
|
||||
* @param {Object} data - 存款数据
|
||||
* @returns {Promise} 存款结果
|
||||
*/
|
||||
async deposit(id, data) {
|
||||
return api.post(`/accounts/${id}/deposit`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取款
|
||||
* @param {number} id - 账户ID
|
||||
* @param {Object} data - 取款数据
|
||||
* @returns {Promise} 取款结果
|
||||
*/
|
||||
async withdraw(id, data) {
|
||||
return api.post(`/accounts/${id}/withdraw`, data)
|
||||
}
|
||||
},
|
||||
|
||||
// 交易管理API
|
||||
transactions: {
|
||||
/**
|
||||
* 获取交易记录列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise} 交易记录列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/transactions', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取交易详情
|
||||
* @param {number} id - 交易ID
|
||||
* @returns {Promise} 交易详情
|
||||
*/
|
||||
async getById(id) {
|
||||
return api.get(`/transactions/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 转账
|
||||
* @param {Object} data - 转账数据
|
||||
* @returns {Promise} 转账结果
|
||||
*/
|
||||
async transfer(data) {
|
||||
return api.post('/transactions/transfer', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤销交易
|
||||
* @param {number} id - 交易ID
|
||||
* @returns {Promise} 撤销结果
|
||||
*/
|
||||
async reverse(id) {
|
||||
return api.post(`/transactions/${id}/reverse`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取交易统计
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise} 交易统计
|
||||
*/
|
||||
async getStats(params = {}) {
|
||||
return api.get('/transactions/stats', { params })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
785
bank-frontend/src/views/Accounts.vue
Normal file
785
bank-frontend/src/views/Accounts.vue
Normal file
@@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<div class="accounts-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1>账户管理</h1>
|
||||
<p>管理银行账户信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<a-button type="primary" @click="showAddAccountModal">
|
||||
<plus-outlined /> 添加账户
|
||||
</a-button>
|
||||
<a-input-search
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索账户..."
|
||||
style="width: 250px; margin-left: 16px;"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 账户表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="accounts"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusName(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 账户类型列 -->
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeName(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 余额列 -->
|
||||
<template v-if="column.key === 'balance'">
|
||||
<span :style="{ color: record.balance < 0 ? '#f5222d' : '' }">
|
||||
{{ formatCurrency(record.balance) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="viewAccount(record)">查看</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="editAccount(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
title="确定要冻结此账户吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="freezeAccount(record.id)"
|
||||
v-if="record.status === 'active'"
|
||||
>
|
||||
<a class="warning-link">冻结</a>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
title="确定要激活此账户吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="activateAccount(record.id)"
|
||||
v-if="record.status === 'frozen'"
|
||||
>
|
||||
<a class="success-link">激活</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑账户对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="accountModalVisible"
|
||||
:title="isEditing ? '编辑账户' : '添加账户'"
|
||||
@ok="handleAccountFormSubmit"
|
||||
:confirmLoading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="accountForm"
|
||||
:rules="rules"
|
||||
ref="accountFormRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="账户号码" name="accountNumber" v-if="isEditing">
|
||||
<a-input v-model:value="accountForm.accountNumber" disabled />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="账户名称" name="name">
|
||||
<a-input v-model:value="accountForm.name" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="账户类型" name="type">
|
||||
<a-select v-model:value="accountForm.type">
|
||||
<a-select-option value="savings">储蓄账户</a-select-option>
|
||||
<a-select-option value="checking">活期账户</a-select-option>
|
||||
<a-select-option value="credit">信用账户</a-select-option>
|
||||
<a-select-option value="loan">贷款账户</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="所属用户" name="userId">
|
||||
<a-select
|
||||
v-model:value="accountForm.userId"
|
||||
:loading="usersLoading"
|
||||
show-search
|
||||
placeholder="选择用户"
|
||||
:filter-option="filterUserOption"
|
||||
>
|
||||
<a-select-option v-for="user in usersList" :key="user.id" :value="user.id">
|
||||
{{ user.name }} ({{ user.username }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="初始余额" name="balance" v-if="!isEditing">
|
||||
<a-input-number
|
||||
v-model:value="accountForm.balance"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status" v-if="isEditing">
|
||||
<a-select v-model:value="accountForm.status">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="frozen">冻结</a-select-option>
|
||||
<a-select-option value="closed">关闭</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注" name="notes">
|
||||
<a-textarea v-model:value="accountForm.notes" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 账户详情对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="accountDetailVisible"
|
||||
title="账户详情"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
>
|
||||
<template v-if="selectedAccount">
|
||||
<a-descriptions bordered :column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }">
|
||||
<a-descriptions-item label="账户号码">{{ selectedAccount.accountNumber }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账户名称">{{ selectedAccount.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账户类型">{{ getTypeName(selectedAccount.type) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账户状态">
|
||||
<a-tag :color="getStatusColor(selectedAccount.status)">
|
||||
{{ getStatusName(selectedAccount.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前余额">{{ formatCurrency(selectedAccount.balance) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属用户">{{ selectedAccount.userName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="开户日期">{{ selectedAccount.createdAt }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最后更新">{{ selectedAccount.updatedAt }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ selectedAccount.notes || '无' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>最近交易记录</a-divider>
|
||||
|
||||
<a-table
|
||||
:columns="transactionColumns"
|
||||
:data-source="recentTransactions"
|
||||
:loading="transactionsLoading"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="getTransactionTypeColor(record.type)">
|
||||
{{ getTransactionTypeName(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span :style="{ color: record.type === 'withdrawal' ? '#f5222d' : '#52c41a' }">
|
||||
{{ record.type === 'withdrawal' ? '-' : '+' }}{{ formatCurrency(record.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<a-button @click="accountDetailVisible = false">关闭</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AccountsPage',
|
||||
components: {
|
||||
PlusOutlined
|
||||
},
|
||||
setup() {
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '账户号码',
|
||||
dataIndex: 'accountNumber',
|
||||
key: 'accountNumber',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '账户名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '账户类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
filters: [
|
||||
{ text: '储蓄账户', value: 'savings' },
|
||||
{ text: '活期账户', value: 'checking' },
|
||||
{ text: '信用账户', value: 'credit' },
|
||||
{ text: '贷款账户', value: 'loan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '所属用户',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balance',
|
||||
key: 'balance',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '活跃', value: 'active' },
|
||||
{ text: '冻结', value: 'frozen' },
|
||||
{ text: '关闭', value: 'closed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '开户日期',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 交易记录列定义
|
||||
const transactionColumns = [
|
||||
{
|
||||
title: '交易ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '交易类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
},
|
||||
{
|
||||
title: '交易时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态变量
|
||||
const accounts = ref([]);
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
});
|
||||
|
||||
// 用户列表相关
|
||||
const usersList = ref([]);
|
||||
const usersLoading = ref(false);
|
||||
|
||||
// 账户表单相关
|
||||
const accountFormRef = ref(null);
|
||||
const accountModalVisible = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const submitting = ref(false);
|
||||
const accountForm = reactive({
|
||||
id: null,
|
||||
accountNumber: '',
|
||||
name: '',
|
||||
type: 'savings',
|
||||
userId: null,
|
||||
balance: 0,
|
||||
status: 'active',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
// 账户详情相关
|
||||
const accountDetailVisible = ref(false);
|
||||
const selectedAccount = ref(null);
|
||||
const recentTransactions = ref([]);
|
||||
const transactionsLoading = ref(false);
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入账户名称', trigger: 'blur' },
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择账户类型', trigger: 'change' },
|
||||
],
|
||||
userId: [
|
||||
{ required: true, message: '请选择所属用户', trigger: 'change' },
|
||||
],
|
||||
balance: [
|
||||
{ required: true, message: '请输入初始余额', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
// 获取账户列表
|
||||
const fetchAccounts = async (params = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getAccounts(params);
|
||||
// accounts.value = response.data;
|
||||
// pagination.total = response.total;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
const mockAccounts = [
|
||||
{
|
||||
id: 1,
|
||||
accountNumber: '6225123456789001',
|
||||
name: '张三储蓄账户',
|
||||
type: 'savings',
|
||||
userId: 1,
|
||||
userName: '张三',
|
||||
balance: 10000.50,
|
||||
status: 'active',
|
||||
createdAt: '2023-01-01',
|
||||
updatedAt: '2023-09-15',
|
||||
notes: '主要储蓄账户'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
accountNumber: '6225123456789002',
|
||||
name: '李四活期账户',
|
||||
type: 'checking',
|
||||
userId: 2,
|
||||
userName: '李四',
|
||||
balance: 5000.75,
|
||||
status: 'active',
|
||||
createdAt: '2023-02-15',
|
||||
updatedAt: '2023-09-10',
|
||||
notes: ''
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
accountNumber: '6225123456789003',
|
||||
name: '王五信用卡',
|
||||
type: 'credit',
|
||||
userId: 3,
|
||||
userName: '王五',
|
||||
balance: -2000.00,
|
||||
status: 'active',
|
||||
createdAt: '2023-03-20',
|
||||
updatedAt: '2023-09-12',
|
||||
notes: '信用额度: 50000'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
accountNumber: '6225123456789004',
|
||||
name: '赵六房贷',
|
||||
type: 'loan',
|
||||
userId: 4,
|
||||
userName: '赵六',
|
||||
balance: -500000.00,
|
||||
status: 'frozen',
|
||||
createdAt: '2023-04-10',
|
||||
updatedAt: '2023-09-01',
|
||||
notes: '30年房贷'
|
||||
},
|
||||
];
|
||||
accounts.value = mockAccounts;
|
||||
pagination.total = mockAccounts.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
message.error('获取账户列表失败');
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
usersLoading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getUsers();
|
||||
// usersList.value = response.data;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
usersList.value = [
|
||||
{ id: 1, username: 'zhangsan', name: '张三' },
|
||||
{ id: 2, username: 'lisi', name: '李四' },
|
||||
{ id: 3, username: 'wangwu', name: '王五' },
|
||||
{ id: 4, username: 'zhaoliu', name: '赵六' },
|
||||
];
|
||||
usersLoading.value = false;
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
usersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取账户交易记录
|
||||
const fetchAccountTransactions = async (accountId) => {
|
||||
transactionsLoading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getAccountTransactions(accountId);
|
||||
// recentTransactions.value = response.data;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
recentTransactions.value = [
|
||||
{
|
||||
id: 'T20230915001',
|
||||
type: 'deposit',
|
||||
amount: 1000.00,
|
||||
timestamp: '2023-09-15 10:30:25',
|
||||
description: '工资入账'
|
||||
},
|
||||
{
|
||||
id: 'T20230914002',
|
||||
type: 'withdrawal',
|
||||
amount: 500.00,
|
||||
timestamp: '2023-09-14 15:45:12',
|
||||
description: '超市购物'
|
||||
},
|
||||
{
|
||||
id: 'T20230913003',
|
||||
type: 'transfer',
|
||||
amount: 2000.00,
|
||||
timestamp: '2023-09-13 09:20:45',
|
||||
description: '转账给李四'
|
||||
},
|
||||
{
|
||||
id: 'T20230912004',
|
||||
type: 'withdrawal',
|
||||
amount: 100.00,
|
||||
timestamp: '2023-09-12 18:10:33',
|
||||
description: '餐饮消费'
|
||||
},
|
||||
{
|
||||
id: 'T20230911005',
|
||||
type: 'deposit',
|
||||
amount: 5000.00,
|
||||
timestamp: '2023-09-11 14:05:22',
|
||||
description: '投资回报'
|
||||
},
|
||||
];
|
||||
transactionsLoading.value = false;
|
||||
}, 400);
|
||||
} catch (error) {
|
||||
message.error('获取交易记录失败');
|
||||
transactionsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
const params = {
|
||||
page: pag.current,
|
||||
pageSize: pag.pageSize,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
...filters,
|
||||
};
|
||||
|
||||
pagination.current = pag.current;
|
||||
fetchAccounts(params);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchAccounts({ search: searchQuery.value });
|
||||
};
|
||||
|
||||
// 显示添加账户对话框
|
||||
const showAddAccountModal = () => {
|
||||
isEditing.value = false;
|
||||
resetAccountForm();
|
||||
accountModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑账户
|
||||
const editAccount = (record) => {
|
||||
isEditing.value = true;
|
||||
Object.assign(accountForm, { ...record });
|
||||
accountModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 查看账户详情
|
||||
const viewAccount = (record) => {
|
||||
selectedAccount.value = record;
|
||||
accountDetailVisible.value = true;
|
||||
fetchAccountTransactions(record.id);
|
||||
};
|
||||
|
||||
// 冻结账户
|
||||
const freezeAccount = async (id) => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// await api.updateAccountStatus(id, 'frozen');
|
||||
message.success('账户已冻结');
|
||||
fetchAccounts({ page: pagination.current });
|
||||
} catch (error) {
|
||||
message.error('冻结账户失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 激活账户
|
||||
const activateAccount = async (id) => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// await api.updateAccountStatus(id, 'active');
|
||||
message.success('账户已激活');
|
||||
fetchAccounts({ page: pagination.current });
|
||||
} catch (error) {
|
||||
message.error('激活账户失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交账户表单
|
||||
const handleAccountFormSubmit = () => {
|
||||
accountFormRef.value.validate().then(async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// 编辑账户
|
||||
// await api.updateAccount(accountForm.id, accountForm);
|
||||
message.success('账户更新成功');
|
||||
} else {
|
||||
// 添加账户
|
||||
// await api.createAccount(accountForm);
|
||||
message.success('账户添加成功');
|
||||
}
|
||||
accountModalVisible.value = false;
|
||||
fetchAccounts({ page: pagination.current });
|
||||
} catch (error) {
|
||||
message.error(isEditing.value ? '更新账户失败' : '添加账户失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 重置账户表单
|
||||
const resetAccountForm = () => {
|
||||
accountForm.id = null;
|
||||
accountForm.accountNumber = '';
|
||||
accountForm.name = '';
|
||||
accountForm.type = 'savings';
|
||||
accountForm.userId = null;
|
||||
accountForm.balance = 0;
|
||||
accountForm.status = 'active';
|
||||
accountForm.notes = '';
|
||||
|
||||
// 如果表单已经创建,则重置验证
|
||||
if (accountFormRef.value) {
|
||||
accountFormRef.value.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
// 用户选择过滤
|
||||
const filterUserOption = (input, option) => {
|
||||
return (
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
|
||||
option.value.toString().toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化货币
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 2
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// 获取账户状态名称
|
||||
const getStatusName = (status) => {
|
||||
const statusMap = {
|
||||
active: '活跃',
|
||||
frozen: '冻结',
|
||||
closed: '关闭',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
// 获取账户状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
active: 'green',
|
||||
frozen: 'orange',
|
||||
closed: 'red',
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
// 获取账户类型名称
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
savings: '储蓄账户',
|
||||
checking: '活期账户',
|
||||
credit: '信用账户',
|
||||
loan: '贷款账户',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取账户类型颜色
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
savings: 'blue',
|
||||
checking: 'green',
|
||||
credit: 'purple',
|
||||
loan: 'orange',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 获取交易类型名称
|
||||
const getTransactionTypeName = (type) => {
|
||||
const typeMap = {
|
||||
deposit: '存款',
|
||||
withdrawal: '取款',
|
||||
transfer: '转账',
|
||||
payment: '支付',
|
||||
interest: '利息',
|
||||
fee: '手续费',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取交易类型颜色
|
||||
const getTransactionTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
deposit: 'green',
|
||||
withdrawal: 'red',
|
||||
transfer: 'blue',
|
||||
payment: 'orange',
|
||||
interest: 'purple',
|
||||
fee: 'cyan',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchAccounts();
|
||||
fetchUsers();
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
transactionColumns,
|
||||
accounts,
|
||||
loading,
|
||||
searchQuery,
|
||||
pagination,
|
||||
usersList,
|
||||
usersLoading,
|
||||
accountFormRef,
|
||||
accountModalVisible,
|
||||
isEditing,
|
||||
submitting,
|
||||
accountForm,
|
||||
rules,
|
||||
accountDetailVisible,
|
||||
selectedAccount,
|
||||
recentTransactions,
|
||||
transactionsLoading,
|
||||
handleTableChange,
|
||||
handleSearch,
|
||||
showAddAccountModal,
|
||||
editAccount,
|
||||
viewAccount,
|
||||
freezeAccount,
|
||||
activateAccount,
|
||||
handleAccountFormSubmit,
|
||||
filterUserOption,
|
||||
formatCurrency,
|
||||
getStatusName,
|
||||
getStatusColor,
|
||||
getTypeName,
|
||||
getTypeColor,
|
||||
getTransactionTypeName,
|
||||
getTransactionTypeColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.accounts-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.warning-link {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
513
bank-frontend/src/views/Dashboard.vue
Normal file
513
bank-frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1>仪表盘</h1>
|
||||
<p>欢迎使用银行管理后台系统</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stats-cards">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="总用户数"
|
||||
:value="stats.totalUsers"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<user-outlined style="color: #1890ff" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="总账户数"
|
||||
:value="stats.totalAccounts"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<bank-outlined style="color: #52c41a" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="今日交易数"
|
||||
:value="stats.todayTransactions"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<transaction-outlined style="color: #fa8c16" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="总资产"
|
||||
:value="stats.totalAssets"
|
||||
:precision="2"
|
||||
:loading="loading"
|
||||
suffix="元"
|
||||
>
|
||||
<template #prefix>
|
||||
<dollar-outlined style="color: #f5222d" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="[16, 16]" class="charts-section">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="交易趋势" class="chart-card">
|
||||
<div ref="transactionChartRef" class="chart-container"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="账户类型分布" class="chart-card">
|
||||
<div ref="accountTypeChartRef" class="chart-container"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 最近交易 -->
|
||||
<a-row :gutter="[16, 16]" class="recent-section">
|
||||
<a-col :xs="24" :lg="16">
|
||||
<a-card title="最近交易" class="recent-card">
|
||||
<a-table
|
||||
:columns="transactionColumns"
|
||||
:data-source="recentTransactions"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span :class="getAmountClass(record.transaction_type)">
|
||||
{{ formatAmount(record.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.transaction_type)">
|
||||
{{ getTypeName(record.transaction_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusName(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="系统信息" class="system-card">
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="系统版本">
|
||||
v1.0.0
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="运行时间">
|
||||
{{ systemInfo.uptime }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="数据库状态">
|
||||
<a-tag color="green">正常</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后更新">
|
||||
{{ systemInfo.lastUpdate }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
TransactionOutlined,
|
||||
DollarOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
totalAccounts: 0,
|
||||
todayTransactions: 0,
|
||||
totalAssets: 0
|
||||
})
|
||||
|
||||
const recentTransactions = ref([])
|
||||
const systemInfo = ref({
|
||||
uptime: '0天0小时',
|
||||
lastUpdate: new Date().toLocaleString()
|
||||
})
|
||||
|
||||
// 图表引用
|
||||
const transactionChartRef = ref()
|
||||
const accountTypeChartRef = ref()
|
||||
let transactionChart = null
|
||||
let accountTypeChart = null
|
||||
|
||||
// 交易表格列配置
|
||||
const transactionColumns = [
|
||||
{
|
||||
title: '交易号',
|
||||
dataIndex: 'transaction_number',
|
||||
key: 'transaction_number',
|
||||
width: 120,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'transaction_type',
|
||||
key: 'type',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 100,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 120
|
||||
}
|
||||
]
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 模拟数据,实际应该调用API
|
||||
stats.value = {
|
||||
totalUsers: 1250,
|
||||
totalAccounts: 3420,
|
||||
todayTransactions: 156,
|
||||
totalAssets: 12500000.50
|
||||
}
|
||||
|
||||
// 获取最近交易
|
||||
const transactionResult = await api.transactions.getList({
|
||||
limit: 10,
|
||||
page: 1
|
||||
})
|
||||
|
||||
if (transactionResult.success) {
|
||||
recentTransactions.value = transactionResult.data.transactions || []
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化交易趋势图表
|
||||
const initTransactionChart = () => {
|
||||
if (!transactionChartRef.value) return
|
||||
|
||||
transactionChart = echarts.init(transactionChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['存款', '取款', '转账']
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '存款',
|
||||
type: 'line',
|
||||
data: [120, 132, 101, 134, 90, 230, 210],
|
||||
smooth: true
|
||||
},
|
||||
{
|
||||
name: '取款',
|
||||
type: 'line',
|
||||
data: [220, 182, 191, 234, 290, 330, 310],
|
||||
smooth: true
|
||||
},
|
||||
{
|
||||
name: '转账',
|
||||
type: 'line',
|
||||
data: [150, 232, 201, 154, 190, 330, 410],
|
||||
smooth: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
transactionChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化账户类型分布图表
|
||||
const initAccountTypeChart = () => {
|
||||
if (!accountTypeChartRef.value) return
|
||||
|
||||
accountTypeChart = echarts.init(accountTypeChartRef.value)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '账户类型',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [
|
||||
{ value: 1048, name: '储蓄账户' },
|
||||
{ value: 735, name: '支票账户' },
|
||||
{ value: 580, name: '信用卡账户' },
|
||||
{ value: 484, name: '贷款账户' }
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
accountTypeChart.setOption(option)
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount) => {
|
||||
return (amount / 100).toFixed(2)
|
||||
}
|
||||
|
||||
// 获取金额样式类
|
||||
const getAmountClass = (type) => {
|
||||
if (type === 'deposit' || type === 'transfer_in') {
|
||||
return 'amount-positive'
|
||||
} else if (type === 'withdrawal' || type === 'transfer_out') {
|
||||
return 'amount-negative'
|
||||
}
|
||||
return 'amount-zero'
|
||||
}
|
||||
|
||||
// 获取类型名称
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
'deposit': '存款',
|
||||
'withdrawal': '取款',
|
||||
'transfer_in': '转入',
|
||||
'transfer_out': '转出',
|
||||
'interest': '利息',
|
||||
'fee': '手续费'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
'deposit': 'green',
|
||||
'withdrawal': 'red',
|
||||
'transfer_in': 'blue',
|
||||
'transfer_out': 'orange',
|
||||
'interest': 'purple',
|
||||
'fee': 'gray'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态名称
|
||||
const getStatusName = (status) => {
|
||||
const statusMap = {
|
||||
'pending': '处理中',
|
||||
'completed': '已完成',
|
||||
'failed': '失败',
|
||||
'cancelled': '已取消'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'pending': 'processing',
|
||||
'completed': 'success',
|
||||
'failed': 'error',
|
||||
'cancelled': 'default'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
const handleResize = () => {
|
||||
if (transactionChart) {
|
||||
transactionChart.resize()
|
||||
}
|
||||
if (accountTypeChart) {
|
||||
accountTypeChart.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
|
||||
// 延迟初始化图表,确保DOM已渲染
|
||||
setTimeout(() => {
|
||||
initTransactionChart()
|
||||
initAccountTypeChart()
|
||||
}, 100)
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (transactionChart) {
|
||||
transactionChart.dispose()
|
||||
}
|
||||
if (accountTypeChart) {
|
||||
accountTypeChart.dispose()
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recent-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.recent-card,
|
||||
.system-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
color: #52c41a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-zero {
|
||||
color: #8c8c8c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-cards .ant-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.charts-section .ant-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recent-section .ant-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
267
bank-frontend/src/views/Login.vue
Normal file
267
bank-frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<bank-outlined />
|
||||
</div>
|
||||
<h1 class="title">银行管理后台系统</h1>
|
||||
<p class="subtitle">专业的银行管理解决方案</p>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@finish="handleLogin"
|
||||
class="login-form"
|
||||
size="large"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input
|
||||
v-model:value="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
placeholder="请输入密码"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="loginForm.remember">
|
||||
记住密码
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
:loading="loading"
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>默认账户:admin / Admin123456</p>
|
||||
<p>测试账户:testuser / Test123456</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, h } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import {
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
LockOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 登录表单
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 50, message: '用户名长度在3到50个字符', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在6到20个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const result = await userStore.login(
|
||||
loginForm.value.username,
|
||||
loginForm.value.password
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
message.success('登录成功')
|
||||
|
||||
// 重定向到目标页面或仪表盘
|
||||
const redirectPath = route.query.redirect || '/dashboard'
|
||||
router.push(redirectPath)
|
||||
} else {
|
||||
message.error(result.message || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error)
|
||||
message.error(error.message || '登录失败,请检查网络连接')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查是否已登录
|
||||
if (userStore.isLoggedIn) {
|
||||
const redirectPath = route.query.redirect || '/dashboard'
|
||||
router.push(redirectPath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
color: #1890ff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-form .ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form .ant-input,
|
||||
.login-form .ant-input-password {
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-form .ant-input:focus,
|
||||
.login-form .ant-input-password:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.login-form .ant-btn {
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.login-form .ant-btn:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 移除渐变与动画背景,仅保留纯色背景 */
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-box {
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
padding: 24px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.login-form .ant-input,
|
||||
.login-form .ant-input-password,
|
||||
.login-form .ant-btn {
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
bank-frontend/src/views/NotFound.vue
Normal file
1
bank-frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template>\n <div class=page-container>404 Not Found</div>\n</template>\n\n<script setup>\n</script>\n\n<style scoped>\n.page-container { padding: 24px; text-align:center; color:#8c8c8c; }\n</style>
|
||||
1
bank-frontend/src/views/Profile.vue
Normal file
1
bank-frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template>\n <div class=page-container>Profile</div>\n</template>\n\n<script setup>\n</script>\n\n<style scoped>\n.page-container { padding: 24px; }\n</style>
|
||||
717
bank-frontend/src/views/Reports.vue
Normal file
717
bank-frontend/src/views/Reports.vue
Normal file
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div class="reports-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1>报表统计</h1>
|
||||
<p>查看和导出银行业务报表</p>
|
||||
</div>
|
||||
|
||||
<!-- 报表类型选择 -->
|
||||
<a-card :bordered="false" class="report-selector-card">
|
||||
<a-tabs v-model:activeKey="activeReportType" @change="handleReportTypeChange">
|
||||
<a-tab-pane key="transaction" tab="交易报表">
|
||||
<a-form layout="horizontal" :model="transactionReportForm">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="日期范围" name="dateRange">
|
||||
<a-range-picker
|
||||
v-model:value="transactionReportForm.dateRange"
|
||||
style="width: 100%"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="交易类型" name="transactionTypes">
|
||||
<a-select
|
||||
v-model:value="transactionReportForm.transactionTypes"
|
||||
mode="multiple"
|
||||
placeholder="选择交易类型"
|
||||
style="width: 100%"
|
||||
:options="transactionTypeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="报表格式" name="format">
|
||||
<a-radio-group v-model:value="transactionReportForm.format">
|
||||
<a-radio value="excel">Excel</a-radio>
|
||||
<a-radio value="pdf">PDF</a-radio>
|
||||
<a-radio value="csv">CSV</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row>
|
||||
<a-col :span="24" style="text-align: right;">
|
||||
<a-button type="primary" @click="generateTransactionReport">
|
||||
<file-excel-outlined /> 生成报表
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="account" tab="账户报表">
|
||||
<a-form layout="horizontal" :model="accountReportForm">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="截止日期" name="endDate">
|
||||
<a-date-picker
|
||||
v-model:value="accountReportForm.endDate"
|
||||
style="width: 100%"
|
||||
placeholder="选择截止日期"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="账户类型" name="accountTypes">
|
||||
<a-select
|
||||
v-model:value="accountReportForm.accountTypes"
|
||||
mode="multiple"
|
||||
placeholder="选择账户类型"
|
||||
style="width: 100%"
|
||||
:options="accountTypeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="报表格式" name="format">
|
||||
<a-radio-group v-model:value="accountReportForm.format">
|
||||
<a-radio value="excel">Excel</a-radio>
|
||||
<a-radio value="pdf">PDF</a-radio>
|
||||
<a-radio value="csv">CSV</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row>
|
||||
<a-col :span="24" style="text-align: right;">
|
||||
<a-button type="primary" @click="generateAccountReport">
|
||||
<file-excel-outlined /> 生成报表
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="user" tab="用户报表">
|
||||
<a-form layout="horizontal" :model="userReportForm">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="用户角色" name="roles">
|
||||
<a-select
|
||||
v-model:value="userReportForm.roles"
|
||||
mode="multiple"
|
||||
placeholder="选择用户角色"
|
||||
style="width: 100%"
|
||||
:options="userRoleOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="注册日期" name="registrationDateRange">
|
||||
<a-range-picker
|
||||
v-model:value="userReportForm.registrationDateRange"
|
||||
style="width: 100%"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="报表格式" name="format">
|
||||
<a-radio-group v-model:value="userReportForm.format">
|
||||
<a-radio value="excel">Excel</a-radio>
|
||||
<a-radio value="pdf">PDF</a-radio>
|
||||
<a-radio value="csv">CSV</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row>
|
||||
<a-col :span="24" style="text-align: right;">
|
||||
<a-button type="primary" @click="generateUserReport">
|
||||
<file-excel-outlined /> 生成报表
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据可视化区域 -->
|
||||
<!-- <a-row :gutter="[16, 16]" class="chart-row">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="交易金额趋势">
|
||||
<div class="chart-container">
|
||||
|
||||
<div class="chart-placeholder">
|
||||
<bar-chart-outlined />
|
||||
<p>交易金额趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="交易类型分布">
|
||||
<div class="chart-container">
|
||||
|
||||
<div class="chart-placeholder">
|
||||
<pie-chart-outlined />
|
||||
<p>交易类型饼图</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="账户余额分布">
|
||||
<div class="chart-container">
|
||||
|
||||
<div class="chart-placeholder">
|
||||
<fund-outlined />
|
||||
<p>账户余额分布图</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="用户活跃度">
|
||||
<div class="chart-container">
|
||||
|
||||
<div class="chart-placeholder">
|
||||
<line-chart-outlined />
|
||||
<p>用户活跃度趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
-->
|
||||
<!-- 最近生成的报表 -->
|
||||
<a-card :bordered="false" title="最近生成的报表" class="recent-reports-card">
|
||||
<a-table
|
||||
:columns="recentReportsColumns"
|
||||
:data-source="recentReports"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="getReportTypeColor(record.type)">
|
||||
{{ getReportTypeName(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'format'">
|
||||
<a-tag :color="getFormatColor(record.format)">
|
||||
{{ record.format.toUpperCase() }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="downloadReport(record)">
|
||||
<download-outlined /> 下载
|
||||
</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="viewReport(record)">
|
||||
<eye-outlined /> 查看
|
||||
</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
title="确定要删除此报表吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="deleteReport(record.id)"
|
||||
>
|
||||
<a class="danger-link">
|
||||
<delete-outlined /> 删除
|
||||
</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 报表预览对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="reportPreviewVisible"
|
||||
title="报表预览"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="selectedReport">
|
||||
<div class="report-preview">
|
||||
<h2>{{ selectedReport.name }}</h2>
|
||||
<p>生成时间: {{ selectedReport.createdAt }}</p>
|
||||
<p>报表类型: {{ getReportTypeName(selectedReport.type) }}</p>
|
||||
<p>格式: {{ selectedReport.format.toUpperCase() }}</p>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="report-preview-content">
|
||||
<!-- 这里应该是实际的报表预览内容 -->
|
||||
<div class="preview-placeholder">
|
||||
<file-outlined />
|
||||
<p>报表预览内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<a-button type="primary" @click="downloadReport(selectedReport)" style="margin-right: 8px;">
|
||||
<download-outlined /> 下载
|
||||
</a-button>
|
||||
<a-button @click="reportPreviewVisible = false">关闭</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
FileExcelOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
FundOutlined,
|
||||
LineChartOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
DeleteOutlined,
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ReportsPage',
|
||||
components: {
|
||||
FileExcelOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
FundOutlined,
|
||||
LineChartOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
DeleteOutlined,
|
||||
FileOutlined
|
||||
},
|
||||
setup() {
|
||||
// 活跃的报表类型
|
||||
const activeReportType = ref('transaction');
|
||||
|
||||
// 交易报表表单
|
||||
const transactionReportForm = reactive({
|
||||
dateRange: [],
|
||||
transactionTypes: [],
|
||||
format: 'excel'
|
||||
});
|
||||
|
||||
// 账户报表表单
|
||||
const accountReportForm = reactive({
|
||||
endDate: null,
|
||||
accountTypes: [],
|
||||
format: 'excel'
|
||||
});
|
||||
|
||||
// 用户报表表单
|
||||
const userReportForm = reactive({
|
||||
roles: [],
|
||||
registrationDateRange: [],
|
||||
format: 'excel'
|
||||
});
|
||||
|
||||
// 交易类型选项
|
||||
const transactionTypeOptions = [
|
||||
{ label: '存款', value: 'deposit' },
|
||||
{ label: '取款', value: 'withdrawal' },
|
||||
{ label: '转账', value: 'transfer' },
|
||||
{ label: '支付', value: 'payment' },
|
||||
{ label: '利息', value: 'interest' },
|
||||
{ label: '手续费', value: 'fee' }
|
||||
];
|
||||
|
||||
// 账户类型选项
|
||||
const accountTypeOptions = [
|
||||
{ label: '储蓄账户', value: 'savings' },
|
||||
{ label: '活期账户', value: 'checking' },
|
||||
{ label: '信用账户', value: 'credit' },
|
||||
{ label: '贷款账户', value: 'loan' }
|
||||
];
|
||||
|
||||
// 用户角色选项
|
||||
const userRoleOptions = [
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '经理', value: 'manager' },
|
||||
{ label: '柜员', value: 'teller' },
|
||||
{ label: '普通用户', value: 'user' }
|
||||
];
|
||||
|
||||
// 最近报表列表
|
||||
const recentReports = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 最近报表表格列定义
|
||||
const recentReportsColumns = [
|
||||
{
|
||||
title: '报表名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '报表类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '格式',
|
||||
dataIndex: 'format',
|
||||
key: 'format',
|
||||
},
|
||||
{
|
||||
title: '生成时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
},
|
||||
{
|
||||
title: '生成人',
|
||||
dataIndex: 'createdBy',
|
||||
key: 'createdBy',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 报表预览相关
|
||||
const reportPreviewVisible = ref(false);
|
||||
const selectedReport = ref(null);
|
||||
|
||||
// 获取最近报表列表
|
||||
const fetchRecentReports = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getRecentReports();
|
||||
// recentReports.value = response.data;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
recentReports.value = [
|
||||
{
|
||||
id: 'R20230917001',
|
||||
name: '2023年9月交易报表',
|
||||
type: 'transaction',
|
||||
format: 'excel',
|
||||
createdAt: '2023-09-17 10:30:25',
|
||||
createdBy: '张三 (管理员)'
|
||||
},
|
||||
{
|
||||
id: 'R20230916001',
|
||||
name: '储蓄账户余额报表',
|
||||
type: 'account',
|
||||
format: 'pdf',
|
||||
createdAt: '2023-09-16 15:45:12',
|
||||
createdBy: '李四 (经理)'
|
||||
},
|
||||
{
|
||||
id: 'R20230915001',
|
||||
name: '用户活跃度报表',
|
||||
type: 'user',
|
||||
format: 'csv',
|
||||
createdAt: '2023-09-15 09:20:45',
|
||||
createdBy: '张三 (管理员)'
|
||||
},
|
||||
{
|
||||
id: 'R20230914001',
|
||||
name: '信用卡交易报表',
|
||||
type: 'transaction',
|
||||
format: 'excel',
|
||||
createdAt: '2023-09-14 14:10:33',
|
||||
createdBy: '王五 (柜员)'
|
||||
},
|
||||
{
|
||||
id: 'R20230913001',
|
||||
name: '新用户注册报表',
|
||||
type: 'user',
|
||||
format: 'pdf',
|
||||
createdAt: '2023-09-13 11:05:22',
|
||||
createdBy: '李四 (经理)'
|
||||
},
|
||||
];
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
message.error('获取最近报表列表失败');
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 报表类型变更处理
|
||||
const handleReportTypeChange = (key) => {
|
||||
console.log('切换到报表类型:', key);
|
||||
};
|
||||
|
||||
// 生成交易报表
|
||||
const generateTransactionReport = () => {
|
||||
if (!transactionReportForm.dateRange || transactionReportForm.dateRange.length !== 2) {
|
||||
message.warning('请选择日期范围');
|
||||
return;
|
||||
}
|
||||
|
||||
message.loading('正在生成交易报表,请稍候...', 1.5);
|
||||
|
||||
// 这里应该是实际的API调用
|
||||
// const params = {
|
||||
// startDate: transactionReportForm.dateRange[0].format('YYYY-MM-DD'),
|
||||
// endDate: transactionReportForm.dateRange[1].format('YYYY-MM-DD'),
|
||||
// transactionTypes: transactionReportForm.transactionTypes,
|
||||
// format: transactionReportForm.format
|
||||
// };
|
||||
// api.generateTransactionReport(params).then(response => {
|
||||
// message.success('交易报表生成成功');
|
||||
// fetchRecentReports();
|
||||
// }).catch(error => {
|
||||
// message.error('交易报表生成失败');
|
||||
// });
|
||||
|
||||
// 模拟生成报表
|
||||
setTimeout(() => {
|
||||
message.success('交易报表生成成功');
|
||||
fetchRecentReports();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// 生成账户报表
|
||||
const generateAccountReport = () => {
|
||||
if (!accountReportForm.endDate) {
|
||||
message.warning('请选择截止日期');
|
||||
return;
|
||||
}
|
||||
|
||||
message.loading('正在生成账户报表,请稍候...', 1.5);
|
||||
|
||||
// 这里应该是实际的API调用
|
||||
// const params = {
|
||||
// endDate: accountReportForm.endDate.format('YYYY-MM-DD'),
|
||||
// accountTypes: accountReportForm.accountTypes,
|
||||
// format: accountReportForm.format
|
||||
// };
|
||||
// api.generateAccountReport(params).then(response => {
|
||||
// message.success('账户报表生成成功');
|
||||
// fetchRecentReports();
|
||||
// }).catch(error => {
|
||||
// message.error('账户报表生成失败');
|
||||
// });
|
||||
|
||||
// 模拟生成报表
|
||||
setTimeout(() => {
|
||||
message.success('账户报表生成成功');
|
||||
fetchRecentReports();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// 生成用户报表
|
||||
const generateUserReport = () => {
|
||||
message.loading('正在生成用户报表,请稍候...', 1.5);
|
||||
|
||||
// 这里应该是实际的API调用
|
||||
// const params = {
|
||||
// roles: userReportForm.roles,
|
||||
// startDate: userReportForm.registrationDateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
// endDate: userReportForm.registrationDateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
// format: userReportForm.format
|
||||
// };
|
||||
// api.generateUserReport(params).then(response => {
|
||||
// message.success('用户报表生成成功');
|
||||
// fetchRecentReports();
|
||||
// }).catch(error => {
|
||||
// message.error('用户报表生成失败');
|
||||
// });
|
||||
|
||||
// 模拟生成报表
|
||||
setTimeout(() => {
|
||||
message.success('用户报表生成成功');
|
||||
fetchRecentReports();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// 下载报表
|
||||
const downloadReport = (report) => {
|
||||
message.success(`正在下载报表: ${report.name}`);
|
||||
// 实际应用中,这里应该调用下载API
|
||||
};
|
||||
|
||||
// 查看报表
|
||||
const viewReport = (report) => {
|
||||
selectedReport.value = report;
|
||||
reportPreviewVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除报表
|
||||
const deleteReport = (id) => {
|
||||
// 这里应该是实际的API调用
|
||||
// api.deleteReport(id).then(response => {
|
||||
// message.success('报表删除成功');
|
||||
// fetchRecentReports();
|
||||
// }).catch(error => {
|
||||
// message.error('报表删除失败');
|
||||
// });
|
||||
|
||||
// 模拟删除报表
|
||||
message.success('报表删除成功');
|
||||
recentReports.value = recentReports.value.filter(report => report.id !== id);
|
||||
};
|
||||
|
||||
// 获取报表类型名称
|
||||
const getReportTypeName = (type) => {
|
||||
const typeMap = {
|
||||
transaction: '交易报表',
|
||||
account: '账户报表',
|
||||
user: '用户报表',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取报表类型颜色
|
||||
const getReportTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
transaction: 'blue',
|
||||
account: 'green',
|
||||
user: 'purple',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 获取格式颜色
|
||||
const getFormatColor = (format) => {
|
||||
const colorMap = {
|
||||
excel: 'green',
|
||||
pdf: 'red',
|
||||
csv: 'orange',
|
||||
};
|
||||
return colorMap[format] || 'default';
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchRecentReports();
|
||||
});
|
||||
|
||||
return {
|
||||
activeReportType,
|
||||
transactionReportForm,
|
||||
accountReportForm,
|
||||
userReportForm,
|
||||
transactionTypeOptions,
|
||||
accountTypeOptions,
|
||||
userRoleOptions,
|
||||
recentReports,
|
||||
loading,
|
||||
recentReportsColumns,
|
||||
reportPreviewVisible,
|
||||
selectedReport,
|
||||
handleReportTypeChange,
|
||||
generateTransactionReport,
|
||||
generateAccountReport,
|
||||
generateUserReport,
|
||||
downloadReport,
|
||||
viewReport,
|
||||
deleteReport,
|
||||
getReportTypeName,
|
||||
getReportTypeColor,
|
||||
getFormatColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reports-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.report-selector-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.chart-placeholder svg {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recent-reports-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.report-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.report-preview h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-preview-content {
|
||||
min-height: 400px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.preview-placeholder svg {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
615
bank-frontend/src/views/Settings.vue
Normal file
615
bank-frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,615 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<a-page-header title="系统设置" sub-title="管理系统配置和参数" />
|
||||
|
||||
<a-tabs default-active-key="1" class="settings-tabs">
|
||||
<a-tab-pane key="1" tab="基本设置">
|
||||
<a-card title="系统参数配置" class="settings-card">
|
||||
<a-form :model="basicSettings" layout="vertical">
|
||||
<a-form-item label="系统名称">
|
||||
<a-input v-model:value="basicSettings.systemName" placeholder="请输入系统名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统描述">
|
||||
<a-textarea v-model:value="basicSettings.systemDescription" placeholder="请输入系统描述" :rows="4" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="管理员邮箱">
|
||||
<a-input v-model:value="basicSettings.adminEmail" placeholder="请输入管理员邮箱" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统维护模式">
|
||||
<a-switch v-model:checked="basicSettings.maintenanceMode" />
|
||||
<span class="setting-description">启用后,除管理员外的用户将无法登录系统</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="saveBasicSettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetBasicSettings">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="2" tab="安全设置">
|
||||
<a-card title="密码策略" class="settings-card">
|
||||
<a-form :model="securitySettings" layout="vertical">
|
||||
<a-form-item label="密码最小长度">
|
||||
<a-input-number v-model:value="securitySettings.minPasswordLength" :min="6" :max="20" />
|
||||
<span class="setting-description">密码最小长度要求(6-20个字符)</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码复杂度要求">
|
||||
<a-checkbox-group v-model:value="securitySettings.passwordComplexity">
|
||||
<a-checkbox value="uppercase">必须包含大写字母</a-checkbox>
|
||||
<a-checkbox value="lowercase">必须包含小写字母</a-checkbox>
|
||||
<a-checkbox value="numbers">必须包含数字</a-checkbox>
|
||||
<a-checkbox value="special">必须包含特殊字符</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码过期时间">
|
||||
<a-select v-model:value="securitySettings.passwordExpiry">
|
||||
<a-select-option value="30">30天</a-select-option>
|
||||
<a-select-option value="60">60天</a-select-option>
|
||||
<a-select-option value="90">90天</a-select-option>
|
||||
<a-select-option value="180">180天</a-select-option>
|
||||
<a-select-option value="365">365天</a-select-option>
|
||||
<a-select-option value="0">永不过期</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="登录失败锁定">
|
||||
<a-input-number v-model:value="securitySettings.loginAttempts" :min="3" :max="10" />
|
||||
<span class="setting-description">连续失败次数后锁定账户</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="锁定时间">
|
||||
<a-select v-model:value="securitySettings.lockDuration">
|
||||
<a-select-option value="15">15分钟</a-select-option>
|
||||
<a-select-option value="30">30分钟</a-select-option>
|
||||
<a-select-option value="60">1小时</a-select-option>
|
||||
<a-select-option value="1440">24小时</a-select-option>
|
||||
<a-select-option value="-1">需管理员手动解锁</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="saveSecuritySettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetSecuritySettings">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="双因素认证" class="settings-card">
|
||||
<a-form :model="twoFactorSettings" layout="vertical">
|
||||
<a-form-item label="启用双因素认证">
|
||||
<a-switch v-model:checked="twoFactorSettings.enabled" />
|
||||
<span class="setting-description">要求用户使用双因素认证登录系统</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="适用角色">
|
||||
<a-checkbox-group v-model:value="twoFactorSettings.roles">
|
||||
<a-checkbox value="admin">管理员</a-checkbox>
|
||||
<a-checkbox value="manager">经理</a-checkbox>
|
||||
<a-checkbox value="teller">柜员</a-checkbox>
|
||||
<a-checkbox value="user">普通用户</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="saveTwoFactorSettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetTwoFactorSettings">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="3" tab="系统日志">
|
||||
<a-card title="日志查询" class="settings-card">
|
||||
<a-form layout="inline" class="log-search-form">
|
||||
<a-form-item label="日志类型">
|
||||
<a-select v-model:value="logQuery.type" style="width: 150px">
|
||||
<a-select-option value="all">全部</a-select-option>
|
||||
<a-select-option value="login">登录日志</a-select-option>
|
||||
<a-select-option value="operation">操作日志</a-select-option>
|
||||
<a-select-option value="system">系统日志</a-select-option>
|
||||
<a-select-option value="error">错误日志</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="日期范围">
|
||||
<a-range-picker v-model:value="logQuery.dateRange" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="用户">
|
||||
<a-input v-model:value="logQuery.user" placeholder="用户名" style="width: 150px" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="searchLogs">查询</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetLogQuery">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
:columns="logColumns"
|
||||
:data-source="logs"
|
||||
:loading="logsLoading"
|
||||
:pagination="logPagination"
|
||||
@change="handleLogTableChange"
|
||||
class="log-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getLogLevelColor(record.level)">{{ record.level }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a @click="viewLogDetail(record)">查看详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="4" tab="备份与恢复">
|
||||
<a-card title="数据备份" class="settings-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="自动备份">
|
||||
<a-switch v-model:checked="backupSettings.autoBackup" />
|
||||
<span class="setting-description">启用系统自动备份功能</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备份频率">
|
||||
<a-select v-model:value="backupSettings.frequency" style="width: 200px">
|
||||
<a-select-option value="daily">每天</a-select-option>
|
||||
<a-select-option value="weekly">每周</a-select-option>
|
||||
<a-select-option value="monthly">每月</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="保留备份数量">
|
||||
<a-input-number v-model:value="backupSettings.keepCount" :min="1" :max="30" />
|
||||
<span class="setting-description">系统将保留最近的备份数量</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="saveBackupSettings">保存设置</a-button>
|
||||
<a-button style="margin-left: 10px" @click="resetBackupSettings">重置</a-button>
|
||||
<a-button type="primary" danger style="margin-left: 10px" @click="createManualBackup">立即备份</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<h3>备份历史</h3>
|
||||
<a-table
|
||||
:columns="backupColumns"
|
||||
:data-source="backups"
|
||||
:loading="backupsLoading"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
class="backup-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" @click="downloadBackup(record)">下载</a-button>
|
||||
<a-button type="link" danger @click="confirmRestoreBackup(record)">恢复</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 日志详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="logDetailVisible"
|
||||
title="日志详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item label="ID">{{ selectedLog.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="时间">{{ selectedLog.timestamp }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ selectedLog.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="级别">
|
||||
<a-tag :color="getLogLevelColor(selectedLog.level)">{{ selectedLog.level }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ selectedLog.user }}</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ selectedLog.ip }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作">{{ selectedLog.action }}</a-descriptions-item>
|
||||
<a-descriptions-item label="详细信息">
|
||||
<div class="log-detail-content">{{ selectedLog.message }}</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-modal>
|
||||
|
||||
<!-- 恢复备份确认弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="restoreConfirmVisible"
|
||||
title="恢复备份"
|
||||
@ok="restoreBackup"
|
||||
okText="确认恢复"
|
||||
cancelText="取消"
|
||||
:okButtonProps="{ danger: true }"
|
||||
>
|
||||
<p>您确定要恢复到 {{ selectedBackup.created_at }} 的备份吗?</p>
|
||||
<p><strong>警告:</strong> 此操作将覆盖当前系统数据,且不可撤销!</p>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 基本设置
|
||||
const basicSettings = reactive({
|
||||
systemName: '宁夏银行管理系统',
|
||||
systemDescription: '专业的银行业务管理解决方案,提供全面的账户管理、交易处理和报表分析功能。',
|
||||
adminEmail: 'admin@example.com',
|
||||
maintenanceMode: false
|
||||
})
|
||||
|
||||
// 安全设置
|
||||
const securitySettings = reactive({
|
||||
minPasswordLength: 8,
|
||||
passwordComplexity: ['uppercase', 'lowercase', 'numbers'],
|
||||
passwordExpiry: '90',
|
||||
loginAttempts: 5,
|
||||
lockDuration: '60'
|
||||
})
|
||||
|
||||
// 双因素认证设置
|
||||
const twoFactorSettings = reactive({
|
||||
enabled: false,
|
||||
roles: ['admin', 'manager']
|
||||
})
|
||||
|
||||
// 备份设置
|
||||
const backupSettings = reactive({
|
||||
autoBackup: true,
|
||||
frequency: 'daily',
|
||||
keepCount: 7
|
||||
})
|
||||
|
||||
// 日志查询参数
|
||||
const logQuery = reactive({
|
||||
type: 'all',
|
||||
dateRange: null,
|
||||
user: ''
|
||||
})
|
||||
|
||||
// 日志表格列定义
|
||||
const logColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', sorter: true },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', filters: [
|
||||
{ text: '登录日志', value: 'login' },
|
||||
{ text: '操作日志', value: 'operation' },
|
||||
{ text: '系统日志', value: 'system' },
|
||||
{ text: '错误日志', value: 'error' }
|
||||
] },
|
||||
{ title: '级别', dataIndex: 'level', key: 'level' },
|
||||
{ title: '用户', dataIndex: 'user', key: 'user' },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
// 备份表格列定义
|
||||
const backupColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
{ title: '大小', dataIndex: 'size', key: 'size' },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '创建者', dataIndex: 'created_by', key: 'created_by' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
// 日志数据
|
||||
const logs = ref([])
|
||||
const logsLoading = ref(false)
|
||||
const logPagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 备份数据
|
||||
const backups = ref([])
|
||||
const backupsLoading = ref(false)
|
||||
|
||||
// 日志详情弹窗
|
||||
const logDetailVisible = ref(false)
|
||||
const selectedLog = ref({})
|
||||
|
||||
// 恢复备份确认弹窗
|
||||
const restoreConfirmVisible = ref(false)
|
||||
const selectedBackup = ref({})
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchLogs()
|
||||
fetchBackups()
|
||||
})
|
||||
|
||||
// 获取系统设置
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
// 这里应该调用API获取系统设置
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
// 设置已经在上面初始化了
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
message.error('获取系统设置失败')
|
||||
console.error('获取系统设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取日志数据
|
||||
const fetchLogs = async () => {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
// 这里应该调用API获取日志数据
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
logs.value = [
|
||||
{
|
||||
id: '1001',
|
||||
timestamp: '2025-09-17 14:30:25',
|
||||
type: 'login',
|
||||
level: 'info',
|
||||
user: 'admin',
|
||||
ip: '192.168.1.100',
|
||||
action: '用户登录',
|
||||
message: '管理员成功登录系统'
|
||||
},
|
||||
{
|
||||
id: '1002',
|
||||
timestamp: '2025-09-17 14:35:12',
|
||||
type: 'operation',
|
||||
level: 'warning',
|
||||
user: 'admin',
|
||||
ip: '192.168.1.100',
|
||||
action: '修改用户权限',
|
||||
message: '修改用户 user001 的权限'
|
||||
},
|
||||
{
|
||||
id: '1003',
|
||||
timestamp: '2025-09-17 14:40:05',
|
||||
type: 'system',
|
||||
level: 'error',
|
||||
user: 'system',
|
||||
ip: '127.0.0.1',
|
||||
action: '系统错误',
|
||||
message: '数据库连接失败: Connection timeout'
|
||||
}
|
||||
]
|
||||
logPagination.total = 100
|
||||
logsLoading.value = false
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
message.error('获取日志数据失败')
|
||||
console.error('获取日志数据失败:', error)
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取备份数据
|
||||
const fetchBackups = async () => {
|
||||
backupsLoading.value = true
|
||||
try {
|
||||
// 这里应该调用API获取备份数据
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
backups.value = [
|
||||
{
|
||||
id: '1',
|
||||
created_at: '2025-09-17 00:00:00',
|
||||
size: '125.4 MB',
|
||||
type: '自动备份',
|
||||
created_by: 'system'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
created_at: '2025-09-16 00:00:00',
|
||||
size: '124.8 MB',
|
||||
type: '自动备份',
|
||||
created_by: 'system'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
created_at: '2025-09-15 14:30:00',
|
||||
size: '126.2 MB',
|
||||
type: '手动备份',
|
||||
created_by: 'admin'
|
||||
}
|
||||
]
|
||||
backupsLoading.value = false
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
message.error('获取备份数据失败')
|
||||
console.error('获取备份数据失败:', error)
|
||||
backupsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存基本设置
|
||||
const saveBasicSettings = () => {
|
||||
// 这里应该调用API保存基本设置
|
||||
message.success('基本设置保存成功')
|
||||
}
|
||||
|
||||
// 重置基本设置
|
||||
const resetBasicSettings = () => {
|
||||
// 重置为初始值或从服务器重新获取
|
||||
fetchSettings()
|
||||
message.info('基本设置已重置')
|
||||
}
|
||||
|
||||
// 保存安全设置
|
||||
const saveSecuritySettings = () => {
|
||||
// 这里应该调用API保存安全设置
|
||||
message.success('安全设置保存成功')
|
||||
}
|
||||
|
||||
// 重置安全设置
|
||||
const resetSecuritySettings = () => {
|
||||
// 重置为初始值或从服务器重新获取
|
||||
fetchSettings()
|
||||
message.info('安全设置已重置')
|
||||
}
|
||||
|
||||
// 保存双因素认证设置
|
||||
const saveTwoFactorSettings = () => {
|
||||
// 这里应该调用API保存双因素认证设置
|
||||
message.success('双因素认证设置保存成功')
|
||||
}
|
||||
|
||||
// 重置双因素认证设置
|
||||
const resetTwoFactorSettings = () => {
|
||||
// 重置为初始值或从服务器重新获取
|
||||
fetchSettings()
|
||||
message.info('双因素认证设置已重置')
|
||||
}
|
||||
|
||||
// 保存备份设置
|
||||
const saveBackupSettings = () => {
|
||||
// 这里应该调用API保存备份设置
|
||||
message.success('备份设置保存成功')
|
||||
}
|
||||
|
||||
// 重置备份设置
|
||||
const resetBackupSettings = () => {
|
||||
// 重置为初始值或从服务器重新获取
|
||||
fetchSettings()
|
||||
message.info('备份设置已重置')
|
||||
}
|
||||
|
||||
// 创建手动备份
|
||||
const createManualBackup = () => {
|
||||
// 这里应该调用API创建手动备份
|
||||
message.loading({ content: '正在创建备份...', key: 'backupLoading' })
|
||||
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
message.success({ content: '备份创建成功', key: 'backupLoading' })
|
||||
fetchBackups() // 刷新备份列表
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 搜索日志
|
||||
const searchLogs = () => {
|
||||
// 重置分页到第一页
|
||||
logPagination.current = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// 重置日志查询条件
|
||||
const resetLogQuery = () => {
|
||||
logQuery.type = 'all'
|
||||
logQuery.dateRange = null
|
||||
logQuery.user = ''
|
||||
searchLogs()
|
||||
}
|
||||
|
||||
// 处理日志表格变化(排序、筛选、分页)
|
||||
const handleLogTableChange = (pagination, filters, sorter) => {
|
||||
logPagination.current = pagination.current
|
||||
// 这里可以根据sorter和filters更新查询参数
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// 查看日志详情
|
||||
const viewLogDetail = (record) => {
|
||||
selectedLog.value = record
|
||||
logDetailVisible.value = true
|
||||
}
|
||||
|
||||
// 下载备份
|
||||
const downloadBackup = (record) => {
|
||||
// 这里应该调用API下载备份
|
||||
message.success(`开始下载备份 ${record.id}`)
|
||||
}
|
||||
|
||||
// 确认恢复备份
|
||||
const confirmRestoreBackup = (record) => {
|
||||
selectedBackup.value = record
|
||||
restoreConfirmVisible.value = true
|
||||
}
|
||||
|
||||
// 恢复备份
|
||||
const restoreBackup = () => {
|
||||
// 这里应该调用API恢复备份
|
||||
message.loading({ content: '正在恢复备份...', key: 'restoreLoading' })
|
||||
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
message.success({ content: '备份恢复成功,系统将在5秒后刷新', key: 'restoreLoading' })
|
||||
restoreConfirmVisible.value = false
|
||||
|
||||
// 模拟系统刷新
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 5000)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 获取日志级别对应的颜色
|
||||
const getLogLevelColor = (level) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
debug: 'green'
|
||||
}
|
||||
return colors[level] || 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
padding: 24px;
|
||||
background-color: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
background-color: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
color: #8c8c8c;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-search-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.log-table, .backup-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.log-detail-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
740
bank-frontend/src/views/Transactions.vue
Normal file
740
bank-frontend/src/views/Transactions.vue
Normal file
@@ -0,0 +1,740 @@
|
||||
<template>
|
||||
<div class="transactions-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1>交易管理</h1>
|
||||
<p>查询和管理银行交易记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<a-card class="filter-card" :bordered="false">
|
||||
<a-form layout="inline" :model="filterForm">
|
||||
<a-form-item label="交易类型">
|
||||
<a-select
|
||||
v-model:value="filterForm.type"
|
||||
style="width: 150px"
|
||||
placeholder="选择交易类型"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="deposit">存款</a-select-option>
|
||||
<a-select-option value="withdrawal">取款</a-select-option>
|
||||
<a-select-option value="transfer">转账</a-select-option>
|
||||
<a-select-option value="payment">支付</a-select-option>
|
||||
<a-select-option value="interest">利息</a-select-option>
|
||||
<a-select-option value="fee">手续费</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="账户号码">
|
||||
<a-input
|
||||
v-model:value="filterForm.accountNumber"
|
||||
placeholder="输入账户号码"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="交易日期">
|
||||
<a-range-picker
|
||||
v-model:value="filterForm.dateRange"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 300px"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="金额范围">
|
||||
<a-input-number
|
||||
v-model:value="filterForm.minAmount"
|
||||
placeholder="最小金额"
|
||||
style="width: 120px"
|
||||
:precision="2"
|
||||
/>
|
||||
<span style="margin: 0 8px;">至</span>
|
||||
<a-input-number
|
||||
v-model:value="filterForm.maxAmount"
|
||||
placeholder="最大金额"
|
||||
style="width: 120px"
|
||||
:precision="2"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<search-outlined /> 搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="resetFilters">
|
||||
<reload-outlined /> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 交易统计卡片 -->
|
||||
<!-- <a-row :gutter="[16, 16]" class="stats-cards">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="今日交易总数"
|
||||
:value="stats.todayCount"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<transaction-outlined style="color: #1890ff" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="今日交易总额"
|
||||
:value="stats.todayAmount"
|
||||
:precision="2"
|
||||
:loading="statsLoading"
|
||||
:formatter="value => `¥${value}`"
|
||||
>
|
||||
<template #prefix>
|
||||
<dollar-outlined style="color: #52c41a" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="本月交易总数"
|
||||
:value="stats.monthCount"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<bar-chart-outlined style="color: #fa8c16" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stat-card">
|
||||
<a-statistic
|
||||
title="本月交易总额"
|
||||
:value="stats.monthAmount"
|
||||
:precision="2"
|
||||
:loading="statsLoading"
|
||||
:formatter="value => `¥${value}`"
|
||||
>
|
||||
<template #prefix>
|
||||
<fund-outlined style="color: #722ed1" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row> -->
|
||||
|
||||
<!-- 交易表格 -->
|
||||
<a-card :bordered="false" class="table-card">
|
||||
<template #title>
|
||||
<div class="table-title">
|
||||
<span>交易记录列表</span>
|
||||
<a-button type="primary" @click="exportTransactions">
|
||||
<download-outlined /> 导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="transactions"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 交易类型列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="getTransactionTypeColor(record.type)">
|
||||
{{ getTransactionTypeName(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 金额列 -->
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span :style="{ color: isNegativeTransaction(record.type) ? '#f5222d' : '#52c41a' }">
|
||||
{{ isNegativeTransaction(record.type) ? '-' : '+' }}{{ formatCurrency(record.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusName(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="viewTransactionDetail(record)">详情</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="printReceipt(record)">打印凭证</a>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 交易详情对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="detailModalVisible"
|
||||
title="交易详情"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
>
|
||||
<template v-if="selectedTransaction">
|
||||
<a-descriptions bordered :column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }">
|
||||
<a-descriptions-item label="交易ID">{{ selectedTransaction.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="交易类型">
|
||||
<a-tag :color="getTransactionTypeColor(selectedTransaction.type)">
|
||||
{{ getTransactionTypeName(selectedTransaction.type) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="交易金额">
|
||||
<span :style="{ color: isNegativeTransaction(selectedTransaction.type) ? '#f5222d' : '#52c41a' }">
|
||||
{{ isNegativeTransaction(selectedTransaction.type) ? '-' : '+' }}{{ formatCurrency(selectedTransaction.amount) }}
|
||||
</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="交易状态">
|
||||
<a-tag :color="getStatusColor(selectedTransaction.status)">
|
||||
{{ getStatusName(selectedTransaction.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="交易时间">{{ selectedTransaction.timestamp }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账户号码">{{ selectedTransaction.accountNumber }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账户名称">{{ selectedTransaction.accountName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="交易渠道">{{ getChannelName(selectedTransaction.channel) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="交易描述" :span="2">{{ selectedTransaction.description || '无' }}</a-descriptions-item>
|
||||
|
||||
<template v-if="selectedTransaction.type === 'transfer'">
|
||||
<a-descriptions-item label="收款账户">{{ selectedTransaction.targetAccountNumber }}</a-descriptions-item>
|
||||
<a-descriptions-item label="收款人">{{ selectedTransaction.targetAccountName }}</a-descriptions-item>
|
||||
</template>
|
||||
|
||||
<a-descriptions-item label="交易备注" :span="2">{{ selectedTransaction.notes || '无' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h3>交易流水</h3>
|
||||
<a-timeline>
|
||||
<a-timeline-item color="green">
|
||||
交易发起 - {{ selectedTransaction.timestamp }}
|
||||
</a-timeline-item>
|
||||
<a-timeline-item color="blue">
|
||||
交易处理 - {{ selectedTransaction.processedTime || '立即处理' }}
|
||||
</a-timeline-item>
|
||||
<a-timeline-item :color="selectedTransaction.status === 'completed' ? 'green' : 'red'">
|
||||
交易{{ selectedTransaction.status === 'completed' ? '完成' : '失败' }} - {{ selectedTransaction.completedTime || selectedTransaction.timestamp }}
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<a-button @click="printReceipt(selectedTransaction)" style="margin-right: 8px;">
|
||||
<printer-outlined /> 打印凭证
|
||||
</a-button>
|
||||
<a-button @click="detailModalVisible = false">关闭</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
TransactionOutlined,
|
||||
DollarOutlined,
|
||||
BarChartOutlined,
|
||||
FundOutlined,
|
||||
PrinterOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TransactionsPage',
|
||||
components: {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
TransactionOutlined,
|
||||
DollarOutlined,
|
||||
BarChartOutlined,
|
||||
FundOutlined,
|
||||
PrinterOutlined
|
||||
},
|
||||
setup() {
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '交易ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '交易类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
filters: [
|
||||
{ text: '存款', value: 'deposit' },
|
||||
{ text: '取款', value: 'withdrawal' },
|
||||
{ text: '转账', value: 'transfer' },
|
||||
{ text: '支付', value: 'payment' },
|
||||
{ text: '利息', value: 'interest' },
|
||||
{ text: '手续费', value: 'fee' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账户号码',
|
||||
dataIndex: 'accountNumber',
|
||||
key: 'accountNumber',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '交易时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '已完成', value: 'completed' },
|
||||
{ text: '处理中', value: 'processing' },
|
||||
{ text: '失败', value: 'failed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '交易渠道',
|
||||
dataIndex: 'channel',
|
||||
key: 'channel',
|
||||
filters: [
|
||||
{ text: '柜台', value: 'counter' },
|
||||
{ text: '网银', value: 'online' },
|
||||
{ text: '手机银行', value: 'mobile' },
|
||||
{ text: 'ATM', value: 'atm' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态变量
|
||||
const transactions = ref([]);
|
||||
const loading = ref(false);
|
||||
const statsLoading = ref(false);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
});
|
||||
|
||||
// 筛选表单
|
||||
const filterForm = reactive({
|
||||
type: undefined,
|
||||
accountNumber: '',
|
||||
dateRange: [],
|
||||
minAmount: null,
|
||||
maxAmount: null,
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
todayCount: 0,
|
||||
todayAmount: 0,
|
||||
monthCount: 0,
|
||||
monthAmount: 0,
|
||||
});
|
||||
|
||||
// 交易详情相关
|
||||
const detailModalVisible = ref(false);
|
||||
const selectedTransaction = ref(null);
|
||||
|
||||
// 获取交易列表
|
||||
const fetchTransactions = async (params = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getTransactions(params);
|
||||
// transactions.value = response.data;
|
||||
// pagination.total = response.total;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
const mockTransactions = [
|
||||
{
|
||||
id: 'T20230917001',
|
||||
type: 'deposit',
|
||||
accountNumber: '6225123456789001',
|
||||
accountName: '张三储蓄账户',
|
||||
amount: 5000.00,
|
||||
timestamp: '2023-09-17 09:30:25',
|
||||
processedTime: '2023-09-17 09:30:26',
|
||||
completedTime: '2023-09-17 09:30:28',
|
||||
status: 'completed',
|
||||
channel: 'counter',
|
||||
description: '现金存款',
|
||||
notes: ''
|
||||
},
|
||||
{
|
||||
id: 'T20230917002',
|
||||
type: 'withdrawal',
|
||||
accountNumber: '6225123456789002',
|
||||
accountName: '李四活期账户',
|
||||
amount: 2000.00,
|
||||
timestamp: '2023-09-17 10:15:42',
|
||||
processedTime: '2023-09-17 10:15:43',
|
||||
completedTime: '2023-09-17 10:15:45',
|
||||
status: 'completed',
|
||||
channel: 'atm',
|
||||
description: 'ATM取款',
|
||||
notes: ''
|
||||
},
|
||||
{
|
||||
id: 'T20230917003',
|
||||
type: 'transfer',
|
||||
accountNumber: '6225123456789001',
|
||||
accountName: '张三储蓄账户',
|
||||
targetAccountNumber: '6225123456789002',
|
||||
targetAccountName: '李四活期账户',
|
||||
amount: 3000.00,
|
||||
timestamp: '2023-09-17 11:05:18',
|
||||
processedTime: '2023-09-17 11:05:20',
|
||||
completedTime: '2023-09-17 11:05:22',
|
||||
status: 'completed',
|
||||
channel: 'online',
|
||||
description: '转账给李四',
|
||||
notes: '项目款'
|
||||
},
|
||||
{
|
||||
id: 'T20230916001',
|
||||
type: 'payment',
|
||||
accountNumber: '6225123456789003',
|
||||
accountName: '王五信用卡',
|
||||
amount: 1500.00,
|
||||
timestamp: '2023-09-16 14:30:10',
|
||||
processedTime: '2023-09-16 14:30:12',
|
||||
completedTime: '2023-09-16 14:30:15',
|
||||
status: 'completed',
|
||||
channel: 'mobile',
|
||||
description: '电商购物',
|
||||
notes: ''
|
||||
},
|
||||
{
|
||||
id: 'T20230916002',
|
||||
type: 'interest',
|
||||
accountNumber: '6225123456789001',
|
||||
accountName: '张三储蓄账户',
|
||||
amount: 125.50,
|
||||
timestamp: '2023-09-16 23:59:59',
|
||||
processedTime: '2023-09-16 23:59:59',
|
||||
completedTime: '2023-09-16 23:59:59',
|
||||
status: 'completed',
|
||||
channel: 'system',
|
||||
description: '利息结算',
|
||||
notes: '月度利息'
|
||||
},
|
||||
{
|
||||
id: 'T20230915001',
|
||||
type: 'fee',
|
||||
accountNumber: '6225123456789003',
|
||||
accountName: '王五信用卡',
|
||||
amount: 50.00,
|
||||
timestamp: '2023-09-15 00:00:01',
|
||||
processedTime: '2023-09-15 00:00:01',
|
||||
completedTime: '2023-09-15 00:00:01',
|
||||
status: 'completed',
|
||||
channel: 'system',
|
||||
description: '年费',
|
||||
notes: ''
|
||||
},
|
||||
{
|
||||
id: 'T20230915002',
|
||||
type: 'transfer',
|
||||
accountNumber: '6225123456789004',
|
||||
accountName: '赵六房贷',
|
||||
targetAccountNumber: '6225123456789001',
|
||||
targetAccountName: '张三储蓄账户',
|
||||
amount: 5000.00,
|
||||
timestamp: '2023-09-15 09:45:30',
|
||||
processedTime: '2023-09-15 09:45:32',
|
||||
status: 'failed',
|
||||
channel: 'online',
|
||||
description: '转账',
|
||||
notes: '余额不足'
|
||||
},
|
||||
];
|
||||
transactions.value = mockTransactions;
|
||||
pagination.total = mockTransactions.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
message.error('获取交易列表失败');
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getTransactionStats();
|
||||
// Object.assign(stats, response.data);
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
stats.todayCount = 15;
|
||||
stats.todayAmount = 25000.00;
|
||||
stats.monthCount = 342;
|
||||
stats.monthAmount = 1250000.00;
|
||||
statsLoading.value = false;
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
message.error('获取统计数据失败');
|
||||
statsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
const params = {
|
||||
page: pag.current,
|
||||
pageSize: pag.pageSize,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
...filters,
|
||||
};
|
||||
|
||||
pagination.current = pag.current;
|
||||
fetchTransactions(params);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
|
||||
const params = {
|
||||
type: filterForm.type,
|
||||
accountNumber: filterForm.accountNumber,
|
||||
startDate: filterForm.dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: filterForm.dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
minAmount: filterForm.minAmount,
|
||||
maxAmount: filterForm.maxAmount,
|
||||
};
|
||||
|
||||
fetchTransactions(params);
|
||||
};
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
filterForm.type = undefined;
|
||||
filterForm.accountNumber = '';
|
||||
filterForm.dateRange = [];
|
||||
filterForm.minAmount = null;
|
||||
filterForm.maxAmount = null;
|
||||
|
||||
pagination.current = 1;
|
||||
fetchTransactions();
|
||||
};
|
||||
|
||||
// 查看交易详情
|
||||
const viewTransactionDetail = (record) => {
|
||||
selectedTransaction.value = record;
|
||||
detailModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 打印交易凭证
|
||||
const printReceipt = (record) => {
|
||||
message.success(`正在打印交易 ${record.id} 的凭证`);
|
||||
// 实际应用中,这里应该调用打印API或生成PDF
|
||||
};
|
||||
|
||||
// 导出交易数据
|
||||
const exportTransactions = () => {
|
||||
message.success('交易数据导出中,请稍候...');
|
||||
// 实际应用中,这里应该调用导出API
|
||||
setTimeout(() => {
|
||||
message.success('交易数据导出成功');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 格式化货币
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 2
|
||||
}).format(value).replace('CN¥', '¥');
|
||||
};
|
||||
|
||||
// 判断是否为负向交易(支出)
|
||||
const isNegativeTransaction = (type) => {
|
||||
return ['withdrawal', 'payment', 'fee', 'transfer'].includes(type);
|
||||
};
|
||||
|
||||
// 获取交易类型名称
|
||||
const getTransactionTypeName = (type) => {
|
||||
const typeMap = {
|
||||
deposit: '存款',
|
||||
withdrawal: '取款',
|
||||
transfer: '转账',
|
||||
payment: '支付',
|
||||
interest: '利息',
|
||||
fee: '手续费',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取交易类型颜色
|
||||
const getTransactionTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
deposit: 'green',
|
||||
withdrawal: 'red',
|
||||
transfer: 'blue',
|
||||
payment: 'orange',
|
||||
interest: 'purple',
|
||||
fee: 'cyan',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 获取交易状态名称
|
||||
const getStatusName = (status) => {
|
||||
const statusMap = {
|
||||
completed: '已完成',
|
||||
processing: '处理中',
|
||||
failed: '失败',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
// 获取交易状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
completed: 'green',
|
||||
processing: 'blue',
|
||||
failed: 'red',
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
// 获取交易渠道名称
|
||||
const getChannelName = (channel) => {
|
||||
const channelMap = {
|
||||
counter: '柜台',
|
||||
online: '网银',
|
||||
mobile: '手机银行',
|
||||
atm: 'ATM',
|
||||
system: '系统',
|
||||
};
|
||||
return channelMap[channel] || channel;
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchTransactions();
|
||||
fetchStats();
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
transactions,
|
||||
loading,
|
||||
pagination,
|
||||
filterForm,
|
||||
stats,
|
||||
statsLoading,
|
||||
detailModalVisible,
|
||||
selectedTransaction,
|
||||
handleTableChange,
|
||||
handleSearch,
|
||||
resetFilters,
|
||||
viewTransactionDetail,
|
||||
printReceipt,
|
||||
exportTransactions,
|
||||
formatCurrency,
|
||||
isNegativeTransaction,
|
||||
getTransactionTypeName,
|
||||
getTransactionTypeColor,
|
||||
getStatusName,
|
||||
getStatusColor,
|
||||
getChannelName,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transactions-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
412
bank-frontend/src/views/Users.vue
Normal file
412
bank-frontend/src/views/Users.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<p>管理系统用户账号和权限</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<a-button type="primary" @click="showAddUserModal">
|
||||
<plus-outlined /> 添加用户
|
||||
</a-button>
|
||||
<a-input-search
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索用户..."
|
||||
style="width: 250px; margin-left: 16px;"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status === 'active' ? '活跃' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 角色列 -->
|
||||
<template v-if="column.key === 'role'">
|
||||
<a-tag :color="getRoleColor(record.role)">
|
||||
{{ getRoleName(record.role) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="editUser(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="deleteUser(record.id)"
|
||||
>
|
||||
<a class="danger-link">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑用户对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="userModalVisible"
|
||||
:title="isEditing ? '编辑用户' : '添加用户'"
|
||||
@ok="handleUserFormSubmit"
|
||||
:confirmLoading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="userForm"
|
||||
:rules="rules"
|
||||
ref="userFormRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input v-model:value="userForm.username" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
:rules="isEditing ? [] : [{ required: true, message: '请输入密码' }]"
|
||||
>
|
||||
<a-input-password v-model:value="userForm.password" :placeholder="isEditing ? '不修改请留空' : '请输入密码'" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="姓名" name="name">
|
||||
<a-input v-model:value="userForm.name" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="角色" name="role">
|
||||
<a-select v-model:value="userForm.role">
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
<a-select-option value="manager">经理</a-select-option>
|
||||
<a-select-option value="teller">柜员</a-select-option>
|
||||
<a-select-option value="user">普通用户</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="userForm.status">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="disabled">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UsersPage',
|
||||
components: {
|
||||
PlusOutlined
|
||||
},
|
||||
setup() {
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
filters: [
|
||||
{ text: '管理员', value: 'admin' },
|
||||
{ text: '经理', value: 'manager' },
|
||||
{ text: '柜员', value: 'teller' },
|
||||
{ text: '普通用户', value: 'user' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '活跃', value: 'active' },
|
||||
{ text: '禁用', value: 'disabled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态变量
|
||||
const users = ref([]);
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
});
|
||||
|
||||
// 用户表单相关
|
||||
const userFormRef = ref(null);
|
||||
const userModalVisible = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const submitting = ref(false);
|
||||
const userForm = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间', trigger: 'blur' },
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
],
|
||||
role: [
|
||||
{ required: true, message: '请选择角色', trigger: 'change' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' },
|
||||
],
|
||||
};
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async (params = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await api.getUsers(params);
|
||||
// users.value = response.data;
|
||||
// pagination.total = response.total;
|
||||
|
||||
// 模拟数据
|
||||
setTimeout(() => {
|
||||
const mockUsers = [
|
||||
{ id: 1, username: 'admin', name: '系统管理员', role: 'admin', status: 'active', createdAt: '2023-01-01' },
|
||||
{ id: 2, username: 'manager1', name: '张经理', role: 'manager', status: 'active', createdAt: '2023-01-02' },
|
||||
{ id: 3, username: 'teller1', name: '李柜员', role: 'teller', status: 'active', createdAt: '2023-01-03' },
|
||||
{ id: 4, username: 'user1', name: '王用户', role: 'user', status: 'disabled', createdAt: '2023-01-04' },
|
||||
];
|
||||
users.value = mockUsers;
|
||||
pagination.total = mockUsers.length;
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
const params = {
|
||||
page: pag.current,
|
||||
pageSize: pag.pageSize,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
...filters,
|
||||
};
|
||||
|
||||
pagination.current = pag.current;
|
||||
fetchUsers(params);
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchUsers({ search: searchQuery.value });
|
||||
};
|
||||
|
||||
// 显示添加用户对话框
|
||||
const showAddUserModal = () => {
|
||||
isEditing.value = false;
|
||||
resetUserForm();
|
||||
userModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑用户
|
||||
const editUser = (record) => {
|
||||
isEditing.value = true;
|
||||
Object.assign(userForm, { ...record, password: '' });
|
||||
userModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (id) => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
// await api.deleteUser(id);
|
||||
message.success('用户删除成功');
|
||||
fetchUsers({ page: pagination.current });
|
||||
} catch (error) {
|
||||
message.error('删除用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交用户表单
|
||||
const handleUserFormSubmit = () => {
|
||||
userFormRef.value.validate().then(async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// 编辑用户
|
||||
// await api.updateUser(userForm.id, userForm);
|
||||
message.success('用户更新成功');
|
||||
} else {
|
||||
// 添加用户
|
||||
// await api.createUser(userForm);
|
||||
message.success('用户添加成功');
|
||||
}
|
||||
userModalVisible.value = false;
|
||||
fetchUsers({ page: pagination.current });
|
||||
} catch (error) {
|
||||
message.error(isEditing.value ? '更新用户失败' : '添加用户失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 重置用户表单
|
||||
const resetUserForm = () => {
|
||||
userForm.id = null;
|
||||
userForm.username = '';
|
||||
userForm.password = '';
|
||||
userForm.name = '';
|
||||
userForm.role = 'user';
|
||||
userForm.status = 'active';
|
||||
|
||||
// 如果表单已经创建,则重置验证
|
||||
if (userFormRef.value) {
|
||||
userFormRef.value.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色名称
|
||||
const getRoleName = (role) => {
|
||||
const roleMap = {
|
||||
admin: '管理员',
|
||||
manager: '经理',
|
||||
teller: '柜员',
|
||||
user: '普通用户',
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
// 获取角色颜色
|
||||
const getRoleColor = (role) => {
|
||||
const colorMap = {
|
||||
admin: 'red',
|
||||
manager: 'blue',
|
||||
teller: 'green',
|
||||
user: 'default',
|
||||
};
|
||||
return colorMap[role] || 'default';
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
users,
|
||||
loading,
|
||||
searchQuery,
|
||||
pagination,
|
||||
userFormRef,
|
||||
userModalVisible,
|
||||
isEditing,
|
||||
submitting,
|
||||
userForm,
|
||||
rules,
|
||||
handleTableChange,
|
||||
handleSearch,
|
||||
showAddUserModal,
|
||||
editUser,
|
||||
deleteUser,
|
||||
handleUserFormSubmit,
|
||||
getRoleName,
|
||||
getRoleColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
44
bank-frontend/vite.config.js
Normal file
44
bank-frontend/vite.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5300,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_BASE_URL || 'http://localhost:5350',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api')
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
entryFileNames: 'assets/[name]-[hash].js'
|
||||
}
|
||||
}
|
||||
},
|
||||
define: {
|
||||
// 将环境变量注入到应用中
|
||||
__APP_ENV__: JSON.stringify(env)
|
||||
}
|
||||
}
|
||||
})
|
||||
358
docs/保险端产品需求文档.md
Normal file
358
docs/保险端产品需求文档.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# 保险端产品需求文档 (PRD)
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 作者 | 变更说明 |
|
||||
|------|------|------|----------|
|
||||
| v1.0 | 2025-01-19 | 产品经理 | 初始版本,基于宁夏智慧养殖监管平台需求梳理 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 背景
|
||||
宁夏智慧养殖监管平台中的保险监管系统,旨在为保险公司提供专业的养殖保险业务管理解决方案。通过数字化手段实现保险业务的智能化、规范化和高效化管理,降低保险公司运营成本,提高理赔效率和客户满意度。
|
||||
|
||||
### 1.2 目标
|
||||
- **业务目标**:提升保险业务处理效率60%,降低运营成本40%,提高客户满意度35%
|
||||
- **用户目标**:为保险公司提供一站式养殖保险业务管理平台
|
||||
- **技术目标**:构建稳定、安全、可扩展的保险业务管理系统
|
||||
|
||||
### 1.3 成功标准
|
||||
- 理赔处理时间缩短70%
|
||||
- 风险识别准确率达到90%
|
||||
- 赔付率下降25%
|
||||
- 系统可用性达到99.9%
|
||||
|
||||
### 1.4 范围界定
|
||||
**包含范围:**
|
||||
- 保单全生命周期管理
|
||||
- 理赔流程管理
|
||||
- 风险定价与评估
|
||||
- 防损管理
|
||||
- 数据统计分析
|
||||
- 业务监控预警
|
||||
|
||||
**不包含范围:**
|
||||
- 财务核算系统
|
||||
- 人力资源管理
|
||||
- 第三方支付接口
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户角色与用例
|
||||
|
||||
### 2.1 用户角色
|
||||
|
||||
| 角色 | 描述 | 主要职责 |
|
||||
|------|------|----------|
|
||||
| 保险业务员 | 负责保险销售和客户服务 | 保单录入、客户咨询、业务推广 |
|
||||
| 理赔专员 | 负责理赔案件处理 | 理赔审核、现场查勘、赔付处理 |
|
||||
| 风控专员 | 负责风险评估和控制 | 风险定价、防损管理、数据分析 |
|
||||
| 系统管理员 | 负责系统维护和用户管理 | 用户权限管理、系统配置、数据备份 |
|
||||
|
||||
### 2.2 核心用例
|
||||
|
||||
1. **保单管理**:投保、续保、批改、退保
|
||||
2. **理赔处理**:报案登记、查勘定损、理赔审核、赔付处理
|
||||
3. **风险管理**:风险评估、定价策略、防损措施
|
||||
4. **数据分析**:业务统计、趋势分析、报表生成
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能需求
|
||||
|
||||
### 3.1 保单管理模块
|
||||
|
||||
#### 3.1.1 保单录入
|
||||
**用户故事:** As a 保险业务员,I want to 快速录入保单信息,so that 我可以高效完成投保业务
|
||||
|
||||
**功能描述:**
|
||||
- 支持养殖保险保单信息录入
|
||||
- 自动校验保单信息完整性和准确性
|
||||
- 支持批量导入保单数据
|
||||
- 关联养殖场和牲畜信息
|
||||
|
||||
**验收标准:**
|
||||
- Given 业务员登录系统
|
||||
- When 选择新增保单功能
|
||||
- Then 显示保单录入表单
|
||||
- And 支持必填字段验证
|
||||
- And 保存成功后生成唯一保单号
|
||||
|
||||
#### 3.1.2 保单查询与管理
|
||||
**功能描述:**
|
||||
- 多维度保单查询(保单号、投保人、保险类型、状态等)
|
||||
- 保单详情查看和编辑
|
||||
- 保单状态管理(有效、过期、待审核等)
|
||||
- 保单续保提醒
|
||||
|
||||
**数据字段:**
|
||||
- 保单号(policyNo)
|
||||
- 投保人(insured)
|
||||
- 保险类型(type)
|
||||
- 保险金额(amount)
|
||||
- 保费(premium)
|
||||
- 生效日期(startDate)
|
||||
- 到期日期(endDate)
|
||||
- 状态(status)
|
||||
|
||||
### 3.2 理赔处理模块
|
||||
|
||||
#### 3.2.1 报案登记
|
||||
**用户故事:** As a 理赔专员,I want to 快速登记理赔案件,so that 我可以及时启动理赔流程
|
||||
|
||||
**功能描述:**
|
||||
- 理赔案件信息登记
|
||||
- 关联保单信息验证
|
||||
- 上传相关证明材料
|
||||
- 自动分配查勘人员
|
||||
|
||||
#### 3.2.2 查勘定损
|
||||
**功能描述:**
|
||||
- 现场查勘记录
|
||||
- 损失评估和定损
|
||||
- 查勘照片和视频上传
|
||||
- 查勘报告生成
|
||||
|
||||
#### 3.2.3 理赔审核
|
||||
**功能描述:**
|
||||
- 理赔材料审核
|
||||
- 赔付金额计算
|
||||
- 审核流程管理
|
||||
- 审核意见记录
|
||||
|
||||
#### 3.2.4 赔付处理
|
||||
**功能描述:**
|
||||
- 赔付金额确认
|
||||
- 赔付方式选择
|
||||
- 赔付状态跟踪
|
||||
- 赔付凭证生成
|
||||
|
||||
### 3.3 风险管理模块
|
||||
|
||||
#### 3.3.1 风险评估
|
||||
**功能描述:**
|
||||
- 基于历史数据的风险评估模型
|
||||
- 养殖场风险等级评定
|
||||
- 个体牲畜风险评估
|
||||
- 区域风险分析
|
||||
|
||||
#### 3.3.2 风险定价
|
||||
**功能描述:**
|
||||
- 动态费率计算
|
||||
- 风险系数调整
|
||||
- 保费优惠政策管理
|
||||
- 定价策略配置
|
||||
|
||||
#### 3.3.3 防损管理
|
||||
**功能描述:**
|
||||
- 风险预警机制
|
||||
- 防损措施建议
|
||||
- 防损效果跟踪
|
||||
- 防损成本分析
|
||||
|
||||
### 3.4 数据统计分析模块
|
||||
|
||||
#### 3.4.1 业务统计
|
||||
**功能描述:**
|
||||
- 保单统计(数量、金额、类型分布)
|
||||
- 理赔统计(案件数、赔付率、处理时效)
|
||||
- 收入统计(保费收入、增长趋势)
|
||||
- 客户统计(新增客户、续保率)
|
||||
|
||||
#### 3.4.2 数据可视化
|
||||
**功能描述:**
|
||||
- 参保统计图表(如牛只参保统计)
|
||||
- 保险公司业务分布
|
||||
- 理赔趋势分析
|
||||
- 风险热力图
|
||||
|
||||
#### 3.4.3 报表管理
|
||||
**功能描述:**
|
||||
- 标准报表模板
|
||||
- 自定义报表配置
|
||||
- 报表定时生成
|
||||
- 报表导出功能
|
||||
|
||||
### 3.5 系统管理模块
|
||||
|
||||
#### 3.5.1 用户管理
|
||||
**功能描述:**
|
||||
- 用户账号管理
|
||||
- 角色权限配置
|
||||
- 登录日志记录
|
||||
- 密码策略管理
|
||||
|
||||
#### 3.5.2 参数配置
|
||||
**功能描述:**
|
||||
- 保险产品配置
|
||||
- 费率参数设置
|
||||
- 业务流程配置
|
||||
- 系统参数管理
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### 4.1 性能要求
|
||||
- **响应时间**:页面加载时间 < 3秒,API响应时间 < 1秒
|
||||
- **并发用户**:支持1000个并发用户
|
||||
- **数据处理**:支持单次处理10万条保单数据
|
||||
|
||||
### 4.2 安全要求
|
||||
- **数据加密**:采用HTTPS加密传输,敏感数据AES-256加密存储
|
||||
- **身份认证**:JWT令牌机制,支持单点登录
|
||||
- **权限控制**:基于角色的访问控制(RBAC)
|
||||
- **安全防护**:SQL注入防护、XSS防护、CSRF防护
|
||||
|
||||
### 4.3 可靠性要求
|
||||
- **系统可用性**:99.9%
|
||||
- **数据备份**:每日自动备份,支持增量备份
|
||||
- **故障恢复**:RTO < 4小时,RPO < 1小时
|
||||
- **容错机制**:关键业务支持降级处理
|
||||
|
||||
### 4.4 兼容性要求
|
||||
- **浏览器支持**:Chrome 70+、Firefox 65+、Safari 12+、Edge 79+
|
||||
- **移动端适配**:响应式设计,支持主流移动设备
|
||||
- **操作系统**:Windows 10+、macOS 10.14+、主流Linux发行版
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术架构
|
||||
|
||||
### 5.1 技术栈
|
||||
- **前端**:Vue.js 3.x + Ant Design Vue + ECharts
|
||||
- **后端**:Node.js + Express/NestJS
|
||||
- **数据库**:MySQL 8.0+
|
||||
- **缓存**:Redis
|
||||
- **构建工具**:Vite
|
||||
|
||||
### 5.2 系统架构
|
||||
- **表现层**:Vue.js组件化开发
|
||||
- **业务层**:RESTful API服务
|
||||
- **数据层**:MySQL关系型数据库
|
||||
- **缓存层**:Redis缓存热点数据
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据字典
|
||||
|
||||
### 6.1 保单状态枚举
|
||||
```javascript
|
||||
const PolicyStatus = {
|
||||
ACTIVE: 'active', // 有效
|
||||
EXPIRED: 'expired', // 已过期
|
||||
PENDING: 'pending', // 待审核
|
||||
CANCELLED: 'cancelled' // 已取消
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 理赔状态枚举
|
||||
```javascript
|
||||
const ClaimStatus = {
|
||||
REPORTED: 'reported', // 已报案
|
||||
SURVEYING: 'surveying', // 查勘中
|
||||
REVIEWING: 'reviewing', // 审核中
|
||||
APPROVED: 'approved', // 已批准
|
||||
PAID: 'paid', // 已赔付
|
||||
REJECTED: 'rejected' // 已拒赔
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API规范
|
||||
|
||||
### 7.1 通用响应格式
|
||||
```javascript
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {},
|
||||
"timestamp": "2025-01-19T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 错误处理
|
||||
```javascript
|
||||
{
|
||||
"code": 400,
|
||||
"message": "参数错误",
|
||||
"error": "详细错误信息",
|
||||
"timestamp": "2025-01-19T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 部署要求
|
||||
|
||||
### 8.1 环境要求
|
||||
- **开发环境**:Node.js 16+、MySQL 8.0+、Redis 6.0+
|
||||
- **生产环境**:Docker容器化部署,支持负载均衡
|
||||
- **监控要求**:系统监控、性能监控、业务监控
|
||||
|
||||
### 8.2 安全配置
|
||||
- 防火墙配置
|
||||
- SSL证书配置
|
||||
- 数据库安全配置
|
||||
- 日志审计配置
|
||||
|
||||
---
|
||||
|
||||
## 9. 项目计划
|
||||
|
||||
### 9.1 开发阶段
|
||||
- **需求分析**:1周
|
||||
- **系统设计**:1周
|
||||
- **开发实现**:6周
|
||||
- **测试验收**:2周
|
||||
- **部署上线**:1周
|
||||
|
||||
### 9.2 里程碑
|
||||
- M1:需求确认完成
|
||||
- M2:系统设计评审通过
|
||||
- M3:核心功能开发完成
|
||||
- M4:系统测试通过
|
||||
- M5:生产环境部署完成
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与约束
|
||||
|
||||
### 10.1 技术风险
|
||||
- 数据迁移风险
|
||||
- 系统集成风险
|
||||
- 性能瓶颈风险
|
||||
|
||||
### 10.2 业务约束
|
||||
- 监管合规要求
|
||||
- 数据安全要求
|
||||
- 业务连续性要求
|
||||
|
||||
### 10.3 资源约束
|
||||
- 开发团队规模
|
||||
- 项目预算限制
|
||||
- 时间进度要求
|
||||
|
||||
---
|
||||
|
||||
## 11. 附录
|
||||
|
||||
### 11.1 参考文档
|
||||
- 宁夏智慧养殖监管平台整体PRD
|
||||
- 保险行业监管要求
|
||||
- 技术架构设计文档
|
||||
|
||||
### 11.2 术语表
|
||||
- **保单**:保险合同的书面凭证
|
||||
- **理赔**:保险事故发生后的赔偿处理
|
||||
- **风控**:风险识别、评估和控制
|
||||
- **防损**:预防和减少损失的措施
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1.0*
|
||||
*最后更新:2025年1月19日*
|
||||
*文档状态:待评审*
|
||||
454
docs/政府端产品需求文档.md
Normal file
454
docs/政府端产品需求文档.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# 政府端产品需求文档 (PRD)
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 作者 | 变更说明 |
|
||||
|------|------|------|----------|
|
||||
| v1.0 | 2025-01-19 | 产品经理 | 初始版本,定义政府端核心功能需求 |
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 产品背景
|
||||
宁夏智慧养殖监管平台政府端是专为政府监管部门设计的综合性管理系统,旨在提升政府对养殖业的监管效率,实现监管工作的智能化、规范化和高效化。
|
||||
|
||||
### 1.2 业务目标
|
||||
- **监管效率提升70%**:通过智能化监管工具,减少人工工作量
|
||||
- **监管精准度提升**:精准识别监管重点,提高监管针对性和有效性
|
||||
- **应急响应时间缩短80%**:建立快速响应机制,提高应急处置能力
|
||||
- **监管覆盖率达到95%**:实现养殖业全面监管覆盖
|
||||
|
||||
### 1.3 产品定位
|
||||
面向政府监管部门的专业化养殖业监管平台,提供全方位的监管解决方案。
|
||||
|
||||
## 2. 用户角色与用例
|
||||
|
||||
### 2.1 核心用户角色
|
||||
|
||||
#### 2.1.1 政府监管官员
|
||||
- **角色描述**:农业部门、畜牧监管部门官员
|
||||
- **核心需求**:进行行业监管、政策执行监督、数据统计分析
|
||||
- **使用场景**:日常监管工作、政策制定、应急处理
|
||||
|
||||
#### 2.1.2 防疫监管人员
|
||||
- **角色描述**:动物疫病防控监管人员
|
||||
- **核心需求**:疫病监控、疫苗管理、防疫监督
|
||||
- **使用场景**:疫情监控、防疫检查、疫苗分发
|
||||
|
||||
#### 2.1.3 质量安全监管员
|
||||
- **角色描述**:农产品质量安全监管人员
|
||||
- **核心需求**:质量追溯、安全检测、违规处理
|
||||
- **使用场景**:质量检查、追溯管理、安全监督
|
||||
|
||||
### 2.2 用户故事
|
||||
|
||||
#### 故事1:养殖场备案管理
|
||||
**As a** 政府监管官员
|
||||
**I want to** 管理养殖场备案登记
|
||||
**So that** 我可以实现养殖主体信息的规范化管理
|
||||
|
||||
**验收标准**:
|
||||
- Given 监管官员登录系统
|
||||
- When 查看养殖场备案列表
|
||||
- Then 显示所有养殖场的备案信息
|
||||
- And 支持按地区、规模、类型筛选
|
||||
- And 可以审批新的备案申请
|
||||
|
||||
#### 故事2:防疫监管
|
||||
**As a** 防疫监管人员
|
||||
**I want to** 监控动物疫病防控情况
|
||||
**So that** 我可以确保养殖业生产安全和公共卫生安全
|
||||
|
||||
**验收标准**:
|
||||
- Given 防疫人员登录系统
|
||||
- When 查看防疫监管面板
|
||||
- Then 显示疫情分布地图
|
||||
- And 显示疫苗接种统计
|
||||
- And 可以发布防疫预警
|
||||
|
||||
#### 故事3:政策法规发布
|
||||
**As a** 政府监管官员
|
||||
**I want to** 发布养殖业相关政策法规
|
||||
**So that** 我可以指导养殖业规范发展
|
||||
|
||||
**验收标准**:
|
||||
- Given 监管官员有发布权限
|
||||
- When 创建新政策文档
|
||||
- Then 可以编辑政策内容
|
||||
- And 可以设置生效时间
|
||||
- And 可以推送给相关养殖场
|
||||
|
||||
## 3. 功能需求
|
||||
|
||||
### 3.1 核心功能模块
|
||||
|
||||
#### 3.1.1 政府监管仪表板
|
||||
**功能描述**:提供政府监管工作的全局视图和关键指标监控
|
||||
|
||||
**主要功能**:
|
||||
- 监管数据统计展示(注册养殖场、监管动物总数、注册兽医、预警信息)
|
||||
- 养殖场地区分布图表
|
||||
- 监管任务进度跟踪
|
||||
- 实时预警信息展示
|
||||
- 今日工作概览
|
||||
|
||||
**技术要求**:
|
||||
- 支持实时数据更新
|
||||
- 响应式图表展示
|
||||
- 数据钻取功能
|
||||
|
||||
#### 3.1.2 养殖场备案管理
|
||||
**功能描述**:管理养殖场的备案登记、审批和证书发放
|
||||
|
||||
**主要功能**:
|
||||
- 养殖场备案申请审批
|
||||
- 养殖场信息管理
|
||||
- 许可证管理(养殖许可证、运输许可证、屠宰许可证、饲料生产许可证)
|
||||
- 证书到期提醒
|
||||
- 备案信息查询和统计
|
||||
|
||||
**业务流程**:
|
||||
1. 养殖场提交备案申请
|
||||
2. 监管部门审核材料
|
||||
3. 现场检查(如需要)
|
||||
4. 审批决定
|
||||
5. 证书发放
|
||||
6. 定期复审
|
||||
|
||||
#### 3.1.3 防疫监管系统
|
||||
**功能描述**:动物疫病防控监管,确保养殖业生产安全
|
||||
|
||||
**主要功能**:
|
||||
- 疫情监控和预警
|
||||
- 疫苗管理(库存、分发、接种记录)
|
||||
- 防疫活动管理
|
||||
- 防疫机构管理
|
||||
- 疫情报告和统计
|
||||
|
||||
**关键指标**:
|
||||
- 疫苗接种覆盖率
|
||||
- 疫情发生频率
|
||||
- 防疫响应时间
|
||||
- 疫苗库存预警
|
||||
|
||||
#### 3.1.4 质量安全追溯
|
||||
**功能描述**:农产品质量安全追溯体系,实现全链条监管
|
||||
|
||||
**主要功能**:
|
||||
- 产品追溯码管理
|
||||
- 质量检测记录
|
||||
- 问题产品召回
|
||||
- 追溯链条查询
|
||||
- 质量安全报告
|
||||
|
||||
**追溯环节**:
|
||||
- 养殖环节:饲料、兽药使用记录
|
||||
- 屠宰环节:检疫检验记录
|
||||
- 流通环节:运输、储存记录
|
||||
- 销售环节:销售去向记录
|
||||
|
||||
#### 3.1.5 政策法规管理
|
||||
**功能描述**:发布和管理养殖业相关政策法规
|
||||
|
||||
**主要功能**:
|
||||
- 政策文档管理
|
||||
- 政策发布和推送
|
||||
- 政策执行监督
|
||||
- 政策效果评估
|
||||
- 政策法规查询
|
||||
|
||||
**政策类型**:
|
||||
- 养殖业发展政策
|
||||
- 环保要求
|
||||
- 补贴政策
|
||||
- 技术标准
|
||||
- 安全规范
|
||||
|
||||
#### 3.1.6 行业数据统计
|
||||
**功能描述**:全面的养殖业数据统计分析,为政策制定提供数据支持
|
||||
|
||||
**主要功能**:
|
||||
- 养殖业发展趋势分析
|
||||
- 区域对比分析
|
||||
- 行业结构分析
|
||||
- 经济效益分析
|
||||
- 数据报表生成
|
||||
|
||||
**统计维度**:
|
||||
- 时间维度:日、周、月、季、年
|
||||
- 地区维度:省、市、县、乡镇
|
||||
- 类型维度:养殖品种、规模、模式
|
||||
|
||||
#### 3.1.7 应急事件处理
|
||||
**功能描述**:突发事件应急处理机制,快速响应各类突发事件
|
||||
|
||||
**主要功能**:
|
||||
- 应急事件上报
|
||||
- 应急预案管理
|
||||
- 应急响应流程
|
||||
- 资源调度管理
|
||||
- 事件处理跟踪
|
||||
|
||||
**应急事件类型**:
|
||||
- 重大疫情
|
||||
- 食品安全事故
|
||||
- 环境污染事件
|
||||
- 自然灾害
|
||||
- 市场异常波动
|
||||
|
||||
### 3.2 辅助功能模块
|
||||
|
||||
#### 3.2.1 用户权限管理
|
||||
- 角色权限配置
|
||||
- 用户账号管理
|
||||
- 操作日志记录
|
||||
- 数据访问控制
|
||||
|
||||
#### 3.2.2 系统设置
|
||||
- 系统参数配置
|
||||
- 通知设置
|
||||
- 数据备份
|
||||
- 系统监控
|
||||
|
||||
#### 3.2.3 报表中心
|
||||
- 标准报表模板
|
||||
- 自定义报表
|
||||
- 报表导出
|
||||
- 报表分发
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### 4.1 性能要求
|
||||
- **响应时间**:页面加载时间 < 3秒,查询响应时间 < 2秒
|
||||
- **并发用户**:支持1000个并发用户同时在线
|
||||
- **数据处理**:支持百万级数据量的查询和统计
|
||||
- **可用性**:系统可用性 ≥ 99.5%
|
||||
|
||||
### 4.2 安全要求
|
||||
- **身份认证**:支持多因子认证
|
||||
- **数据加密**:敏感数据传输和存储加密
|
||||
- **访问控制**:基于角色的权限控制
|
||||
- **审计日志**:完整的操作审计记录
|
||||
- **数据备份**:定期数据备份和恢复机制
|
||||
|
||||
### 4.3 兼容性要求
|
||||
- **浏览器兼容**:支持Chrome、Firefox、Safari、Edge最新版本
|
||||
- **移动端适配**:响应式设计,支持平板和手机访问
|
||||
- **操作系统**:支持Windows、macOS、Linux
|
||||
|
||||
### 4.4 可扩展性要求
|
||||
- **模块化设计**:支持功能模块的独立部署和扩展
|
||||
- **API接口**:提供标准REST API接口
|
||||
- **第三方集成**:支持与其他政府系统的数据对接
|
||||
- **负载均衡**:支持水平扩展和负载均衡
|
||||
|
||||
## 5. 技术架构
|
||||
|
||||
### 5.1 前端技术栈
|
||||
- **框架**:Vue.js 3.x
|
||||
- **UI组件库**:Ant Design Vue
|
||||
- **状态管理**:Pinia
|
||||
- **路由管理**:Vue Router
|
||||
- **图表库**:ECharts
|
||||
- **地图服务**:百度地图API
|
||||
- **构建工具**:Vite
|
||||
|
||||
### 5.2 后端技术栈
|
||||
- **运行环境**:Node.js
|
||||
- **Web框架**:Express/NestJS
|
||||
- **数据库**:MySQL
|
||||
- **ORM**:TypeORM/Sequelize
|
||||
- **认证**:JWT
|
||||
- **API文档**:Swagger
|
||||
|
||||
### 5.3 部署架构
|
||||
- **容器化**:Docker
|
||||
- **编排工具**:Docker Compose
|
||||
- **反向代理**:Nginx
|
||||
- **监控**:Prometheus + Grafana
|
||||
|
||||
## 6. 数据字典
|
||||
|
||||
### 6.1 核心数据实体
|
||||
|
||||
#### 6.1.1 养殖场信息 (Farm)
|
||||
| 字段名 | 类型 | 长度 | 必填 | 说明 |
|
||||
|--------|------|------|------|------|
|
||||
| id | VARCHAR | 32 | 是 | 养殖场唯一标识 |
|
||||
| name | VARCHAR | 100 | 是 | 养殖场名称 |
|
||||
| owner | VARCHAR | 50 | 是 | 养殖场主 |
|
||||
| address | VARCHAR | 200 | 是 | 详细地址 |
|
||||
| scale | ENUM | - | 是 | 养殖规模(small/medium/large) |
|
||||
| type | ENUM | - | 是 | 养殖类型(cattle/pig/sheep/poultry) |
|
||||
| status | ENUM | - | 是 | 状态(active/inactive/suspended) |
|
||||
| license_no | VARCHAR | 50 | 否 | 许可证编号 |
|
||||
| register_date | DATETIME | - | 是 | 注册日期 |
|
||||
|
||||
#### 6.1.2 政策信息 (Policy)
|
||||
| 字段名 | 类型 | 长度 | 必填 | 说明 |
|
||||
|--------|------|------|------|------|
|
||||
| id | VARCHAR | 32 | 是 | 政策唯一标识 |
|
||||
| title | VARCHAR | 200 | 是 | 政策标题 |
|
||||
| content | TEXT | - | 是 | 政策内容 |
|
||||
| type | ENUM | - | 是 | 政策类型 |
|
||||
| status | ENUM | - | 是 | 状态(draft/active/expired) |
|
||||
| effective_date | DATETIME | - | 是 | 生效日期 |
|
||||
| expiry_date | DATETIME | - | 否 | 失效日期 |
|
||||
| publisher | VARCHAR | 50 | 是 | 发布人 |
|
||||
|
||||
#### 6.1.3 疫苗信息 (Vaccine)
|
||||
| 字段名 | 类型 | 长度 | 必填 | 说明 |
|
||||
|--------|------|------|------|------|
|
||||
| id | VARCHAR | 32 | 是 | 疫苗唯一标识 |
|
||||
| name | VARCHAR | 100 | 是 | 疫苗名称 |
|
||||
| type | ENUM | - | 是 | 疫苗类型 |
|
||||
| manufacturer | VARCHAR | 100 | 是 | 生产厂家 |
|
||||
| batch_no | VARCHAR | 50 | 是 | 批次号 |
|
||||
| production_date | DATE | - | 是 | 生产日期 |
|
||||
| expiry_date | DATE | - | 是 | 有效期 |
|
||||
| stock_quantity | INT | - | 是 | 库存数量 |
|
||||
|
||||
## 7. API规范
|
||||
|
||||
### 7.1 API设计原则
|
||||
- 遵循RESTful设计规范
|
||||
- 统一的响应格式
|
||||
- 完善的错误处理
|
||||
- API版本控制
|
||||
|
||||
### 7.2 核心API接口
|
||||
|
||||
#### 7.2.1 养殖场管理API
|
||||
```
|
||||
GET /api/v1/farms - 获取养殖场列表
|
||||
GET /api/v1/farms/{id} - 获取养殖场详情
|
||||
POST /api/v1/farms - 创建养殖场
|
||||
PUT /api/v1/farms/{id} - 更新养殖场信息
|
||||
DELETE /api/v1/farms/{id} - 删除养殖场
|
||||
```
|
||||
|
||||
#### 7.2.2 政策管理API
|
||||
```
|
||||
GET /api/v1/policies - 获取政策列表
|
||||
GET /api/v1/policies/{id} - 获取政策详情
|
||||
POST /api/v1/policies - 发布政策
|
||||
PUT /api/v1/policies/{id} - 更新政策
|
||||
DELETE /api/v1/policies/{id} - 删除政策
|
||||
```
|
||||
|
||||
#### 7.2.3 统计分析API
|
||||
```
|
||||
GET /api/v1/statistics/dashboard - 获取仪表板统计数据
|
||||
GET /api/v1/statistics/farms - 获取养殖场统计
|
||||
GET /api/v1/statistics/epidemic - 获取防疫统计
|
||||
GET /api/v1/statistics/quality - 获取质量安全统计
|
||||
```
|
||||
|
||||
### 7.3 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {},
|
||||
"timestamp": "2025-01-19T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 部署要求
|
||||
|
||||
### 8.1 硬件要求
|
||||
- **CPU**:8核心以上
|
||||
- **内存**:16GB以上
|
||||
- **存储**:500GB SSD
|
||||
- **网络**:千兆网络
|
||||
|
||||
### 8.2 软件环境
|
||||
- **操作系统**:Ubuntu 20.04 LTS或CentOS 8
|
||||
- **Node.js**:v18.x以上
|
||||
- **MySQL**:v8.0以上
|
||||
- **Nginx**:v1.20以上
|
||||
- **Docker**:v20.x以上
|
||||
|
||||
### 8.3 部署架构
|
||||
- **负载均衡**:Nginx反向代理
|
||||
- **应用服务**:多实例部署
|
||||
- **数据库**:主从复制
|
||||
- **缓存**:Redis集群
|
||||
- **监控**:Prometheus + Grafana
|
||||
|
||||
## 9. 项目计划
|
||||
|
||||
### 9.1 开发阶段
|
||||
|
||||
#### 第一阶段(4周):基础框架搭建
|
||||
- 项目架构设计
|
||||
- 基础组件开发
|
||||
- 用户认证系统
|
||||
- 权限管理系统
|
||||
|
||||
#### 第二阶段(6周):核心功能开发
|
||||
- 政府监管仪表板
|
||||
- 养殖场备案管理
|
||||
- 防疫监管系统
|
||||
- 政策法规管理
|
||||
|
||||
#### 第三阶段(4周):高级功能开发
|
||||
- 质量安全追溯
|
||||
- 行业数据统计
|
||||
- 应急事件处理
|
||||
- 报表中心
|
||||
|
||||
#### 第四阶段(2周):测试与优化
|
||||
- 功能测试
|
||||
- 性能测试
|
||||
- 安全测试
|
||||
- 用户体验优化
|
||||
|
||||
### 9.2 里程碑
|
||||
- **M1**:基础框架完成(第4周)
|
||||
- **M2**:核心功能完成(第10周)
|
||||
- **M3**:全功能完成(第14周)
|
||||
- **M4**:系统上线(第16周)
|
||||
|
||||
## 10. 风险与约束
|
||||
|
||||
### 10.1 技术风险
|
||||
- **数据安全风险**:政府敏感数据的安全保护
|
||||
- **性能风险**:大数据量处理的性能瓶颈
|
||||
- **集成风险**:与现有政府系统的集成复杂度
|
||||
|
||||
### 10.2 业务风险
|
||||
- **需求变更风险**:政策法规变化导致的需求调整
|
||||
- **用户接受度风险**:政府用户对新系统的接受程度
|
||||
- **合规风险**:系统需符合政府信息化相关规范
|
||||
|
||||
### 10.3 约束条件
|
||||
- **预算约束**:项目预算限制
|
||||
- **时间约束**:项目交付时间要求
|
||||
- **人力约束**:开发团队规模限制
|
||||
- **技术约束**:现有技术栈和基础设施限制
|
||||
|
||||
## 11. 附录
|
||||
|
||||
### 11.1 术语表
|
||||
- **PRD**:Product Requirements Document,产品需求文档
|
||||
- **API**:Application Programming Interface,应用程序编程接口
|
||||
- **REST**:Representational State Transfer,表述性状态转移
|
||||
- **JWT**:JSON Web Token,JSON网络令牌
|
||||
- **ORM**:Object-Relational Mapping,对象关系映射
|
||||
|
||||
### 11.2 参考文档
|
||||
- 《政府信息化建设规范》
|
||||
- 《农业信息化标准》
|
||||
- 《数据安全法》
|
||||
- 《网络安全法》
|
||||
|
||||
### 11.3 联系信息
|
||||
- **产品经理**:张三 (zhangsan@example.com)
|
||||
- **技术负责人**:李四 (lisi@example.com)
|
||||
- **项目经理**:王五 (wangwu@example.com)
|
||||
|
||||
---
|
||||
|
||||
**文档状态**:已完成
|
||||
**最后更新**:2025-01-19
|
||||
**下次评审**:2025-01-26
|
||||
25
government-admin/.env.example
Normal file
25
government-admin/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# 环境变量配置示例文件
|
||||
# 复制此文件为 .env 并根据实际情况修改配置
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=宁夏智慧养殖监管平台 - 政府端管理后台
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_APP_ENV=development
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=http://localhost:5350/api
|
||||
VITE_API_FULL_URL=http://localhost:5350/api
|
||||
|
||||
# 百度地图API密钥
|
||||
VITE_BAIDU_MAP_AK=your_baidu_map_api_key
|
||||
|
||||
# WebSocket配置
|
||||
VITE_WS_URL=ws://localhost:5350
|
||||
|
||||
# 文件上传配置
|
||||
VITE_UPLOAD_URL=http://localhost:5350/api/upload
|
||||
VITE_MAX_FILE_SIZE=10485760
|
||||
|
||||
# 其他配置
|
||||
VITE_ENABLE_MOCK=false
|
||||
VITE_ENABLE_DEVTOOLS=true
|
||||
1
government-admin/.nvmrc
Normal file
1
government-admin/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
16
|
||||
191
government-admin/README.md
Normal file
191
government-admin/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 宁夏智慧养殖监管平台 - 政府端管理后台
|
||||
|
||||
## 项目简介
|
||||
|
||||
本项目是宁夏智慧养殖监管平台的政府端管理后台,基于 Vue 3 + Ant Design Vue 构建,为政府监管部门提供养殖场管理、设备监控、数据分析等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**: Vue 3.4+
|
||||
- **构建工具**: Vite 5.0+
|
||||
- **UI组件库**: Ant Design Vue 4.0+
|
||||
- **状态管理**: Pinia 2.1+
|
||||
- **路由管理**: Vue Router 4.2+
|
||||
- **HTTP客户端**: Axios 1.6+
|
||||
- **图表库**: ECharts 5.4+
|
||||
- **样式预处理**: Sass
|
||||
- **Node.js版本**: 16.x
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 核心功能
|
||||
- 🔐 用户认证与权限管理
|
||||
- 🏠 养殖场信息管理
|
||||
- 🗺️ 地图可视化展示
|
||||
- 📊 设备监控与状态管理
|
||||
- 🐄 动物健康管理
|
||||
- ⚠️ 预警管理系统
|
||||
- 📈 数据可视化与报表
|
||||
- 👥 用户管理
|
||||
- ⚙️ 系统设置
|
||||
|
||||
### 技术特性
|
||||
- 📱 响应式设计,支持多端适配
|
||||
- 🎨 现代化UI设计,用户体验优良
|
||||
- 🚀 基于Vite的快速开发体验
|
||||
- 🔄 实时数据更新(WebSocket)
|
||||
- 📦 组件化开发,代码复用性高
|
||||
- 🛡️ 完善的权限控制系统
|
||||
- 🌐 国际化支持(预留)
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 16.x
|
||||
- npm 8.0+ 或 yarn 1.22+
|
||||
- 现代浏览器(Chrome 88+, Firefox 78+, Safari 14+)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd government-admin
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
```bash
|
||||
# 使用npm
|
||||
npm install
|
||||
|
||||
# 或使用yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,配置API地址等信息
|
||||
```
|
||||
|
||||
### 4. 启动开发服务器
|
||||
```bash
|
||||
# 使用npm
|
||||
npm run dev
|
||||
|
||||
# 或使用yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### 5. 构建生产版本
|
||||
```bash
|
||||
# 使用npm
|
||||
npm run build
|
||||
|
||||
# 或使用yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
government-admin/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── assets/ # 资源文件
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── styles/ # 样式文件
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── .env.example # 环境变量示例
|
||||
├── .nvmrc # Node.js版本配置
|
||||
├── index.html # HTML模板
|
||||
├── package.json # 项目配置
|
||||
├── vite.config.js # Vite配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码规范
|
||||
- 使用 ESLint + Prettier 进行代码格式化
|
||||
- 组件命名使用 PascalCase
|
||||
- 文件命名使用 kebab-case
|
||||
- 变量命名使用 camelCase
|
||||
|
||||
### Git提交规范
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复bug
|
||||
docs: 文档更新
|
||||
style: 代码格式调整
|
||||
refactor: 代码重构
|
||||
test: 测试相关
|
||||
chore: 构建过程或辅助工具的变动
|
||||
```
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 开发环境
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
访问: http://localhost:5400
|
||||
|
||||
### 生产环境
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Docker部署
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t government-admin .
|
||||
|
||||
# 运行容器
|
||||
docker run -p 5400:80 government-admin
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
后端API服务地址: http://localhost:5350/api
|
||||
|
||||
主要接口:
|
||||
- `/auth/*` - 认证相关
|
||||
- `/farms/*` - 养殖场管理
|
||||
- `/devices/*` - 设备监控
|
||||
- `/animals/*` - 动物管理
|
||||
- `/alerts/*` - 预警管理
|
||||
- `/reports/*` - 报表数据
|
||||
- `/users/*` - 用户管理
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
| Chrome | Firefox | Safari | Edge |
|
||||
|--------|---------|--------|------|
|
||||
| 88+ | 78+ | 14+ | 88+ |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 项目维护: NXXM Development Team
|
||||
- 技术支持: [技术支持邮箱]
|
||||
- 问题反馈: [GitHub Issues]
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2025-01-18)
|
||||
- 🎉 初始版本发布
|
||||
- ✨ 完成基础框架搭建
|
||||
- ✨ 实现用户认证系统
|
||||
- ✨ 完成基础布局和路由配置
|
||||
15
government-admin/index.html
Normal file
15
government-admin/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="宁夏智慧养殖监管平台 - 政府端管理后台" />
|
||||
<meta name="keywords" content="智慧养殖,监管平台,政府管理,数据监控" />
|
||||
<title>宁夏智慧养殖监管平台 - 政府端管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
6175
government-admin/package-lock.json
generated
Normal file
6175
government-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
government-admin/package.json
Normal file
65
government-admin/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "government-admin-system",
|
||||
"version": "1.0.0",
|
||||
"description": "宁夏智慧养殖监管平台 - 政府端管理后台",
|
||||
"author": "NXXM Development Team",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"vue3",
|
||||
"vite",
|
||||
"ant-design-vue",
|
||||
"echarts",
|
||||
"pinia",
|
||||
"government-admin",
|
||||
"smart-farming",
|
||||
"monitoring-system"
|
||||
],
|
||||
"engines": {
|
||||
"node": "16.x",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"preview": "vite preview --port 5400",
|
||||
"lint": "eslint . --ext .vue,.js,.ts --fix",
|
||||
"lint:check": "eslint . --ext .vue,.js,.ts",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"clean": "rimraf dist node_modules/.vite",
|
||||
"analyze": "vite-bundle-analyzer dist/stats.html",
|
||||
"deploy": "npm run build && npm run preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.0.6",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.11.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.20.1",
|
||||
"prettier": "^3.2.4",
|
||||
"rimraf": "^5.0.5",
|
||||
"vite": "^5.0.12",
|
||||
"vite-bundle-analyzer": "^0.7.0",
|
||||
"vitest": "^1.2.2",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
10
government-admin/public/favicon.svg
Normal file
10
government-admin/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#1890ff"/>
|
||||
<circle cx="50" cy="50" r="30" fill="white"/>
|
||||
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#1890ff"/>
|
||||
<circle cx="50" cy="50" r="30" fill="white"/>
|
||||
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
5
government-admin/public/static/favicon.svg
Normal file
5
government-admin/public/static/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#1890ff"/>
|
||||
<circle cx="50" cy="50" r="30" fill="white"/>
|
||||
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
60
government-admin/src/App.vue
Normal file
60
government-admin/src/App.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 应用初始化时检查用户登录状态
|
||||
authStore.checkAuthStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
// 全局样式重置
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
137
government-admin/src/api/farm.js
Normal file
137
government-admin/src/api/farm.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取养殖场列表
|
||||
export function getFarmList(params) {
|
||||
return request({
|
||||
url: '/api/farms',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取养殖场详情
|
||||
export function getFarmDetail(id) {
|
||||
return request({
|
||||
url: `/api/farms/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建养殖场
|
||||
export function createFarm(data) {
|
||||
return request({
|
||||
url: '/api/farms',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新养殖场
|
||||
export function updateFarm(id, data) {
|
||||
return request({
|
||||
url: `/api/farms/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除养殖场
|
||||
export function deleteFarm(id) {
|
||||
return request({
|
||||
url: `/api/farms/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除养殖场
|
||||
export function batchDeleteFarms(ids) {
|
||||
return request({
|
||||
url: '/api/farms/batch',
|
||||
method: 'delete',
|
||||
data: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
// 更新养殖场状态
|
||||
export function updateFarmStatus(id, status) {
|
||||
return request({
|
||||
url: `/api/farms/${id}/status`,
|
||||
method: 'patch',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取养殖场统计数据
|
||||
export function getFarmStats() {
|
||||
return request({
|
||||
url: '/api/farms/stats',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取养殖场地图数据
|
||||
export function getFarmMapData(params) {
|
||||
return request({
|
||||
url: '/api/farms/map',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 导出养殖场数据
|
||||
export function exportFarmData(params) {
|
||||
return request({
|
||||
url: '/api/farms/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
// 导入养殖场数据
|
||||
export function importFarmData(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request({
|
||||
url: '/api/farms/import',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取养殖场类型选项
|
||||
export function getFarmTypes() {
|
||||
return request({
|
||||
url: '/api/farms/types',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取养殖场规模选项
|
||||
export function getFarmScales() {
|
||||
return request({
|
||||
url: '/api/farms/scales',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证养殖场编号唯一性
|
||||
export function validateFarmCode(code, excludeId) {
|
||||
return request({
|
||||
url: '/api/farms/validate-code',
|
||||
method: 'post',
|
||||
data: { code, excludeId }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取附近养殖场
|
||||
export function getNearbyFarms(lat, lng, radius = 5000) {
|
||||
return request({
|
||||
url: '/api/farms/nearby',
|
||||
method: 'get',
|
||||
params: { lat, lng, radius }
|
||||
})
|
||||
}
|
||||
391
government-admin/src/api/government.js
Normal file
391
government-admin/src/api/government.js
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 政府业务API接口
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 政府监管API
|
||||
export const supervisionApi = {
|
||||
// 获取监管数据
|
||||
getSupervisionData: () => request.get('/api/supervision/data'),
|
||||
|
||||
// 获取监管实体列表
|
||||
getEntities: (params) => request.get('/api/supervision/entities', { params }),
|
||||
|
||||
// 添加监管实体
|
||||
addEntity: (data) => request.post('/api/supervision/entities', data),
|
||||
|
||||
// 更新监管实体
|
||||
updateEntity: (id, data) => request.put(`/api/supervision/entities/${id}`, data),
|
||||
|
||||
// 删除监管实体
|
||||
deleteEntity: (id) => request.delete(`/api/supervision/entities/${id}`),
|
||||
|
||||
// 获取检查记录
|
||||
getInspections: (params) => request.get('/api/supervision/inspections', { params }),
|
||||
|
||||
// 创建检查记录
|
||||
createInspection: (data) => request.post('/api/supervision/inspections', data),
|
||||
|
||||
// 更新检查记录
|
||||
updateInspection: (id, data) => request.put(`/api/supervision/inspections/${id}`, data),
|
||||
|
||||
// 获取违规记录
|
||||
getViolations: (params) => request.get('/api/supervision/violations', { params }),
|
||||
|
||||
// 创建违规记录
|
||||
createViolation: (data) => request.post('/api/supervision/violations', data),
|
||||
|
||||
// 处理违规记录
|
||||
processViolation: (id, data) => request.put(`/api/supervision/violations/${id}/process`, data)
|
||||
}
|
||||
|
||||
// 审批管理API
|
||||
export const approvalApi = {
|
||||
// 获取审批数据
|
||||
getApprovalData: () => request.get('/api/approval/data'),
|
||||
|
||||
// 获取审批流程
|
||||
getWorkflows: (params) => request.get('/api/approval/workflows', { params }),
|
||||
|
||||
// 创建审批流程
|
||||
createWorkflow: (data) => request.post('/api/approval/workflows', data),
|
||||
|
||||
// 更新审批流程
|
||||
updateWorkflow: (id, data) => request.put(`/api/approval/workflows/${id}`, data),
|
||||
|
||||
// 获取审批记录
|
||||
getRecords: (params) => request.get('/api/approval/records', { params }),
|
||||
|
||||
// 提交审批申请
|
||||
submitApproval: (data) => request.post('/api/approval/records', data),
|
||||
|
||||
// 处理审批
|
||||
processApproval: (id, data) => request.put(`/api/approval/records/${id}/process`, data),
|
||||
|
||||
// 获取待办任务
|
||||
getTasks: (params) => request.get('/api/approval/tasks', { params }),
|
||||
|
||||
// 完成任务
|
||||
completeTask: (id, data) => request.put(`/api/approval/tasks/${id}/complete`, data),
|
||||
|
||||
// 转派任务
|
||||
transferTask: (id, data) => request.put(`/api/approval/tasks/${id}/transfer`, data)
|
||||
}
|
||||
|
||||
// 人员管理API
|
||||
export const personnelApi = {
|
||||
// 获取人员数据
|
||||
getPersonnelData: () => request.get('/api/personnel/data'),
|
||||
|
||||
// 获取员工列表
|
||||
getStaff: (params) => request.get('/api/personnel/staff', { params }),
|
||||
|
||||
// 添加员工
|
||||
addStaff: (data) => request.post('/api/personnel/staff', data),
|
||||
|
||||
// 更新员工信息
|
||||
updateStaff: (id, data) => request.put(`/api/personnel/staff/${id}`, data),
|
||||
|
||||
// 删除员工
|
||||
deleteStaff: (id) => request.delete(`/api/personnel/staff/${id}`),
|
||||
|
||||
// 获取部门列表
|
||||
getDepartments: (params) => request.get('/api/personnel/departments', { params }),
|
||||
|
||||
// 添加部门
|
||||
addDepartment: (data) => request.post('/api/personnel/departments', data),
|
||||
|
||||
// 更新部门信息
|
||||
updateDepartment: (id, data) => request.put(`/api/personnel/departments/${id}`, data),
|
||||
|
||||
// 删除部门
|
||||
deleteDepartment: (id) => request.delete(`/api/personnel/departments/${id}`),
|
||||
|
||||
// 获取职位列表
|
||||
getPositions: (params) => request.get('/api/personnel/positions', { params }),
|
||||
|
||||
// 添加职位
|
||||
addPosition: (data) => request.post('/api/personnel/positions', data),
|
||||
|
||||
// 更新职位信息
|
||||
updatePosition: (id, data) => request.put(`/api/personnel/positions/${id}`, data),
|
||||
|
||||
// 获取考勤记录
|
||||
getAttendance: (params) => request.get('/api/personnel/attendance', { params }),
|
||||
|
||||
// 记录考勤
|
||||
recordAttendance: (data) => request.post('/api/personnel/attendance', data),
|
||||
|
||||
// 获取员工详情
|
||||
getStaffDetail: (id) => request.get(`/api/personnel/staff/${id}`),
|
||||
|
||||
// 员工调岗
|
||||
transferStaff: (id, data) => request.put(`/api/personnel/staff/${id}/transfer`, data)
|
||||
}
|
||||
|
||||
// 设备仓库API
|
||||
export const warehouseApi = {
|
||||
// 获取仓库数据
|
||||
getWarehouseData: () => request.get('/api/warehouse/data'),
|
||||
|
||||
// 获取设备列表
|
||||
getEquipment: (params) => request.get('/api/warehouse/equipment', { params }),
|
||||
|
||||
// 添加设备
|
||||
addEquipment: (data) => request.post('/api/warehouse/equipment', data),
|
||||
|
||||
// 更新设备信息
|
||||
updateEquipment: (id, data) => request.put(`/api/warehouse/equipment/${id}`, data),
|
||||
|
||||
// 删除设备
|
||||
deleteEquipment: (id) => request.delete(`/api/warehouse/equipment/${id}`),
|
||||
|
||||
// 设备入库
|
||||
equipmentInbound: (data) => request.post('/api/warehouse/inbound', data),
|
||||
|
||||
// 设备出库
|
||||
equipmentOutbound: (data) => request.post('/api/warehouse/outbound', data),
|
||||
|
||||
// 获取入库记录
|
||||
getInboundRecords: (params) => request.get('/api/warehouse/inbound', { params }),
|
||||
|
||||
// 获取出库记录
|
||||
getOutboundRecords: (params) => request.get('/api/warehouse/outbound', { params }),
|
||||
|
||||
// 获取维护记录
|
||||
getMaintenanceRecords: (params) => request.get('/api/warehouse/maintenance', { params }),
|
||||
|
||||
// 创建维护记录
|
||||
createMaintenanceRecord: (data) => request.post('/api/warehouse/maintenance', data),
|
||||
|
||||
// 更新维护记录
|
||||
updateMaintenanceRecord: (id, data) => request.put(`/api/warehouse/maintenance/${id}`, data),
|
||||
|
||||
// 设备盘点
|
||||
inventoryCheck: (data) => request.post('/api/warehouse/inventory', data),
|
||||
|
||||
// 获取库存报告
|
||||
getInventoryReport: (params) => request.get('/api/warehouse/inventory/report', { params })
|
||||
}
|
||||
|
||||
// 防疫管理API
|
||||
export const epidemicApi = {
|
||||
// 获取防疫数据
|
||||
getEpidemicData: () => request.get('/api/epidemic/data'),
|
||||
|
||||
// 获取疫情案例
|
||||
getCases: (params) => request.get('/api/epidemic/cases', { params }),
|
||||
|
||||
// 添加疫情案例
|
||||
addCase: (data) => request.post('/api/epidemic/cases', data),
|
||||
|
||||
// 更新疫情案例
|
||||
updateCase: (id, data) => request.put(`/api/epidemic/cases/${id}`, data),
|
||||
|
||||
// 获取疫苗接种记录
|
||||
getVaccinations: (params) => request.get('/api/epidemic/vaccinations', { params }),
|
||||
|
||||
// 记录疫苗接种
|
||||
recordVaccination: (data) => request.post('/api/epidemic/vaccinations', data),
|
||||
|
||||
// 获取防疫措施
|
||||
getMeasures: (params) => request.get('/api/epidemic/measures', { params }),
|
||||
|
||||
// 创建防疫措施
|
||||
createMeasure: (data) => request.post('/api/epidemic/measures', data),
|
||||
|
||||
// 更新防疫措施
|
||||
updateMeasure: (id, data) => request.put(`/api/epidemic/measures/${id}`, data),
|
||||
|
||||
// 获取健康码数据
|
||||
getHealthCodes: (params) => request.get('/api/epidemic/health-codes', { params }),
|
||||
|
||||
// 生成健康码
|
||||
generateHealthCode: (data) => request.post('/api/epidemic/health-codes', data),
|
||||
|
||||
// 验证健康码
|
||||
verifyHealthCode: (code) => request.get(`/api/epidemic/health-codes/${code}/verify`),
|
||||
|
||||
// 获取疫情统计
|
||||
getEpidemicStats: (params) => request.get('/api/epidemic/stats', { params }),
|
||||
|
||||
// 获取疫情地图数据
|
||||
getEpidemicMapData: (params) => request.get('/api/epidemic/map', { params })
|
||||
}
|
||||
|
||||
// 服务管理API
|
||||
export const serviceApi = {
|
||||
// 获取服务数据
|
||||
getServiceData: () => request.get('/api/service/data'),
|
||||
|
||||
// 获取服务项目
|
||||
getServices: (params) => request.get('/api/service/services', { params }),
|
||||
|
||||
// 创建服务项目
|
||||
createService: (data) => request.post('/api/service/services', data),
|
||||
|
||||
// 更新服务项目
|
||||
updateService: (id, data) => request.put(`/api/service/services/${id}`, data),
|
||||
|
||||
// 删除服务项目
|
||||
deleteService: (id) => request.delete(`/api/service/services/${id}`),
|
||||
|
||||
// 获取服务申请
|
||||
getApplications: (params) => request.get('/api/service/applications', { params }),
|
||||
|
||||
// 提交服务申请
|
||||
submitApplication: (data) => request.post('/api/service/applications', data),
|
||||
|
||||
// 处理服务申请
|
||||
processApplication: (id, data) => request.put(`/api/service/applications/${id}/process`, data),
|
||||
|
||||
// 获取服务评价
|
||||
getEvaluations: (params) => request.get('/api/service/evaluations', { params }),
|
||||
|
||||
// 提交服务评价
|
||||
submitEvaluation: (data) => request.post('/api/service/evaluations', data),
|
||||
|
||||
// 获取服务指南
|
||||
getGuides: (params) => request.get('/api/service/guides', { params }),
|
||||
|
||||
// 创建服务指南
|
||||
createGuide: (data) => request.post('/api/service/guides', data),
|
||||
|
||||
// 更新服务指南
|
||||
updateGuide: (id, data) => request.put(`/api/service/guides/${id}`, data),
|
||||
|
||||
// 获取服务统计
|
||||
getServiceStats: (params) => request.get('/api/service/stats', { params })
|
||||
}
|
||||
|
||||
// 数据可视化API
|
||||
export const visualizationApi = {
|
||||
// 获取仪表盘数据
|
||||
getDashboardData: () => request.get('/api/visualization/dashboard'),
|
||||
|
||||
// 获取图表数据
|
||||
getChartData: (chartType, params) => request.get(`/api/visualization/charts/${chartType}`, { params }),
|
||||
|
||||
// 获取实时数据
|
||||
getRealTimeData: (dataType) => request.get(`/api/visualization/realtime/${dataType}`),
|
||||
|
||||
// 获取统计报告
|
||||
getStatisticsReport: (params) => request.get('/api/visualization/statistics', { params }),
|
||||
|
||||
// 导出数据
|
||||
exportData: (params) => request.get('/api/visualization/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
}),
|
||||
|
||||
// 获取地图数据
|
||||
getMapData: (params) => request.get('/api/visualization/map', { params }),
|
||||
|
||||
// 获取热力图数据
|
||||
getHeatmapData: (params) => request.get('/api/visualization/heatmap', { params })
|
||||
}
|
||||
|
||||
// 系统管理API
|
||||
export const systemApi = {
|
||||
// 获取系统信息
|
||||
getSystemInfo: () => request.get('/api/system/info'),
|
||||
|
||||
// 获取系统日志
|
||||
getSystemLogs: (params) => request.get('/api/system/logs', { params }),
|
||||
|
||||
// 获取操作日志
|
||||
getOperationLogs: (params) => request.get('/api/system/operation-logs', { params }),
|
||||
|
||||
// 系统备份
|
||||
systemBackup: () => request.post('/api/system/backup'),
|
||||
|
||||
// 系统恢复
|
||||
systemRestore: (data) => request.post('/api/system/restore', data),
|
||||
|
||||
// 清理缓存
|
||||
clearCache: () => request.post('/api/system/clear-cache'),
|
||||
|
||||
// 获取系统配置
|
||||
getSystemConfig: () => request.get('/api/system/config'),
|
||||
|
||||
// 更新系统配置
|
||||
updateSystemConfig: (data) => request.put('/api/system/config', data)
|
||||
}
|
||||
|
||||
// 文件管理API
|
||||
export const fileApi = {
|
||||
// 上传文件
|
||||
uploadFile: (file, onProgress) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request.post('/api/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: onProgress
|
||||
})
|
||||
},
|
||||
|
||||
// 批量上传文件
|
||||
uploadFiles: (files, onProgress) => {
|
||||
const formData = new FormData()
|
||||
files.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return request.post('/api/files/upload/batch', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: onProgress
|
||||
})
|
||||
},
|
||||
|
||||
// 删除文件
|
||||
deleteFile: (fileId) => request.delete(`/api/files/${fileId}`),
|
||||
|
||||
// 获取文件列表
|
||||
getFiles: (params) => request.get('/api/files', { params }),
|
||||
|
||||
// 下载文件
|
||||
downloadFile: (fileId) => request.get(`/api/files/${fileId}/download`, {
|
||||
responseType: 'blob'
|
||||
}),
|
||||
|
||||
// 获取文件信息
|
||||
getFileInfo: (fileId) => request.get(`/api/files/${fileId}`)
|
||||
}
|
||||
|
||||
// 统一导出政府业务API
|
||||
export const governmentApi = {
|
||||
// 获取所有模块数据
|
||||
getSupervisionData: supervisionApi.getSupervisionData,
|
||||
getApprovalData: approvalApi.getApprovalData,
|
||||
getPersonnelData: personnelApi.getPersonnelData,
|
||||
getWarehouseData: warehouseApi.getWarehouseData,
|
||||
getEpidemicData: epidemicApi.getEpidemicData,
|
||||
getServiceData: serviceApi.getServiceData,
|
||||
|
||||
// 常用操作
|
||||
submitApproval: approvalApi.submitApproval,
|
||||
processApproval: approvalApi.processApproval,
|
||||
addEquipment: warehouseApi.addEquipment,
|
||||
equipmentInbound: warehouseApi.equipmentInbound,
|
||||
equipmentOutbound: warehouseApi.equipmentOutbound,
|
||||
addStaff: personnelApi.addStaff,
|
||||
updateStaff: personnelApi.updateStaff,
|
||||
|
||||
// 子模块API
|
||||
supervision: supervisionApi,
|
||||
approval: approvalApi,
|
||||
personnel: personnelApi,
|
||||
warehouse: warehouseApi,
|
||||
epidemic: epidemicApi,
|
||||
service: serviceApi,
|
||||
visualization: visualizationApi,
|
||||
system: systemApi,
|
||||
file: fileApi
|
||||
}
|
||||
|
||||
export default governmentApi
|
||||
5
government-admin/src/assets/images/favicon.svg
Normal file
5
government-admin/src/assets/images/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#1890ff"/>
|
||||
<circle cx="50" cy="50" r="30" fill="white"/>
|
||||
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
76
government-admin/src/components/PageHeader.vue
Normal file
76
government-admin/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div class="page-header-main">
|
||||
<div class="page-header-title">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="description" class="page-header-description">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="page-header-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="page-header-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.page-header-content {
|
||||
.page-header-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.page-header-title {
|
||||
flex: 1;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.page-header-description {
|
||||
margin: 4px 0 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
flex-shrink: 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
government-admin/src/components/PermissionButton.vue
Normal file
89
government-admin/src/components/PermissionButton.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<a-button
|
||||
v-if="hasPermission"
|
||||
v-bind="$attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
|
||||
const props = defineProps({
|
||||
// 权限码
|
||||
permission: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 角色
|
||||
role: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 权限列表(任一权限)
|
||||
permissions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 是否需要全部权限
|
||||
requireAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 角色列表
|
||||
roles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// 检查是否有权限
|
||||
const hasPermission = computed(() => {
|
||||
// 如果没有设置任何权限要求,默认显示
|
||||
if (!props.permission && !props.role && props.permissions.length === 0 && props.roles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查单个权限
|
||||
if (props.permission) {
|
||||
return permissionStore.hasPermission(props.permission)
|
||||
}
|
||||
|
||||
// 检查单个角色
|
||||
if (props.role) {
|
||||
return permissionStore.hasRole(props.role)
|
||||
}
|
||||
|
||||
// 检查权限列表
|
||||
if (props.permissions.length > 0) {
|
||||
return props.requireAll
|
||||
? permissionStore.hasAllPermissions(props.permissions)
|
||||
: permissionStore.hasAnyPermission(props.permissions)
|
||||
}
|
||||
|
||||
// 检查角色列表
|
||||
if (props.roles.length > 0) {
|
||||
return props.roles.some(role => permissionStore.hasRole(role))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const handleClick = (event) => {
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PermissionButton',
|
||||
inheritAttrs: false
|
||||
}
|
||||
</script>
|
||||
196
government-admin/src/components/charts/BarChart.vue
Normal file
196
government-admin/src/components/charts/BarChart.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="bar-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
xAxisData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
|
||||
},
|
||||
horizontal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
stack: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
grid: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
top: '10%',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const series = props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'bar',
|
||||
data: item.data,
|
||||
stack: props.stack,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: props.color[index % props.color.length] },
|
||||
{ offset: 1, color: echarts.color.lift(props.color[index % props.color.length], -0.3) }
|
||||
]),
|
||||
borderRadius: props.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: props.color[index % props.color.length]
|
||||
}
|
||||
},
|
||||
barWidth: '60%'
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map(item => item.name),
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
grid: props.grid,
|
||||
xAxis: {
|
||||
type: props.horizontal ? 'value' : 'category',
|
||||
data: props.horizontal ? null : props.xAxisData,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666',
|
||||
rotate: props.horizontal ? 0 : (props.xAxisData.length > 6 ? 45 : 0)
|
||||
},
|
||||
splitLine: props.horizontal ? {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
} : null
|
||||
},
|
||||
yAxis: {
|
||||
type: props.horizontal ? 'category' : 'value',
|
||||
data: props.horizontal ? props.xAxisData : null,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
},
|
||||
splitLine: props.horizontal ? null : {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.xAxisData], () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
205
government-admin/src/components/charts/GaugeChart.vue
Normal file
205
government-admin/src/components/charts/GaugeChart.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="gauge-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: '%'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
[0.2, '#67e0e3'],
|
||||
[0.8, '#37a2da'],
|
||||
[1, '#fd666d']
|
||||
]
|
||||
},
|
||||
radius: {
|
||||
type: String,
|
||||
default: '75%'
|
||||
},
|
||||
center: {
|
||||
type: Array,
|
||||
default: () => ['50%', '60%']
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
formatter: '{a} <br/>{b} : {c}' + props.unit
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title || '指标',
|
||||
type: 'gauge',
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
radius: props.radius,
|
||||
center: props.center,
|
||||
splitNumber: 10,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: props.color,
|
||||
width: 20,
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
length: 15,
|
||||
lineStyle: {
|
||||
color: 'auto',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
length: 25,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 5
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
fontSize: 20,
|
||||
fontStyle: 'italic',
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
backgroundColor: 'rgba(30,144,255,0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 5,
|
||||
offsetCenter: [0, '50%'],
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
color: '#fff'
|
||||
},
|
||||
formatter: function(value) {
|
||||
return value + props.unit
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.value,
|
||||
name: props.title || '完成度'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.value, props.max, props.min], () => {
|
||||
updateChart()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gauge-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
200
government-admin/src/components/charts/LineChart.vue
Normal file
200
government-admin/src/components/charts/LineChart.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="line-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
xAxisData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
|
||||
},
|
||||
smooth: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showArea: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSymbol: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
grid: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
top: '10%',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const series = props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.data,
|
||||
smooth: props.smooth,
|
||||
symbol: props.showSymbol ? 'circle' : 'none',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.color[index % props.color.length]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.color[index % props.color.length]
|
||||
},
|
||||
areaStyle: props.showArea ? {
|
||||
opacity: 0.3,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: props.color[index % props.color.length] },
|
||||
{ offset: 1, color: 'rgba(255, 255, 255, 0)' }
|
||||
])
|
||||
} : null
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map(item => item.name),
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
grid: props.grid,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.xAxisData], () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
185
government-admin/src/components/charts/MapChart.vue
Normal file
185
government-admin/src/components/charts/MapChart.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="map-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
mapName: {
|
||||
type: String,
|
||||
default: 'china'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#313695', '#4575b4', '#74add1', '#abd9e9', '#e0f3f8', '#ffffcc', '#fee090', '#fdae61', '#f46d43', '#d73027', '#a50026']
|
||||
},
|
||||
visualMapMin: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
visualMapMax: {
|
||||
type: Number,
|
||||
default: 1000
|
||||
},
|
||||
roam: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = async () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
|
||||
// 注册地图(这里需要根据实际情况加载地图数据)
|
||||
// 示例:加载中国地图数据
|
||||
try {
|
||||
// 这里应该加载实际的地图JSON数据
|
||||
// const mapData = await import('@/assets/maps/china.json')
|
||||
// echarts.registerMap(props.mapName, mapData.default)
|
||||
|
||||
// 临时使用内置地图
|
||||
updateChart()
|
||||
} catch (error) {
|
||||
console.warn('地图数据加载失败,使用默认配置')
|
||||
updateChart()
|
||||
}
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(params) {
|
||||
if (params.data) {
|
||||
return `${params.name}<br/>${params.seriesName}: ${params.data.value}`
|
||||
}
|
||||
return `${params.name}<br/>暂无数据`
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
min: props.visualMapMin,
|
||||
max: props.visualMapMax,
|
||||
left: 'left',
|
||||
top: 'bottom',
|
||||
text: ['高', '低'],
|
||||
inRange: {
|
||||
color: props.color
|
||||
},
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title || '数据分布',
|
||||
type: 'map',
|
||||
map: props.mapName,
|
||||
roam: props.roam,
|
||||
data: props.data,
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff'
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#389BB7',
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
areaColor: '#eee'
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 12,
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
179
government-admin/src/components/charts/PieChart.vue
Normal file
179
government-admin/src/components/charts/PieChart.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="pie-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
|
||||
},
|
||||
radius: {
|
||||
type: Array,
|
||||
default: () => ['40%', '70%']
|
||||
},
|
||||
center: {
|
||||
type: Array,
|
||||
default: () => ['50%', '50%']
|
||||
},
|
||||
roseType: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showLabelLine: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
},
|
||||
formatter: function(name) {
|
||||
const item = props.data.find(d => d.name === name)
|
||||
return item ? `${name}: ${item.value}` : name
|
||||
}
|
||||
},
|
||||
color: props.color,
|
||||
series: [
|
||||
{
|
||||
name: props.title || '数据统计',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
center: props.center,
|
||||
roseType: props.roseType,
|
||||
data: props.data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
show: props.showLabel,
|
||||
position: 'outside',
|
||||
formatter: '{b}: {d}%',
|
||||
fontSize: 12,
|
||||
color: '#666'
|
||||
},
|
||||
labelLine: {
|
||||
show: props.showLabelLine,
|
||||
length: 15,
|
||||
length2: 10,
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pie-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
22
government-admin/src/components/charts/index.js
Normal file
22
government-admin/src/components/charts/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// 图表组件统一导出
|
||||
import LineChart from './LineChart.vue'
|
||||
import BarChart from './BarChart.vue'
|
||||
import PieChart from './PieChart.vue'
|
||||
import GaugeChart from './GaugeChart.vue'
|
||||
import MapChart from './MapChart.vue'
|
||||
|
||||
export {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
GaugeChart,
|
||||
MapChart
|
||||
}
|
||||
|
||||
export default {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
GaugeChart,
|
||||
MapChart
|
||||
}
|
||||
272
government-admin/src/components/common/DataTable.vue
Normal file
272
government-admin/src/components/common/DataTable.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="data-table">
|
||||
<div v-if="showToolbar" class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<slot name="toolbar-left">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="showAdd"
|
||||
type="primary"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
<PlusOutlined />
|
||||
{{ addText || '新增' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="showBatchDelete && selectedRowKeys.length > 0"
|
||||
danger
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<slot name="toolbar-right">
|
||||
<a-space>
|
||||
<a-tooltip title="刷新">
|
||||
<a-button @click="$emit('refresh')">
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="列设置">
|
||||
<a-button @click="showColumnSetting = true">
|
||||
<SettingOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="visibleColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="paginationConfig"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="scroll"
|
||||
:size="size"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData"></slot>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 列设置弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showColumnSetting"
|
||||
title="列设置"
|
||||
@ok="handleColumnSettingOk"
|
||||
>
|
||||
<a-checkbox-group v-model:value="selectedColumns" class="column-setting">
|
||||
<div v-for="column in columns" :key="column.key || column.dataIndex" class="column-item">
|
||||
<a-checkbox :value="column.key || column.dataIndex">
|
||||
{{ column.title }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
dataSource: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
pagination: {
|
||||
type: [Object, Boolean],
|
||||
default: () => ({})
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
addText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showBatchDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
scroll: {
|
||||
type: Object,
|
||||
default: () => ({ x: 'max-content' })
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'middle'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add',
|
||||
'refresh',
|
||||
'change',
|
||||
'batchDelete',
|
||||
'selectionChange'
|
||||
])
|
||||
|
||||
const selectedRowKeys = ref([])
|
||||
const showColumnSetting = ref(false)
|
||||
const selectedColumns = ref([])
|
||||
|
||||
// 初始化选中的列
|
||||
const initSelectedColumns = () => {
|
||||
selectedColumns.value = props.columns
|
||||
.filter(col => col.key || col.dataIndex)
|
||||
.map(col => col.key || col.dataIndex)
|
||||
}
|
||||
|
||||
// 可见的列
|
||||
const visibleColumns = computed(() => {
|
||||
return props.columns.filter(col => {
|
||||
const key = col.key || col.dataIndex
|
||||
return !key || selectedColumns.value.includes(key)
|
||||
})
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = computed(() => {
|
||||
if (!props.showBatchDelete) return null
|
||||
|
||||
return {
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys, rows) => {
|
||||
selectedRowKeys.value = keys
|
||||
emit('selectionChange', keys, rows)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = computed(() => {
|
||||
if (props.pagination === false) return false
|
||||
|
||||
return {
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
...props.pagination
|
||||
}
|
||||
})
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
emit('change', { pagination, filters, sorter })
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请选择要删除的数据')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条数据吗?`,
|
||||
onOk: () => {
|
||||
emit('batchDelete', selectedRowKeys.value)
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 列设置确认
|
||||
const handleColumnSettingOk = () => {
|
||||
showColumnSetting.value = false
|
||||
message.success('列设置已保存')
|
||||
}
|
||||
|
||||
// 监听列变化,重新初始化选中的列
|
||||
watch(
|
||||
() => props.columns,
|
||||
() => {
|
||||
initSelectedColumns()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data-table {
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.column-setting {
|
||||
.column-item {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.data-table {
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
government-admin/src/components/common/EmptyState.vue
Normal file
80
government-admin/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<component v-if="icon" :is="icon" />
|
||||
<InboxOutlined v-else />
|
||||
</div>
|
||||
<div class="empty-title">{{ title || '暂无数据' }}</div>
|
||||
<div v-if="description" class="empty-description">{{ description }}</div>
|
||||
<div v-if="showAction" class="empty-action">
|
||||
<a-button type="primary" @click="$emit('action')">
|
||||
{{ actionText || '重新加载' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { InboxOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
government-admin/src/components/common/LoadingSpinner.vue
Normal file
83
government-admin/src/components/common/LoadingSpinner.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="loading-spinner" :class="{ 'full-screen': fullScreen }">
|
||||
<div class="spinner-container">
|
||||
<div class="spinner" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<div class="spinner-inner"></div>
|
||||
</div>
|
||||
<div v-if="text" class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fullScreen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
|
||||
&.full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.spinner {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, #1890ff, #40a9ff, #69c0ff, #91d5ff, transparent);
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
.spinner-inner {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
106
government-admin/src/components/common/PageHeader.vue
Normal file
106
government-admin/src/components/common/PageHeader.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-title">
|
||||
<component v-if="icon" :is="icon" class="title-icon" />
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="description" class="header-description">{{ description }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="header-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.tabs" class="header-tabs">
|
||||
<slot name="tabs"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px;
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.title-icon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.header-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.header-extra {
|
||||
flex-shrink: 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
padding: 0 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.header-extra {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
210
government-admin/src/components/common/SearchForm.vue
Normal file
210
government-admin/src/components/common/SearchForm.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="search-form">
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
@reset="handleReset"
|
||||
>
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<!-- 输入框 -->
|
||||
<a-form-item
|
||||
v-if="field.type === 'input'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 选择框 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'select'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请选择${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'date'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-date-picker
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请选择${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 日期范围选择 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-range-picker
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || ['开始日期', '结束日期']"
|
||||
:style="{ width: field.width || '300px' }"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
<SearchOutlined />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button html-type="reset">
|
||||
<ReloadOutlined />
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="showToggle && fields.length > 3"
|
||||
type="link"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ expanded ? '收起' : '展开' }}
|
||||
<UpOutlined v-if="expanded" />
|
||||
<DownOutlined v-else />
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { SearchOutlined, ReloadOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showToggle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
initialValues: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'reset'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const formData = reactive({})
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
props.fields.forEach(field => {
|
||||
formData[field.key] = props.initialValues[field.key] || field.defaultValue || undefined
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const searchData = { ...formData }
|
||||
// 过滤空值
|
||||
Object.keys(searchData).forEach(key => {
|
||||
if (searchData[key] === undefined || searchData[key] === null || searchData[key] === '') {
|
||||
delete searchData[key]
|
||||
}
|
||||
})
|
||||
emit('search', searchData)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
props.fields.forEach(field => {
|
||||
formData[field.key] = field.defaultValue || undefined
|
||||
})
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
// 监听初始值变化
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
() => {
|
||||
initFormData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 监听字段变化
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
initFormData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-form {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.search-form {
|
||||
padding: 16px;
|
||||
|
||||
:deep(.ant-form) {
|
||||
.ant-form-item {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-form-item-control-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
551
government-admin/src/components/common/TabsView.vue
Normal file
551
government-admin/src/components/common/TabsView.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div class="tabs-view">
|
||||
<!-- 标签页导航 -->
|
||||
<div class="tabs-nav" ref="tabsNavRef">
|
||||
<div class="tabs-nav-scroll" :style="{ transform: `translateX(${scrollOffset}px)` }">
|
||||
<div
|
||||
v-for="tab in tabsStore.openTabs"
|
||||
:key="tab.path"
|
||||
:class="[
|
||||
'tab-item',
|
||||
{ 'active': tab.active },
|
||||
{ 'closable': tab.closable }
|
||||
]"
|
||||
@click="handleTabClick(tab)"
|
||||
@contextmenu.prevent="handleTabContextMenu(tab, $event)"
|
||||
>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<CloseOutlined
|
||||
v-if="tab.closable"
|
||||
class="tab-close"
|
||||
@click.stop="handleTabClose(tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动控制按钮 -->
|
||||
<div class="tabs-nav-controls">
|
||||
<LeftOutlined
|
||||
:class="['nav-btn', { 'disabled': scrollOffset >= 0 }]"
|
||||
@click="scrollTabs('left')"
|
||||
/>
|
||||
<RightOutlined
|
||||
:class="['nav-btn', { 'disabled': scrollOffset <= maxScrollOffset }]"
|
||||
@click="scrollTabs('right')"
|
||||
/>
|
||||
<MoreOutlined
|
||||
class="nav-btn"
|
||||
@click="showTabsMenu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页内容 -->
|
||||
<div class="tabs-content">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="tabsStore.cachedViews">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="Component"
|
||||
/>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
:class="['context-menu']"
|
||||
:style="{
|
||||
left: contextMenu.x + 'px',
|
||||
top: contextMenu.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div class="menu-item" @click="refreshTab(contextMenu.tab)">
|
||||
<ReloadOutlined />
|
||||
刷新页面
|
||||
</div>
|
||||
<div
|
||||
v-if="contextMenu.tab.closable"
|
||||
class="menu-item"
|
||||
@click="closeTab(contextMenu.tab)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
关闭标签
|
||||
</div>
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-item" @click="closeOtherTabs(contextMenu.tab)">
|
||||
<CloseCircleOutlined />
|
||||
关闭其他
|
||||
</div>
|
||||
<div class="menu-item" @click="closeLeftTabs(contextMenu.tab)">
|
||||
<VerticalLeftOutlined />
|
||||
关闭左侧
|
||||
</div>
|
||||
<div class="menu-item" @click="closeRightTabs(contextMenu.tab)">
|
||||
<VerticalRightOutlined />
|
||||
关闭右侧
|
||||
</div>
|
||||
<div class="menu-item" @click="closeAllTabs">
|
||||
<CloseSquareOutlined />
|
||||
关闭全部
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页列表菜单 -->
|
||||
<a-dropdown
|
||||
v-model:open="tabsMenuVisible"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-for="tab in tabsStore.openTabs"
|
||||
:key="tab.path"
|
||||
@click="handleTabClick(tab)"
|
||||
>
|
||||
<span :class="{ 'active-tab': tab.active }">
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 遮罩层,用于关闭右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu-overlay"
|
||||
@click="hideContextMenu"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import {
|
||||
CloseOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
MoreOutlined,
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined,
|
||||
CloseSquareOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
// 标签页导航引用
|
||||
const tabsNavRef = ref(null)
|
||||
|
||||
// 滚动偏移量
|
||||
const scrollOffset = ref(0)
|
||||
|
||||
// 最大滚动偏移量
|
||||
const maxScrollOffset = computed(() => {
|
||||
if (!tabsNavRef.value) return 0
|
||||
const navWidth = tabsNavRef.value.clientWidth - 120 // 减去控制按钮宽度
|
||||
const scrollWidth = tabsNavRef.value.querySelector('.tabs-nav-scroll')?.scrollWidth || 0
|
||||
return Math.min(0, navWidth - scrollWidth)
|
||||
})
|
||||
|
||||
// 右键菜单状态
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tab: null
|
||||
})
|
||||
|
||||
// 标签页菜单显示状态
|
||||
const tabsMenuVisible = ref(false)
|
||||
|
||||
/**
|
||||
* 处理标签页点击
|
||||
*/
|
||||
const handleTabClick = (tab) => {
|
||||
if (tab.path !== route.path) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
tabsStore.setActiveTab(tab.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签页关闭
|
||||
*/
|
||||
const handleTabClose = (tab) => {
|
||||
if (!tab.closable) return
|
||||
|
||||
tabsStore.removeTab(tab.path)
|
||||
|
||||
// 如果关闭的是当前标签页,跳转到其他标签页
|
||||
if (tab.active && tabsStore.openTabs.length > 0) {
|
||||
const activeTab = tabsStore.openTabs.find(t => t.active)
|
||||
if (activeTab) {
|
||||
router.push(activeTab.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签页右键菜单
|
||||
*/
|
||||
const handleTabContextMenu = (tab, event) => {
|
||||
contextMenu.visible = true
|
||||
contextMenu.x = event.clientX
|
||||
contextMenu.y = event.clientY
|
||||
contextMenu.tab = tab
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏右键菜单
|
||||
*/
|
||||
const hideContextMenu = () => {
|
||||
contextMenu.visible = false
|
||||
contextMenu.tab = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新标签页
|
||||
*/
|
||||
const refreshTab = (tab) => {
|
||||
tabsStore.refreshTab(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭标签页
|
||||
*/
|
||||
const closeTab = (tab) => {
|
||||
handleTabClose(tab)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭其他标签页
|
||||
*/
|
||||
const closeOtherTabs = (tab) => {
|
||||
tabsStore.closeOtherTabs(tab.path)
|
||||
if (tab.path !== route.path) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭左侧标签页
|
||||
*/
|
||||
const closeLeftTabs = (tab) => {
|
||||
tabsStore.closeLeftTabs(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭右侧标签页
|
||||
*/
|
||||
const closeRightTabs = (tab) => {
|
||||
tabsStore.closeRightTabs(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有标签页
|
||||
*/
|
||||
const closeAllTabs = () => {
|
||||
tabsStore.closeAllTabs()
|
||||
const activeTab = tabsStore.openTabs[0]
|
||||
if (activeTab && activeTab.path !== route.path) {
|
||||
router.push(activeTab.path)
|
||||
}
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动标签页
|
||||
*/
|
||||
const scrollTabs = (direction) => {
|
||||
const step = 200
|
||||
if (direction === 'left') {
|
||||
scrollOffset.value = Math.min(0, scrollOffset.value + step)
|
||||
} else {
|
||||
scrollOffset.value = Math.max(maxScrollOffset.value, scrollOffset.value - step)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示标签页菜单
|
||||
*/
|
||||
const showTabsMenu = () => {
|
||||
tabsMenuVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听路由变化,添加标签页
|
||||
*/
|
||||
const addCurrentRouteTab = () => {
|
||||
const { path, meta, name } = route
|
||||
|
||||
if (meta.hidden) return
|
||||
|
||||
const tab = {
|
||||
path,
|
||||
title: meta.title || name || '未命名页面',
|
||||
name: name,
|
||||
closable: meta.closable !== false
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听点击事件,关闭右键菜单
|
||||
*/
|
||||
const handleDocumentClick = () => {
|
||||
if (contextMenu.visible) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听窗口大小变化,调整滚动偏移量
|
||||
*/
|
||||
const handleWindowResize = () => {
|
||||
nextTick(() => {
|
||||
if (scrollOffset.value < maxScrollOffset.value) {
|
||||
scrollOffset.value = maxScrollOffset.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 添加当前路由标签页
|
||||
addCurrentRouteTab()
|
||||
|
||||
// 监听文档点击事件
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach((to) => {
|
||||
if (!to.meta.hidden) {
|
||||
const tab = {
|
||||
path: to.path,
|
||||
title: to.meta.title || to.name || '未命名页面',
|
||||
name: to.name,
|
||||
closable: to.meta.closable !== false
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.tabs-nav-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
transition: transform 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
margin: 4px 2px;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
max-width: 200px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #fff;
|
||||
|
||||
.tab-close {
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 8px;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 120px;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.active-tab {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.tabs-nav {
|
||||
.tab-item {
|
||||
min-width: 60px;
|
||||
max-width: 120px;
|
||||
padding: 0 8px;
|
||||
|
||||
.tab-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
min-width: 100px;
|
||||
|
||||
.menu-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
312
government-admin/src/components/layout/SidebarMenu.vue
Normal file
312
government-admin/src/components/layout/SidebarMenu.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="sidebar-menu">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<a-menu-item
|
||||
v-if="!item.children"
|
||||
:key="item.key"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
<span>{{ item.title }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-sub-menu
|
||||
v-else
|
||||
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<!-- :key="item.key" -->
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
:disabled="child.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="child.icon" />
|
||||
</template>
|
||||
<span>{{ child.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
HomeOutlined,
|
||||
MonitorOutlined,
|
||||
AuditOutlined,
|
||||
LinkOutlined,
|
||||
AlertOutlined,
|
||||
FileTextOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
SafetyOutlined,
|
||||
TeamOutlined,
|
||||
DatabaseOutlined,
|
||||
KeyOutlined,
|
||||
SolutionOutlined,
|
||||
MedicineBoxOutlined,
|
||||
CustomerServiceOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
// 菜单配置
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
key: '/dashboard',
|
||||
title: '仪表盘',
|
||||
icon: DashboardOutlined,
|
||||
path: '/dashboard'
|
||||
},
|
||||
{
|
||||
key: '/breeding',
|
||||
title: '养殖管理',
|
||||
icon: HomeOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/breeding/farms',
|
||||
title: '养殖场管理',
|
||||
icon: HomeOutlined,
|
||||
path: '/breeding/farms'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/monitoring',
|
||||
title: '健康监控',
|
||||
icon: MonitorOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/monitoring/health',
|
||||
title: '动物健康监控',
|
||||
icon: SafetyOutlined,
|
||||
path: '/monitoring/health'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/inspection',
|
||||
title: '检查管理',
|
||||
icon: AuditOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/inspection/management',
|
||||
title: '检查管理',
|
||||
icon: AuditOutlined,
|
||||
path: '/inspection/management'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/traceability',
|
||||
title: '溯源系统',
|
||||
icon: LinkOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/traceability/system',
|
||||
title: '产品溯源',
|
||||
icon: LinkOutlined,
|
||||
path: '/traceability/system'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/emergency',
|
||||
title: '应急响应',
|
||||
icon: AlertOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/emergency/response',
|
||||
title: '应急响应',
|
||||
icon: AlertOutlined,
|
||||
path: '/emergency/response'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/policy',
|
||||
title: '政策管理',
|
||||
icon: FileTextOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/policy/management',
|
||||
title: '政策管理',
|
||||
icon: FileTextOutlined,
|
||||
path: '/policy/management'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/statistics',
|
||||
title: '数据统计',
|
||||
icon: BarChartOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/statistics/data',
|
||||
title: '数据统计',
|
||||
icon: BarChartOutlined,
|
||||
path: '/statistics/data'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
title: '报表中心',
|
||||
icon: FileTextOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/reports/center',
|
||||
title: '报表中心',
|
||||
icon: FileTextOutlined,
|
||||
path: '/reports/center'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
title: '系统设置',
|
||||
icon: SettingOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/settings/system',
|
||||
title: '系统设置',
|
||||
icon: SettingOutlined,
|
||||
path: '/settings/system'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const findMenuItem = (items, targetKey) => {
|
||||
for (const item of items) {
|
||||
if (item.key === targetKey) {
|
||||
return item
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, targetKey)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const menuItem = findMenuItem(menuItems.value, key)
|
||||
if (menuItem && menuItem.path) {
|
||||
router.push(menuItem.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前路由设置选中状态
|
||||
const updateSelectedKeys = () => {
|
||||
const currentPath = route.path
|
||||
selectedKeys.value = [currentPath]
|
||||
|
||||
// 自动展开父级菜单
|
||||
const findParentKey = (items, targetPath, parentKey = null) => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
if (child.path === targetPath) {
|
||||
return item.key
|
||||
}
|
||||
}
|
||||
const found = findParentKey(item.children, targetPath, item.key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return parentKey
|
||||
}
|
||||
|
||||
const parentKey = findParentKey(menuItems.value, currentPath)
|
||||
if (parentKey && !openKeys.value.includes(parentKey)) {
|
||||
openKeys.value = [...openKeys.value, parentKey]
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(route, updateSelectedKeys, { immediate: true })
|
||||
|
||||
// 监听折叠状态变化
|
||||
watch(() => props.collapsed, (collapsed) => {
|
||||
if (collapsed) {
|
||||
openKeys.value = []
|
||||
} else {
|
||||
updateSelectedKeys()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-menu {
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: #1890ff !important;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu-selected {
|
||||
.ant-menu-submenu-title {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-icon,
|
||||
.ant-menu-submenu-title .ant-menu-item-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
271
government-admin/src/components/layout/TabsView.vue
Normal file
271
government-admin/src/components/layout/TabsView.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="tabs-view">
|
||||
<a-tabs
|
||||
v-model:activeKey="activeKey"
|
||||
type="editable-card"
|
||||
hide-add
|
||||
@edit="onEdit"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:tab="tab.title"
|
||||
:closable="tab.closable"
|
||||
>
|
||||
<template #tab>
|
||||
<span class="tab-title">
|
||||
<component v-if="tab.icon" :is="tab.icon" class="tab-icon" />
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<a-dropdown
|
||||
v-model:open="contextMenuVisible"
|
||||
:trigger="['contextmenu']"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<div ref="contextMenuTarget" class="context-menu-target"></div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleContextMenu">
|
||||
<a-menu-item key="refresh">
|
||||
<ReloadOutlined />
|
||||
刷新页面
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="close">
|
||||
<CloseOutlined />
|
||||
关闭标签
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<CloseCircleOutlined />
|
||||
关闭其他
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<CloseSquareOutlined />
|
||||
关闭全部
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="closeLeft">
|
||||
<VerticalLeftOutlined />
|
||||
关闭左侧
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeRight">
|
||||
<VerticalRightOutlined />
|
||||
关闭右侧
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
CloseCircleOutlined,
|
||||
CloseSquareOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
const activeKey = ref('')
|
||||
const contextMenuVisible = ref(false)
|
||||
const contextMenuTarget = ref(null)
|
||||
const currentContextTab = ref(null)
|
||||
|
||||
// 标签页列表
|
||||
const tabs = computed(() => tabsStore.tabs)
|
||||
|
||||
// 处理标签页变化
|
||||
const onChange = (key) => {
|
||||
const tab = tabs.value.find(t => t.key === key)
|
||||
if (tab) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理标签页编辑(关闭)
|
||||
const onEdit = (targetKey, action) => {
|
||||
if (action === 'remove') {
|
||||
closeTab(targetKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = (key) => {
|
||||
const tab = tabs.value.find(t => t.key === key)
|
||||
if (tab && tab.closable) {
|
||||
tabsStore.removeTab(key)
|
||||
|
||||
// 如果关闭的是当前标签,跳转到最后一个标签
|
||||
if (key === activeKey.value && tabs.value.length > 0) {
|
||||
const lastTab = tabs.value[tabs.value.length - 1]
|
||||
router.push(lastTab.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右键菜单处理
|
||||
const handleContextMenu = ({ key }) => {
|
||||
const currentTab = currentContextTab.value
|
||||
if (!currentTab) return
|
||||
|
||||
switch (key) {
|
||||
case 'refresh':
|
||||
// 刷新当前页面
|
||||
router.go(0)
|
||||
break
|
||||
case 'close':
|
||||
closeTab(currentTab.key)
|
||||
break
|
||||
case 'closeOthers':
|
||||
tabsStore.closeOtherTabs(currentTab.key)
|
||||
break
|
||||
case 'closeAll':
|
||||
tabsStore.closeAllTabs()
|
||||
router.push('/dashboard')
|
||||
break
|
||||
case 'closeLeft':
|
||||
tabsStore.closeLeftTabs(currentTab.key)
|
||||
break
|
||||
case 'closeRight':
|
||||
tabsStore.closeRightTabs(currentTab.key)
|
||||
break
|
||||
}
|
||||
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化,添加标签页
|
||||
watch(route, (newRoute) => {
|
||||
if (newRoute.meta && newRoute.meta.title) {
|
||||
const tab = {
|
||||
key: newRoute.path,
|
||||
path: newRoute.path,
|
||||
title: newRoute.meta.title,
|
||||
icon: newRoute.meta.icon,
|
||||
closable: !newRoute.meta.affix
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
activeKey.value = newRoute.path
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听标签页变化
|
||||
watch(tabs, (newTabs) => {
|
||||
if (newTabs.length === 0) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 添加右键菜单事件监听
|
||||
const addContextMenuListener = () => {
|
||||
nextTick(() => {
|
||||
const tabsContainer = document.querySelector('.ant-tabs-nav')
|
||||
if (tabsContainer) {
|
||||
tabsContainer.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 查找被右键点击的标签
|
||||
const tabElement = e.target.closest('.ant-tabs-tab')
|
||||
if (tabElement) {
|
||||
const tabKey = tabElement.getAttribute('data-node-key')
|
||||
const tab = tabs.value.find(t => t.key === tabKey)
|
||||
if (tab) {
|
||||
currentContextTab.value = tab
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载后添加事件监听
|
||||
watch(tabs, addContextMenuListener, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-view {
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
:deep(.ant-tabs) {
|
||||
.ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-right: 4px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
|
||||
.tab-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-remove {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-target {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
880
government-admin/src/layout/GovernmentLayout.vue
Normal file
880
government-admin/src/layout/GovernmentLayout.vue
Normal file
@@ -0,0 +1,880 @@
|
||||
<template>
|
||||
<div class="government-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="layout-header">
|
||||
<div class="header-left">
|
||||
<div class="logo">
|
||||
<img src="/logo.svg" alt="政府管理后台" />
|
||||
<span class="logo-text">政府管理后台</span>
|
||||
</div>
|
||||
<div class="header-menu">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedMenuKeys"
|
||||
mode="horizontal"
|
||||
:items="headerMenuItems"
|
||||
@click="handleHeaderMenuClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 通知中心 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<div class="header-action">
|
||||
<a-badge :count="notificationStore.unreadCount" :offset="[10, 0]">
|
||||
<BellOutlined />
|
||||
</a-badge>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<div class="notification-dropdown">
|
||||
<div class="notification-header">
|
||||
<span>通知中心</span>
|
||||
<a @click="notificationStore.markAllAsRead()">全部已读</a>
|
||||
</div>
|
||||
<div class="notification-list">
|
||||
<div
|
||||
v-for="notification in notificationStore.recentNotifications.slice(0, 5)"
|
||||
:key="notification.id"
|
||||
:class="['notification-item', { 'unread': !notification.read }]"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-desc">{{ notification.content }}</div>
|
||||
<div class="notification-time">{{ formatTime(notification.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-footer">
|
||||
<a @click="$router.push('/notifications')">查看全部</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<div class="header-action user-info">
|
||||
<a-avatar :src="userStore.userInfo.avatar" :size="32">
|
||||
{{ userStore.userInfo.name?.charAt(0) }}
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ userStore.userInfo.name }}</span>
|
||||
<DownOutlined />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleUserMenuClick">
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings">
|
||||
<SettingOutlined />
|
||||
系统设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主体内容区域 -->
|
||||
<div class="layout-content">
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['layout-sider', { 'collapsed': siderCollapsed }]">
|
||||
<div class="sider-trigger" @click="toggleSider">
|
||||
<MenuUnfoldOutlined v-if="siderCollapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedSiderKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
:inline-collapsed="siderCollapsed"
|
||||
:items="siderMenuItems"
|
||||
@click="handleSiderMenuClick"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<main class="layout-main">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb-container">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
|
||||
<router-link v-if="item.path && item.path !== $route.path" :to="item.path">
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
|
||||
<!-- 标签页视图 -->
|
||||
<TabsView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 通知抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="notificationDrawerVisible"
|
||||
title="系统通知"
|
||||
placement="right"
|
||||
:width="400"
|
||||
>
|
||||
<div class="notification-drawer">
|
||||
<div class="notification-filters">
|
||||
<a-radio-group v-model:value="notificationFilter" size="small">
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
<a-radio-button value="unread">未读</a-radio-button>
|
||||
<a-radio-button value="system">系统</a-radio-button>
|
||||
<a-radio-button value="task">任务</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="notification-list">
|
||||
<div
|
||||
v-for="notification in filteredNotifications"
|
||||
:key="notification.id"
|
||||
:class="['notification-item', { 'unread': !notification.read }]"
|
||||
>
|
||||
<div class="notification-header">
|
||||
<span class="notification-title">{{ notification.title }}</span>
|
||||
<span class="notification-time">{{ formatTime(notification.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="notification-content">{{ notification.content }}</div>
|
||||
<div class="notification-actions">
|
||||
<a-button
|
||||
v-if="!notification.read"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="notificationStore.markAsRead(notification.id)"
|
||||
>
|
||||
标记已读
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="notificationStore.removeNotification(notification.id)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
BellOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import TabsView from '@/components/layout/TabsView.vue'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// Store
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
const tabsStore = useTabsStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 响应式数据
|
||||
const siderCollapsed = ref(false)
|
||||
const selectedMenuKeys = ref([])
|
||||
const selectedSiderKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
const notificationDrawerVisible = ref(false)
|
||||
const notificationFilter = ref('all')
|
||||
|
||||
// 顶部菜单项
|
||||
const headerMenuItems = computed(() => [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '工作台',
|
||||
onClick: () => router.push('/dashboard')
|
||||
},
|
||||
{
|
||||
key: 'supervision',
|
||||
label: '政府监管',
|
||||
onClick: () => router.push('/supervision')
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
label: '审批管理',
|
||||
onClick: () => router.push('/approval')
|
||||
},
|
||||
{
|
||||
key: 'visualization',
|
||||
label: '可视化大屏',
|
||||
onClick: () => router.push('/visualization')
|
||||
}
|
||||
])
|
||||
|
||||
// 侧边栏菜单项
|
||||
const siderMenuItems = computed(() => {
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
label: '工作台',
|
||||
permission: 'dashboard:view'
|
||||
},
|
||||
{
|
||||
key: '/supervision',
|
||||
icon: 'EyeOutlined',
|
||||
label: '政府监管',
|
||||
permission: 'supervision:view',
|
||||
children: [
|
||||
{
|
||||
key: '/supervision/enterprise',
|
||||
label: '企业监管',
|
||||
permission: 'supervision:enterprise:view'
|
||||
},
|
||||
{
|
||||
key: '/supervision/environment',
|
||||
label: '环境监管',
|
||||
permission: 'supervision:environment:view'
|
||||
},
|
||||
{
|
||||
key: '/supervision/safety',
|
||||
label: '安全监管',
|
||||
permission: 'supervision:safety:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/approval',
|
||||
icon: 'AuditOutlined',
|
||||
label: '审批管理',
|
||||
permission: 'approval:view',
|
||||
children: [
|
||||
{
|
||||
key: '/approval/business',
|
||||
label: '营业执照',
|
||||
permission: 'approval:business:view'
|
||||
},
|
||||
{
|
||||
key: '/approval/construction',
|
||||
label: '建设工程',
|
||||
permission: 'approval:construction:view'
|
||||
},
|
||||
{
|
||||
key: '/approval/environmental',
|
||||
label: '环保审批',
|
||||
permission: 'approval:environmental:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/personnel',
|
||||
icon: 'TeamOutlined',
|
||||
label: '人员管理',
|
||||
permission: 'personnel:view',
|
||||
children: [
|
||||
{
|
||||
key: '/personnel/staff',
|
||||
label: '员工管理',
|
||||
permission: 'personnel:staff:view'
|
||||
},
|
||||
{
|
||||
key: '/personnel/department',
|
||||
label: '部门管理',
|
||||
permission: 'personnel:department:view'
|
||||
},
|
||||
{
|
||||
key: '/personnel/role',
|
||||
label: '角色管理',
|
||||
permission: 'personnel:role:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/warehouse',
|
||||
icon: 'InboxOutlined',
|
||||
label: '设备仓库',
|
||||
permission: 'warehouse:view',
|
||||
children: [
|
||||
{
|
||||
key: '/warehouse/equipment',
|
||||
label: '设备管理',
|
||||
permission: 'warehouse:equipment:view'
|
||||
},
|
||||
{
|
||||
key: '/warehouse/inventory',
|
||||
label: '库存管理',
|
||||
permission: 'warehouse:inventory:view'
|
||||
},
|
||||
{
|
||||
key: '/warehouse/maintenance',
|
||||
label: '维护记录',
|
||||
permission: 'warehouse:maintenance:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/epidemic',
|
||||
icon: 'SafetyOutlined',
|
||||
label: '防疫管理',
|
||||
permission: 'epidemic:view',
|
||||
children: [
|
||||
{
|
||||
key: '/epidemic/monitoring',
|
||||
label: '疫情监控',
|
||||
permission: 'epidemic:monitoring:view'
|
||||
},
|
||||
{
|
||||
key: '/epidemic/prevention',
|
||||
label: '防控措施',
|
||||
permission: 'epidemic:prevention:view'
|
||||
},
|
||||
{
|
||||
key: '/epidemic/statistics',
|
||||
label: '统计报告',
|
||||
permission: 'epidemic:statistics:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/service',
|
||||
icon: 'CustomerServiceOutlined',
|
||||
label: '服务管理',
|
||||
permission: 'service:view',
|
||||
children: [
|
||||
{
|
||||
key: '/service/public',
|
||||
label: '公共服务',
|
||||
permission: 'service:public:view'
|
||||
},
|
||||
{
|
||||
key: '/service/online',
|
||||
label: '在线办事',
|
||||
permission: 'service:online:view'
|
||||
},
|
||||
{
|
||||
key: '/service/feedback',
|
||||
label: '意见反馈',
|
||||
permission: 'service:feedback:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/visualization',
|
||||
icon: 'BarChartOutlined',
|
||||
label: '可视化大屏',
|
||||
permission: 'visualization:view'
|
||||
},
|
||||
{
|
||||
key: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
label: '系统管理',
|
||||
permission: 'system:view',
|
||||
children: [
|
||||
{
|
||||
key: '/system/user',
|
||||
label: '用户管理',
|
||||
permission: 'system:user:view'
|
||||
},
|
||||
{
|
||||
key: '/system/role',
|
||||
label: '角色管理',
|
||||
permission: 'system:role:view'
|
||||
},
|
||||
{
|
||||
key: '/system/permission',
|
||||
label: '权限管理',
|
||||
permission: 'system:permission:view'
|
||||
},
|
||||
{
|
||||
key: '/system/config',
|
||||
label: '系统配置',
|
||||
permission: 'system:config:view'
|
||||
},
|
||||
{
|
||||
key: '/system/log',
|
||||
label: '操作日志',
|
||||
permission: 'system:log:view'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 根据权限过滤菜单项
|
||||
return filterMenuByPermission(menuItems)
|
||||
})
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumbItems = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
return matched.map(item => ({
|
||||
path: item.path,
|
||||
title: item.meta.title
|
||||
}))
|
||||
})
|
||||
|
||||
// 过滤后的通知列表
|
||||
const filteredNotifications = computed(() => {
|
||||
let notifications = notificationStore.notifications
|
||||
|
||||
switch (notificationFilter.value) {
|
||||
case 'unread':
|
||||
notifications = notifications.filter(n => !n.read)
|
||||
break
|
||||
case 'system':
|
||||
notifications = notifications.filter(n => n.type === 'system')
|
||||
break
|
||||
case 'task':
|
||||
notifications = notifications.filter(n => n.type === 'task')
|
||||
break
|
||||
}
|
||||
|
||||
return notifications
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSider = () => {
|
||||
siderCollapsed.value = !siderCollapsed.value
|
||||
appStore.setSiderCollapsed(siderCollapsed.value)
|
||||
}
|
||||
|
||||
const handleHeaderMenuClick = ({ key }) => {
|
||||
selectedMenuKeys.value = [key]
|
||||
}
|
||||
|
||||
const handleSiderMenuClick = ({ key }) => {
|
||||
selectedSiderKeys.value = [key]
|
||||
router.push(key)
|
||||
|
||||
// 添加到标签页
|
||||
const route = router.resolve(key)
|
||||
if (route.meta?.title) {
|
||||
tabsStore.addTab({
|
||||
path: key,
|
||||
name: route.name,
|
||||
title: route.meta.title,
|
||||
closable: key !== '/dashboard'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = (notification) => {
|
||||
if (!notification.read) {
|
||||
notificationStore.markAsRead(notification.id)
|
||||
}
|
||||
|
||||
// 如果通知有关联的路由,跳转到对应页面
|
||||
if (notification.route) {
|
||||
router.push(notification.route)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success('退出登录成功')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
message.error('退出登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) { // 1分钟内
|
||||
return '刚刚'
|
||||
} else if (diff < 3600000) { // 1小时内
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
} else if (diff < 86400000) { // 1天内
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
} else {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
const filterMenuByPermission = (menuItems) => {
|
||||
return menuItems.filter(item => {
|
||||
// 检查当前菜单项权限
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 递归过滤子菜单
|
||||
if (item.children) {
|
||||
item.children = filterMenuByPermission(item.children)
|
||||
// 如果子菜单全部被过滤掉,则隐藏父菜单
|
||||
return item.children.length > 0
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(route, (newRoute) => {
|
||||
selectedSiderKeys.value = [newRoute.path]
|
||||
|
||||
// 更新面包屑
|
||||
const matched = newRoute.matched.filter(item => item.meta && item.meta.title)
|
||||
if (matched.length > 0) {
|
||||
// 自动展开对应的菜单
|
||||
const parentPath = matched[matched.length - 2]?.path
|
||||
if (parentPath && !openKeys.value.includes(parentPath)) {
|
||||
openKeys.value.push(parentPath)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从store恢复状态
|
||||
siderCollapsed.value = appStore.siderCollapsed
|
||||
|
||||
// 初始化通知
|
||||
notificationStore.fetchNotifications()
|
||||
|
||||
// 设置当前选中的菜单
|
||||
selectedSiderKeys.value = [route.path]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.government-layout {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f0f2f5;
|
||||
|
||||
.layout-header {
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.system-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.breadcrumb-container {
|
||||
:deep(.el-breadcrumb__item) {
|
||||
.el-breadcrumb__inner {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-center,
|
||||
.fullscreen-toggle {
|
||||
.el-button {
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-sidebar {
|
||||
width: 240px;
|
||||
background: white;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
transition: width 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.sidebar-menu {
|
||||
border: none;
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-right: 3px solid #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.system-info {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
.version {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.tabs-container {
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 16px;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面切换动画
|
||||
.fade-transform-enter-active,
|
||||
.fade-transform-leave-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
// 通知列表样式
|
||||
.notification-list {
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: #f6ffed;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
|
||||
.notification-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.government-layout {
|
||||
.layout-header {
|
||||
padding: 0 12px;
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
|
||||
.user-info .username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 60px;
|
||||
height: calc(100vh - 60px);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
|
||||
&:not(.collapsed) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
388
government-admin/src/layouts/BasicLayout.vue
Normal file
388
government-admin/src/layouts/BasicLayout.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<a-layout class="basic-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
theme="dark"
|
||||
width="256"
|
||||
class="layout-sider"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<img src="/favicon.svg" alt="Logo" />
|
||||
<span v-show="!collapsed" class="logo-text">政府监管平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<template v-for="route in menuRoutes" :key="route.name">
|
||||
<a-menu-item
|
||||
v-if="!route.children || route.children.length === 0"
|
||||
:key="route.name"
|
||||
>
|
||||
<component :is="getIcon(route.meta?.icon)" />
|
||||
<span>{{ route.meta?.title }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-sub-menu
|
||||
v-else
|
||||
|
||||
>
|
||||
<template #title>
|
||||
<component :is="getIcon(route.meta?.icon)" />
|
||||
<span>{{ route.meta?.title }}</span>
|
||||
</template>
|
||||
|
||||
<a-menu-item
|
||||
v-for="child in route.children"
|
||||
:key="child.name"
|
||||
>
|
||||
<component :is="getIcon(child.meta?.icon)" />
|
||||
<span>{{ child.meta?.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<!-- :key="route.name" -->
|
||||
<!-- 主体内容 -->
|
||||
<a-layout class="layout-content">
|
||||
<!-- 顶部导航 -->
|
||||
<a-layout-header class="layout-header">
|
||||
<div class="header-left">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="collapsed = !collapsed"
|
||||
class="trigger"
|
||||
>
|
||||
<menu-unfold-outlined v-if="collapsed" />
|
||||
<menu-fold-outlined v-else />
|
||||
</a-button>
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item
|
||||
v-for="item in breadcrumbItems"
|
||||
:key="item.path"
|
||||
>
|
||||
<router-link v-if="item.path && item.path !== $route.path" :to="item.path">
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 通知 -->
|
||||
<a-badge :count="notificationCount" class="notification-badge">
|
||||
<a-button type="text" @click="showNotifications">
|
||||
<bell-outlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<a-dropdown>
|
||||
<a-button type="text" class="user-info">
|
||||
<a-avatar :src="authStore.avatar" :size="32">
|
||||
{{ authStore.userName.charAt(0) }}
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ authStore.userName }}</span>
|
||||
<down-outlined />
|
||||
</a-button>
|
||||
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="showProfile">
|
||||
<user-outlined />
|
||||
个人资料
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="showSettings">
|
||||
<setting-outlined />
|
||||
系统设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<a-layout-content class="main-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<a-layout-footer class="layout-footer">
|
||||
<div class="footer-content">
|
||||
<span>© 2025 宁夏智慧养殖监管平台 - 政府端管理后台</span>
|
||||
<span>版本 v1.0.0</span>
|
||||
</div>
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
DashboardOutlined,
|
||||
HomeOutlined,
|
||||
MonitorOutlined,
|
||||
BugOutlined,
|
||||
AlertOutlined,
|
||||
BarChartOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
const notificationCount = ref(5)
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
dashboard: DashboardOutlined,
|
||||
home: HomeOutlined,
|
||||
monitor: MonitorOutlined,
|
||||
bug: BugOutlined,
|
||||
alert: AlertOutlined,
|
||||
'bar-chart': BarChartOutlined,
|
||||
user: UserOutlined,
|
||||
setting: SettingOutlined
|
||||
}
|
||||
|
||||
// 获取图标组件
|
||||
const getIcon = (iconName) => {
|
||||
return iconMap[iconName] || DashboardOutlined
|
||||
}
|
||||
|
||||
// 菜单路由
|
||||
const menuRoutes = computed(() => {
|
||||
return router.getRoutes()
|
||||
.find(route => route.name === 'Layout')
|
||||
?.children?.filter(child =>
|
||||
!child.meta?.hidden &&
|
||||
(!child.meta?.roles || authStore.hasRole(child.meta.roles))
|
||||
) || []
|
||||
})
|
||||
|
||||
// 面包屑
|
||||
const breadcrumbItems = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta?.title)
|
||||
return matched.map(item => ({
|
||||
title: item.meta.title,
|
||||
path: item.path === route.path ? null : item.path
|
||||
}))
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.name,
|
||||
(newName) => {
|
||||
if (newName) {
|
||||
selectedKeys.value = [newName]
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = ({ key }) => {
|
||||
router.push({ name: key })
|
||||
}
|
||||
|
||||
// 显示通知
|
||||
const showNotifications = () => {
|
||||
// TODO: 实现通知功能
|
||||
console.log('显示通知')
|
||||
}
|
||||
|
||||
// 显示个人资料
|
||||
const showProfile = () => {
|
||||
// TODO: 实现个人资料功能
|
||||
console.log('显示个人资料')
|
||||
}
|
||||
|
||||
// 显示系统设置
|
||||
const showSettings = () => {
|
||||
router.push('/settings')
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
onOk: async () => {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-layout {
|
||||
height: 100vh;
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
margin-left: 256px;
|
||||
transition: margin-left 0.2s;
|
||||
|
||||
&.collapsed {
|
||||
margin-left: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
background: white;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-badge {
|
||||
.ant-btn {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px - 48px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
background: white;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 24px;
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.basic-layout {
|
||||
.layout-sider {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.ant-layout-sider-collapsed {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
padding: 0 16px;
|
||||
|
||||
.header-left .breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
government-admin/src/main.js
Normal file
20
government-admin/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.css'
|
||||
import { permissionDirective } from './stores/permission'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(Antd)
|
||||
app.use(router)
|
||||
|
||||
// 注册权限指令
|
||||
app.directive('permission', permissionDirective)
|
||||
|
||||
app.mount('#app')
|
||||
287
government-admin/src/router/guards.js
Normal file
287
government-admin/src/router/guards.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 路由守卫配置
|
||||
*/
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import { checkRoutePermission } from '@/utils/permission'
|
||||
import { message } from 'ant-design-vue'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
minimum: 0.2,
|
||||
speed: 500
|
||||
})
|
||||
|
||||
// 白名单路由 - 不需要登录验证的路由
|
||||
const whiteList = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/404',
|
||||
'/403',
|
||||
'/500'
|
||||
]
|
||||
|
||||
// 公共路由 - 登录后都可以访问的路由
|
||||
const publicRoutes = [
|
||||
'/dashboard',
|
||||
'/profile',
|
||||
'/settings'
|
||||
]
|
||||
|
||||
/**
|
||||
* 前置守卫 - 路由跳转前的权限验证
|
||||
*/
|
||||
export async function beforeEach(to, from, next) {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// 获取用户token
|
||||
const token = userStore.token || localStorage.getItem('token')
|
||||
|
||||
// 检查是否在白名单中
|
||||
if (whiteList.includes(to.path)) {
|
||||
// 如果已登录且访问登录页,重定向到首页
|
||||
if (token && to.path === '/login') {
|
||||
next({ path: '/dashboard' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
if (!token) {
|
||||
message.warning('请先登录')
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户信息是否存在
|
||||
if (!userStore.userInfo || !userStore.userInfo.id) {
|
||||
try {
|
||||
// 获取用户信息
|
||||
await userStore.getUserInfo()
|
||||
// 初始化权限
|
||||
await permissionStore.initPermissions(userStore.userInfo)
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
message.error('获取用户信息失败,请重新登录')
|
||||
userStore.logout()
|
||||
next({ path: '/login' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为公共路由
|
||||
if (publicRoutes.includes(to.path)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查路由权限
|
||||
if (!checkRoutePermission(to, userStore.userInfo)) {
|
||||
message.error('您没有访问该页面的权限')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
// 检查动态路由是否已生成
|
||||
if (!permissionStore.routesGenerated) {
|
||||
try {
|
||||
// 生成动态路由
|
||||
const accessRoutes = await permissionStore.generateRoutes(userStore.userInfo)
|
||||
|
||||
// 动态添加路由
|
||||
accessRoutes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
|
||||
// 重新导航到目标路由
|
||||
next({ ...to, replace: true })
|
||||
} catch (error) {
|
||||
console.error('生成路由失败:', error)
|
||||
message.error('系统初始化失败')
|
||||
next({ path: '/500' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置守卫 - 路由跳转后的处理
|
||||
*/
|
||||
export function afterEach(to, from) {
|
||||
// 结束进度条
|
||||
NProgress.done()
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta?.title
|
||||
if (title) {
|
||||
document.title = `${title} - 政府管理后台`
|
||||
} else {
|
||||
document.title = '政府管理后台'
|
||||
}
|
||||
|
||||
// 记录路由访问日志
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`路由跳转: ${from.path} -> ${to.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由错误处理
|
||||
*/
|
||||
export function onError(error) {
|
||||
console.error('路由错误:', error)
|
||||
NProgress.done()
|
||||
|
||||
// 根据错误类型进行处理
|
||||
if (error.name === 'ChunkLoadError') {
|
||||
message.error('页面加载失败,请刷新重试')
|
||||
} else if (error.name === 'NavigationDuplicated') {
|
||||
// 重复导航错误,忽略
|
||||
return
|
||||
} else {
|
||||
message.error('页面访问异常')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限验证中间件
|
||||
*/
|
||||
export function requireAuth(permission) {
|
||||
return (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
if (!userStore.token) {
|
||||
next({ path: '/login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (permission && !permissionStore.hasPermission(permission)) {
|
||||
message.error('权限不足')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色验证中间件
|
||||
*/
|
||||
export function requireRole(role) {
|
||||
return (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
if (!userStore.token) {
|
||||
next({ path: '/login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (role && !permissionStore.hasRole(role)) {
|
||||
message.error('角色权限不足')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员权限验证
|
||||
*/
|
||||
export function requireAdmin(to, from, next) {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (!userStore.token) {
|
||||
next({ path: '/login' })
|
||||
return
|
||||
}
|
||||
|
||||
const userRole = userStore.userInfo?.role
|
||||
if (!['super_admin', 'admin'].includes(userRole)) {
|
||||
message.error('需要管理员权限')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员权限验证
|
||||
*/
|
||||
export function requireSuperAdmin(to, from, next) {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (!userStore.token) {
|
||||
next({ path: '/login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (userStore.userInfo?.role !== 'super_admin') {
|
||||
message.error('需要超级管理员权限')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查页面访问权限
|
||||
*/
|
||||
export function checkPageAccess(requiredPermissions = []) {
|
||||
return (to, from, next) => {
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// 检查是否有任一权限
|
||||
const hasAccess = requiredPermissions.length === 0 ||
|
||||
requiredPermissions.some(permission =>
|
||||
permissionStore.hasPermission(permission)
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
message.error('您没有访问该页面的权限')
|
||||
next({ path: '/403' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态路由加载守卫
|
||||
*/
|
||||
export function loadDynamicRoutes(to, from, next) {
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
if (!permissionStore.routesGenerated) {
|
||||
// 如果路由未生成,等待生成完成
|
||||
permissionStore.generateRoutes().then(() => {
|
||||
next({ ...to, replace: true })
|
||||
}).catch(() => {
|
||||
next({ path: '/500' })
|
||||
})
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
498
government-admin/src/router/index.js
Normal file
498
government-admin/src/router/index.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { beforeEach, afterEach, onError } from './guards'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 导入布局组件
|
||||
const Layout = () => import('@/layout/GovernmentLayout.vue')
|
||||
|
||||
// 基础路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: {
|
||||
title: '页面不存在',
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'Forbidden',
|
||||
component: () => import('@/views/error/403.vue'),
|
||||
meta: {
|
||||
title: '权限不足',
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/500',
|
||||
name: 'ServerError',
|
||||
component: () => import('@/views/error/500.vue'),
|
||||
meta: {
|
||||
title: '服务器错误',
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '仪表盘',
|
||||
icon: 'dashboard',
|
||||
affix: true,
|
||||
permission: 'dashboard:view'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/breeding',
|
||||
name: 'Breeding',
|
||||
meta: {
|
||||
title: '养殖管理',
|
||||
icon: 'home',
|
||||
permission: 'breeding:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'farms',
|
||||
name: 'BreedingFarmList',
|
||||
component: () => import('@/views/breeding/BreedingFarmList.vue'),
|
||||
meta: {
|
||||
title: '养殖场管理',
|
||||
permission: 'breeding:farm'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/monitoring',
|
||||
name: 'Monitoring',
|
||||
meta: {
|
||||
title: '健康监控',
|
||||
icon: 'monitor',
|
||||
permission: 'monitoring:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'health',
|
||||
name: 'AnimalHealthMonitor',
|
||||
component: () => import('@/views/monitoring/AnimalHealthMonitor.vue'),
|
||||
meta: {
|
||||
title: '动物健康监控',
|
||||
permission: 'monitoring:health'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/inspection',
|
||||
name: 'Inspection',
|
||||
meta: {
|
||||
title: '检查管理',
|
||||
icon: 'audit',
|
||||
permission: 'inspection:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'management',
|
||||
name: 'InspectionManagement',
|
||||
component: () => import('@/views/inspection/InspectionManagement.vue'),
|
||||
meta: {
|
||||
title: '检查管理',
|
||||
permission: 'inspection:manage'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/traceability',
|
||||
name: 'Traceability',
|
||||
meta: {
|
||||
title: '溯源系统',
|
||||
icon: 'link',
|
||||
permission: 'traceability:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'system',
|
||||
name: 'TraceabilitySystem',
|
||||
component: () => import('@/views/traceability/TraceabilitySystem.vue'),
|
||||
meta: {
|
||||
title: '产品溯源',
|
||||
permission: 'traceability:system'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/emergency',
|
||||
name: 'Emergency',
|
||||
meta: {
|
||||
title: '应急响应',
|
||||
icon: 'alert',
|
||||
permission: 'emergency:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'response',
|
||||
name: 'EmergencyResponse',
|
||||
component: () => import('@/views/emergency/EmergencyResponse.vue'),
|
||||
meta: {
|
||||
title: '应急响应',
|
||||
permission: 'emergency:response'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/policy',
|
||||
name: 'Policy',
|
||||
meta: {
|
||||
title: '政策管理',
|
||||
icon: 'file-text',
|
||||
permission: 'policy:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'management',
|
||||
name: 'PolicyManagement',
|
||||
component: () => import('@/views/policy/PolicyManagement.vue'),
|
||||
meta: {
|
||||
title: '政策管理',
|
||||
permission: 'policy:manage'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'Statistics',
|
||||
meta: {
|
||||
title: '数据统计',
|
||||
icon: 'bar-chart',
|
||||
permission: 'statistics:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'data',
|
||||
name: 'DataStatistics',
|
||||
component: () => import('@/views/statistics/DataStatistics.vue'),
|
||||
meta: {
|
||||
title: '数据统计',
|
||||
permission: 'statistics:data'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'Reports',
|
||||
meta: {
|
||||
title: '报表中心',
|
||||
icon: 'file-text',
|
||||
permission: 'reports:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'center',
|
||||
name: 'ReportCenter',
|
||||
component: () => import('@/views/reports/ReportCenter.vue'),
|
||||
meta: {
|
||||
title: '报表中心',
|
||||
permission: 'reports:center'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
meta: {
|
||||
title: '系统设置',
|
||||
icon: 'setting',
|
||||
permission: 'settings:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'system',
|
||||
name: 'SystemSettings',
|
||||
component: () => import('@/views/settings/SystemSettings.vue'),
|
||||
meta: {
|
||||
title: '系统设置',
|
||||
permission: 'settings:system'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/monitor',
|
||||
name: 'Monitor',
|
||||
component: () => import('@/views/monitor/MonitorDashboard.vue'),
|
||||
meta: {
|
||||
title: '实时监控',
|
||||
icon: 'eye',
|
||||
requiresAuth: true,
|
||||
permission: 'monitor:view'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'Reports',
|
||||
component: () => import('@/views/reports/ReportList.vue'),
|
||||
meta: {
|
||||
title: '报表管理',
|
||||
icon: 'file-text',
|
||||
requiresAuth: true,
|
||||
permission: 'monitor:report'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/data',
|
||||
name: 'Data',
|
||||
component: () => import('@/views/data/DataAnalysis.vue'),
|
||||
meta: {
|
||||
title: '数据分析',
|
||||
icon: 'bar-chart',
|
||||
requiresAuth: true,
|
||||
permission: 'data:view'
|
||||
}
|
||||
},
|
||||
// 业务管理
|
||||
{
|
||||
path: '/business',
|
||||
name: 'Business',
|
||||
meta: {
|
||||
title: '业务管理',
|
||||
icon: 'solution',
|
||||
permission: 'business:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'insurance',
|
||||
name: 'InsuranceManagement',
|
||||
component: () => import('@/views/business/InsuranceManagement.vue'),
|
||||
meta: {
|
||||
title: '保险管理',
|
||||
permission: 'business:insurance'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'trading',
|
||||
name: 'TradingManagement',
|
||||
component: () => import('@/views/business/TradingManagement.vue'),
|
||||
meta: {
|
||||
title: '生资交易',
|
||||
permission: 'business:trading'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'waste-collection',
|
||||
name: 'WasteCollection',
|
||||
component: () => import('@/views/business/WasteCollection.vue'),
|
||||
meta: {
|
||||
title: '粪污报收',
|
||||
permission: 'business:waste'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'subsidies',
|
||||
name: 'SubsidyManagement',
|
||||
component: () => import('@/views/business/SubsidyManagement.vue'),
|
||||
meta: {
|
||||
title: '奖补管理',
|
||||
permission: 'business:subsidy'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forage-enterprises',
|
||||
name: 'ForageEnterprises',
|
||||
component: () => import('@/views/business/ForageEnterprises.vue'),
|
||||
meta: {
|
||||
title: '饲草料企业管理',
|
||||
permission: 'business:forage'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'market-info',
|
||||
name: 'MarketInfo',
|
||||
component: () => import('@/views/business/MarketInfo.vue'),
|
||||
meta: {
|
||||
title: '市场行情',
|
||||
permission: 'business:market'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 防疫管理
|
||||
{
|
||||
path: '/epidemic-prevention',
|
||||
name: 'EpidemicPrevention',
|
||||
meta: {
|
||||
title: '防疫管理',
|
||||
icon: 'medicine-box',
|
||||
permission: 'epidemic:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'institutions',
|
||||
name: 'EpidemicInstitutions',
|
||||
component: () => import('@/views/epidemic/EpidemicInstitutions.vue'),
|
||||
meta: {
|
||||
title: '防疫机构管理',
|
||||
permission: 'epidemic:institution'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'records',
|
||||
name: 'EpidemicRecords',
|
||||
component: () => import('@/views/epidemic/EpidemicRecords.vue'),
|
||||
meta: {
|
||||
title: '防疫记录',
|
||||
permission: 'epidemic:record'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'vaccines',
|
||||
name: 'VaccineManagement',
|
||||
component: () => import('@/views/epidemic/VaccineManagement.vue'),
|
||||
meta: {
|
||||
title: '疫苗管理',
|
||||
permission: 'epidemic:vaccine'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'activities',
|
||||
name: 'EpidemicActivities',
|
||||
component: () => import('@/views/epidemic/EpidemicActivities.vue'),
|
||||
meta: {
|
||||
title: '防疫活动管理',
|
||||
permission: 'epidemic:activity'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 服务管理
|
||||
{
|
||||
path: '/services',
|
||||
name: 'Services',
|
||||
meta: {
|
||||
title: '服务管理',
|
||||
icon: 'customer-service',
|
||||
permission: 'service:view'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'education',
|
||||
name: 'CattleEducation',
|
||||
component: () => import('@/views/services/CattleEducation.vue'),
|
||||
meta: {
|
||||
title: '养牛学院',
|
||||
permission: 'service:education'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'consultation',
|
||||
name: 'OnlineConsultation',
|
||||
component: () => import('@/views/services/OnlineConsultation.vue'),
|
||||
meta: {
|
||||
title: '线上问诊',
|
||||
permission: 'service:consultation'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community',
|
||||
name: 'CommunityManagement',
|
||||
component: () => import('@/views/services/CommunityManagement.vue'),
|
||||
meta: {
|
||||
title: '交流社区',
|
||||
permission: 'service:community'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/users/UserList.vue'),
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
icon: 'user',
|
||||
requiresAuth: true,
|
||||
permission: 'user:view'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/settings/SystemSettings.vue'),
|
||||
meta: {
|
||||
title: '系统设置',
|
||||
icon: 'setting',
|
||||
requiresAuth: true,
|
||||
permission: 'system:config'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'Forbidden',
|
||||
component: () => import('@/views/error/403.vue'),
|
||||
meta: {
|
||||
title: '权限不足',
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: {
|
||||
title: '页面不存在',
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册路由守卫
|
||||
router.beforeEach(beforeEach)
|
||||
router.afterEach(afterEach)
|
||||
router.onError(onError)
|
||||
|
||||
export default router
|
||||
116
government-admin/src/stores/auth.js
Normal file
116
government-admin/src/stores/auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
|
||||
const permissions = ref([])
|
||||
|
||||
// 计算属性
|
||||
const isAuthenticated = computed(() => !!token.value && !!userInfo.value)
|
||||
const userName = computed(() => userInfo.value?.name || '')
|
||||
const userRole = computed(() => userInfo.value?.role || '')
|
||||
const avatar = computed(() => userInfo.value?.avatar || '')
|
||||
|
||||
// 方法
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login', credentials)
|
||||
const { token: newToken, user, permissions: userPermissions } = response.data
|
||||
|
||||
// 保存认证信息
|
||||
token.value = newToken
|
||||
userInfo.value = user
|
||||
permissions.value = userPermissions || []
|
||||
|
||||
// 持久化存储
|
||||
localStorage.setItem('token', newToken)
|
||||
localStorage.setItem('userInfo', JSON.stringify(user))
|
||||
localStorage.setItem('permissions', JSON.stringify(userPermissions || []))
|
||||
|
||||
message.success('登录成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || '登录失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} catch (error) {
|
||||
console.error('退出登录请求失败:', error)
|
||||
} finally {
|
||||
// 清除认证信息
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
permissions.value = []
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('permissions')
|
||||
|
||||
message.success('已退出登录')
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
if (!token.value) return false
|
||||
|
||||
try {
|
||||
const response = await api.get('/auth/me')
|
||||
userInfo.value = response.data.user
|
||||
permissions.value = response.data.permissions || []
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.user))
|
||||
localStorage.setItem('permissions', JSON.stringify(response.data.permissions || []))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
// 认证失败,清除本地数据
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const hasPermission = (permission) => {
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
const hasRole = (roles) => {
|
||||
if (!Array.isArray(roles)) roles = [roles]
|
||||
return roles.includes(userRole.value)
|
||||
}
|
||||
|
||||
const updateUserInfo = (newUserInfo) => {
|
||||
userInfo.value = { ...userInfo.value, ...newUserInfo }
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
userInfo,
|
||||
permissions,
|
||||
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
userName,
|
||||
userRole,
|
||||
avatar,
|
||||
|
||||
// 方法
|
||||
login,
|
||||
logout,
|
||||
checkAuthStatus,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
updateUserInfo
|
||||
}
|
||||
})
|
||||
292
government-admin/src/stores/farm.js
Normal file
292
government-admin/src/stores/farm.js
Normal file
@@ -0,0 +1,292 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
getFarmList,
|
||||
getFarmDetail,
|
||||
createFarm,
|
||||
updateFarm,
|
||||
deleteFarm,
|
||||
batchDeleteFarms,
|
||||
updateFarmStatus,
|
||||
getFarmStats,
|
||||
getFarmMapData,
|
||||
getFarmTypes,
|
||||
getFarmScales
|
||||
} from '@/api/farm'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export const useFarmStore = defineStore('farm', () => {
|
||||
// 状态
|
||||
const farms = ref([])
|
||||
const currentFarm = ref(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const stats = ref({
|
||||
total: 0,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
pending: 0
|
||||
})
|
||||
const farmTypes = ref([])
|
||||
const farmScales = ref([])
|
||||
const mapData = ref([])
|
||||
|
||||
// 计算属性
|
||||
const activeFarms = computed(() =>
|
||||
farms.value.filter(farm => farm.status === 'active')
|
||||
)
|
||||
|
||||
const inactiveFarms = computed(() =>
|
||||
farms.value.filter(farm => farm.status === 'inactive')
|
||||
)
|
||||
|
||||
const pendingFarms = computed(() =>
|
||||
farms.value.filter(farm => farm.status === 'pending')
|
||||
)
|
||||
|
||||
// 获取养殖场列表
|
||||
const fetchFarms = async (params = {}) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getFarmList(params)
|
||||
farms.value = response.data.list || []
|
||||
total.value = response.data.total || 0
|
||||
return response
|
||||
} catch (error) {
|
||||
message.error('获取养殖场列表失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取养殖场详情
|
||||
const fetchFarmDetail = async (id) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getFarmDetail(id)
|
||||
currentFarm.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('获取养殖场详情失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建养殖场
|
||||
const addFarm = async (farmData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await createFarm(farmData)
|
||||
message.success('创建养殖场成功')
|
||||
// 重新获取列表
|
||||
await fetchFarms()
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('创建养殖场失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新养殖场
|
||||
const editFarm = async (id, farmData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await updateFarm(id, farmData)
|
||||
message.success('更新养殖场成功')
|
||||
|
||||
// 更新本地数据
|
||||
const index = farms.value.findIndex(farm => farm.id === id)
|
||||
if (index !== -1) {
|
||||
farms.value[index] = { ...farms.value[index], ...response.data }
|
||||
}
|
||||
|
||||
// 如果是当前查看的养殖场,也更新
|
||||
if (currentFarm.value && currentFarm.value.id === id) {
|
||||
currentFarm.value = { ...currentFarm.value, ...response.data }
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('更新养殖场失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除养殖场
|
||||
const removeFarm = async (id) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteFarm(id)
|
||||
message.success('删除养殖场成功')
|
||||
|
||||
// 从本地数据中移除
|
||||
const index = farms.value.findIndex(farm => farm.id === id)
|
||||
if (index !== -1) {
|
||||
farms.value.splice(index, 1)
|
||||
total.value -= 1
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error('删除养殖场失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除养殖场
|
||||
const batchRemoveFarms = async (ids) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await batchDeleteFarms(ids)
|
||||
message.success(`成功删除 ${ids.length} 个养殖场`)
|
||||
|
||||
// 从本地数据中移除
|
||||
farms.value = farms.value.filter(farm => !ids.includes(farm.id))
|
||||
total.value -= ids.length
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error('批量删除养殖场失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新养殖场状态
|
||||
const changeFarmStatus = async (id, status) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await updateFarmStatus(id, status)
|
||||
message.success('更新状态成功')
|
||||
|
||||
// 更新本地数据
|
||||
const index = farms.value.findIndex(farm => farm.id === id)
|
||||
if (index !== -1) {
|
||||
farms.value[index].status = status
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('更新状态失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await getFarmStats()
|
||||
stats.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('获取统计数据失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取地图数据
|
||||
const fetchMapData = async (params = {}) => {
|
||||
try {
|
||||
const response = await getFarmMapData(params)
|
||||
mapData.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
message.error('获取地图数据失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取养殖场类型选项
|
||||
const fetchFarmTypes = async () => {
|
||||
try {
|
||||
const response = await getFarmTypes()
|
||||
farmTypes.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('获取养殖场类型失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取养殖场规模选项
|
||||
const fetchFarmScales = async () => {
|
||||
try {
|
||||
const response = await getFarmScales()
|
||||
farmScales.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('获取养殖场规模失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
farms.value = []
|
||||
currentFarm.value = null
|
||||
loading.value = false
|
||||
total.value = 0
|
||||
stats.value = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
pending: 0
|
||||
}
|
||||
mapData.value = []
|
||||
}
|
||||
|
||||
// 设置当前养殖场
|
||||
const setCurrentFarm = (farm) => {
|
||||
currentFarm.value = farm
|
||||
}
|
||||
|
||||
// 清除当前养殖场
|
||||
const clearCurrentFarm = () => {
|
||||
currentFarm.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
farms,
|
||||
currentFarm,
|
||||
loading,
|
||||
total,
|
||||
stats,
|
||||
farmTypes,
|
||||
farmScales,
|
||||
mapData,
|
||||
|
||||
// 计算属性
|
||||
activeFarms,
|
||||
inactiveFarms,
|
||||
pendingFarms,
|
||||
|
||||
// 方法
|
||||
fetchFarms,
|
||||
fetchFarmDetail,
|
||||
addFarm,
|
||||
editFarm,
|
||||
removeFarm,
|
||||
batchRemoveFarms,
|
||||
changeFarmStatus,
|
||||
fetchStats,
|
||||
fetchMapData,
|
||||
fetchFarmTypes,
|
||||
fetchFarmScales,
|
||||
resetState,
|
||||
setCurrentFarm,
|
||||
clearCurrentFarm
|
||||
}
|
||||
})
|
||||
522
government-admin/src/stores/government.js
Normal file
522
government-admin/src/stores/government.js
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 政府业务状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { governmentApi } from '@/api/government'
|
||||
|
||||
export const useGovernmentStore = defineStore('government', {
|
||||
state: () => ({
|
||||
// 政府监管数据
|
||||
supervision: {
|
||||
// 监管统计
|
||||
stats: {
|
||||
totalEntities: 0,
|
||||
activeInspections: 0,
|
||||
pendingApprovals: 0,
|
||||
completedTasks: 0
|
||||
},
|
||||
// 监管实体列表
|
||||
entities: [],
|
||||
// 检查记录
|
||||
inspections: [],
|
||||
// 违规记录
|
||||
violations: []
|
||||
},
|
||||
|
||||
// 审批管理数据
|
||||
approval: {
|
||||
// 审批统计
|
||||
stats: {
|
||||
pending: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
total: 0
|
||||
},
|
||||
// 审批流程
|
||||
workflows: [],
|
||||
// 审批记录
|
||||
records: [],
|
||||
// 待办任务
|
||||
tasks: []
|
||||
},
|
||||
|
||||
// 人员管理数据
|
||||
personnel: {
|
||||
// 人员统计
|
||||
stats: {
|
||||
totalStaff: 0,
|
||||
activeStaff: 0,
|
||||
departments: 0,
|
||||
positions: 0
|
||||
},
|
||||
// 员工列表
|
||||
staff: [],
|
||||
// 部门列表
|
||||
departments: [],
|
||||
// 职位列表
|
||||
positions: [],
|
||||
// 考勤记录
|
||||
attendance: []
|
||||
},
|
||||
|
||||
// 设备仓库数据
|
||||
warehouse: {
|
||||
// 库存统计
|
||||
stats: {
|
||||
totalEquipment: 0,
|
||||
availableEquipment: 0,
|
||||
inUseEquipment: 0,
|
||||
maintenanceEquipment: 0
|
||||
},
|
||||
// 设备列表
|
||||
equipment: [],
|
||||
// 入库记录
|
||||
inboundRecords: [],
|
||||
// 出库记录
|
||||
outboundRecords: [],
|
||||
// 维护记录
|
||||
maintenanceRecords: []
|
||||
},
|
||||
|
||||
// 防疫管理数据
|
||||
epidemic: {
|
||||
// 防疫统计
|
||||
stats: {
|
||||
totalCases: 0,
|
||||
activeCases: 0,
|
||||
recoveredCases: 0,
|
||||
vaccinationRate: 0
|
||||
},
|
||||
// 疫情数据
|
||||
cases: [],
|
||||
// 疫苗接种记录
|
||||
vaccinations: [],
|
||||
// 防疫措施
|
||||
measures: [],
|
||||
// 健康码数据
|
||||
healthCodes: []
|
||||
},
|
||||
|
||||
// 服务管理数据
|
||||
service: {
|
||||
// 服务统计
|
||||
stats: {
|
||||
totalServices: 0,
|
||||
activeServices: 0,
|
||||
completedServices: 0,
|
||||
satisfactionRate: 0
|
||||
},
|
||||
// 服务项目
|
||||
services: [],
|
||||
// 服务申请
|
||||
applications: [],
|
||||
// 服务评价
|
||||
evaluations: [],
|
||||
// 服务指南
|
||||
guides: []
|
||||
},
|
||||
|
||||
// 数据可视化配置
|
||||
visualization: {
|
||||
// 图表配置
|
||||
charts: {},
|
||||
// 数据源配置
|
||||
dataSources: {},
|
||||
// 刷新间隔
|
||||
refreshInterval: 30000,
|
||||
// 实时数据开关
|
||||
realTimeEnabled: true
|
||||
},
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
supervision: false,
|
||||
approval: false,
|
||||
personnel: false,
|
||||
warehouse: false,
|
||||
epidemic: false,
|
||||
service: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
errors: {}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 总体统计数据
|
||||
overallStats: (state) => ({
|
||||
supervision: state.supervision.stats,
|
||||
approval: state.approval.stats,
|
||||
personnel: state.personnel.stats,
|
||||
warehouse: state.warehouse.stats,
|
||||
epidemic: state.epidemic.stats,
|
||||
service: state.service.stats
|
||||
}),
|
||||
|
||||
// 待处理任务总数
|
||||
totalPendingTasks: (state) => {
|
||||
return state.approval.stats.pending +
|
||||
state.supervision.stats.pendingApprovals +
|
||||
state.service.stats.activeServices
|
||||
},
|
||||
|
||||
// 系统健康状态
|
||||
systemHealth: (state) => {
|
||||
const totalTasks = state.approval.stats.total
|
||||
const completedTasks = state.approval.stats.approved + state.approval.stats.rejected
|
||||
const completionRate = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 100
|
||||
|
||||
if (completionRate >= 90) return 'excellent'
|
||||
if (completionRate >= 75) return 'good'
|
||||
if (completionRate >= 60) return 'fair'
|
||||
return 'poor'
|
||||
},
|
||||
|
||||
// 最近活动
|
||||
recentActivities: (state) => {
|
||||
const activities = []
|
||||
|
||||
// 添加审批活动
|
||||
state.approval.records.slice(0, 5).forEach(record => {
|
||||
activities.push({
|
||||
type: 'approval',
|
||||
title: `审批:${record.title}`,
|
||||
time: record.updatedAt,
|
||||
status: record.status
|
||||
})
|
||||
})
|
||||
|
||||
// 添加监管活动
|
||||
state.supervision.inspections.slice(0, 5).forEach(inspection => {
|
||||
activities.push({
|
||||
type: 'inspection',
|
||||
title: `检查:${inspection.title}`,
|
||||
time: inspection.createdAt,
|
||||
status: inspection.status
|
||||
})
|
||||
})
|
||||
|
||||
return activities
|
||||
.sort((a, b) => new Date(b.time) - new Date(a.time))
|
||||
.slice(0, 10)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 初始化政府数据
|
||||
*/
|
||||
async initializeGovernmentData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadSupervisionData(),
|
||||
this.loadApprovalData(),
|
||||
this.loadPersonnelData(),
|
||||
this.loadWarehouseData(),
|
||||
this.loadEpidemicData(),
|
||||
this.loadServiceData()
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('初始化政府数据失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载监管数据
|
||||
*/
|
||||
async loadSupervisionData() {
|
||||
this.loading.supervision = true
|
||||
try {
|
||||
const response = await governmentApi.getSupervisionData()
|
||||
this.supervision = { ...this.supervision, ...response.data }
|
||||
delete this.errors.supervision
|
||||
} catch (error) {
|
||||
this.errors.supervision = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.supervision = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载审批数据
|
||||
*/
|
||||
async loadApprovalData() {
|
||||
this.loading.approval = true
|
||||
try {
|
||||
const response = await governmentApi.getApprovalData()
|
||||
this.approval = { ...this.approval, ...response.data }
|
||||
delete this.errors.approval
|
||||
} catch (error) {
|
||||
this.errors.approval = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.approval = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载人员数据
|
||||
*/
|
||||
async loadPersonnelData() {
|
||||
this.loading.personnel = true
|
||||
try {
|
||||
const response = await governmentApi.getPersonnelData()
|
||||
this.personnel = { ...this.personnel, ...response.data }
|
||||
delete this.errors.personnel
|
||||
} catch (error) {
|
||||
this.errors.personnel = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.personnel = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载仓库数据
|
||||
*/
|
||||
async loadWarehouseData() {
|
||||
this.loading.warehouse = true
|
||||
try {
|
||||
const response = await governmentApi.getWarehouseData()
|
||||
this.warehouse = { ...this.warehouse, ...response.data }
|
||||
delete this.errors.warehouse
|
||||
} catch (error) {
|
||||
this.errors.warehouse = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.warehouse = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载防疫数据
|
||||
*/
|
||||
async loadEpidemicData() {
|
||||
this.loading.epidemic = true
|
||||
try {
|
||||
const response = await governmentApi.getEpidemicData()
|
||||
this.epidemic = { ...this.epidemic, ...response.data }
|
||||
delete this.errors.epidemic
|
||||
} catch (error) {
|
||||
this.errors.epidemic = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.epidemic = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载服务数据
|
||||
*/
|
||||
async loadServiceData() {
|
||||
this.loading.service = true
|
||||
try {
|
||||
const response = await governmentApi.getServiceData()
|
||||
this.service = { ...this.service, ...response.data }
|
||||
delete this.errors.service
|
||||
} catch (error) {
|
||||
this.errors.service = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading.service = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 提交审批
|
||||
* @param {Object} approvalData - 审批数据
|
||||
*/
|
||||
async submitApproval(approvalData) {
|
||||
try {
|
||||
const response = await governmentApi.submitApproval(approvalData)
|
||||
|
||||
// 更新本地数据
|
||||
this.approval.records.unshift(response.data)
|
||||
this.approval.stats.pending += 1
|
||||
this.approval.stats.total += 1
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理审批
|
||||
* @param {string} id - 审批ID
|
||||
* @param {Object} decision - 审批决定
|
||||
*/
|
||||
async processApproval(id, decision) {
|
||||
try {
|
||||
const response = await governmentApi.processApproval(id, decision)
|
||||
|
||||
// 更新本地数据
|
||||
const index = this.approval.records.findIndex(r => r.id === id)
|
||||
if (index > -1) {
|
||||
this.approval.records[index] = response.data
|
||||
|
||||
// 更新统计
|
||||
this.approval.stats.pending -= 1
|
||||
if (decision.status === 'approved') {
|
||||
this.approval.stats.approved += 1
|
||||
} else if (decision.status === 'rejected') {
|
||||
this.approval.stats.rejected += 1
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加设备
|
||||
* @param {Object} equipment - 设备数据
|
||||
*/
|
||||
async addEquipment(equipment) {
|
||||
try {
|
||||
const response = await governmentApi.addEquipment(equipment)
|
||||
|
||||
// 更新本地数据
|
||||
this.warehouse.equipment.unshift(response.data)
|
||||
this.warehouse.stats.totalEquipment += 1
|
||||
this.warehouse.stats.availableEquipment += 1
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设备入库
|
||||
* @param {Object} inboundData - 入库数据
|
||||
*/
|
||||
async equipmentInbound(inboundData) {
|
||||
try {
|
||||
const response = await governmentApi.equipmentInbound(inboundData)
|
||||
|
||||
// 更新本地数据
|
||||
this.warehouse.inboundRecords.unshift(response.data)
|
||||
|
||||
// 更新设备状态
|
||||
const equipment = this.warehouse.equipment.find(e => e.id === inboundData.equipmentId)
|
||||
if (equipment) {
|
||||
equipment.quantity += inboundData.quantity
|
||||
equipment.status = 'available'
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设备出库
|
||||
* @param {Object} outboundData - 出库数据
|
||||
*/
|
||||
async equipmentOutbound(outboundData) {
|
||||
try {
|
||||
const response = await governmentApi.equipmentOutbound(outboundData)
|
||||
|
||||
// 更新本地数据
|
||||
this.warehouse.outboundRecords.unshift(response.data)
|
||||
|
||||
// 更新设备状态
|
||||
const equipment = this.warehouse.equipment.find(e => e.id === outboundData.equipmentId)
|
||||
if (equipment) {
|
||||
equipment.quantity -= outboundData.quantity
|
||||
if (equipment.quantity <= 0) {
|
||||
equipment.status = 'out_of_stock'
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加员工
|
||||
* @param {Object} staff - 员工数据
|
||||
*/
|
||||
async addStaff(staff) {
|
||||
try {
|
||||
const response = await governmentApi.addStaff(staff)
|
||||
|
||||
// 更新本地数据
|
||||
this.personnel.staff.unshift(response.data)
|
||||
this.personnel.stats.totalStaff += 1
|
||||
this.personnel.stats.activeStaff += 1
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新员工信息
|
||||
* @param {string} id - 员工ID
|
||||
* @param {Object} updates - 更新数据
|
||||
*/
|
||||
async updateStaff(id, updates) {
|
||||
try {
|
||||
const response = await governmentApi.updateStaff(id, updates)
|
||||
|
||||
// 更新本地数据
|
||||
const index = this.personnel.staff.findIndex(s => s.id === id)
|
||||
if (index > -1) {
|
||||
this.personnel.staff[index] = response.data
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新所有数据
|
||||
*/
|
||||
async refreshAllData() {
|
||||
await this.initializeGovernmentData()
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除错误信息
|
||||
* @param {string} module - 模块名称
|
||||
*/
|
||||
clearError(module) {
|
||||
if (module) {
|
||||
delete this.errors[module]
|
||||
} else {
|
||||
this.errors = {}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置模块数据
|
||||
* @param {string} module - 模块名称
|
||||
*/
|
||||
resetModuleData(module) {
|
||||
if (this[module]) {
|
||||
// 重置为初始状态
|
||||
const initialState = this.$state[module]
|
||||
this[module] = { ...initialState }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 持久化配置
|
||||
persist: {
|
||||
key: 'government-admin-data',
|
||||
storage: localStorage,
|
||||
paths: ['visualization.charts', 'visualization.dataSources']
|
||||
}
|
||||
})
|
||||
14
government-admin/src/stores/index.js
Normal file
14
government-admin/src/stores/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
|
||||
// 导出所有store
|
||||
export { useUserStore } from './user'
|
||||
export { useAppStore } from './app'
|
||||
export { useTabsStore } from './tabs'
|
||||
export { useNotificationStore } from './notification'
|
||||
export { useGovernmentStore } from './government'
|
||||
407
government-admin/src/stores/notification.js
Normal file
407
government-admin/src/stores/notification.js
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 通知状态管理
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useNotificationStore = defineStore('notification', {
|
||||
state: () => ({
|
||||
// 通知列表
|
||||
notifications: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
title: '系统通知',
|
||||
content: '政府管理后台系统已成功启动',
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
category: 'system'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
title: '待办提醒',
|
||||
content: '您有3个审批任务待处理',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: false,
|
||||
category: 'task'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
title: '操作成功',
|
||||
content: '设备入库操作已完成',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
read: true,
|
||||
category: 'operation'
|
||||
}
|
||||
],
|
||||
|
||||
// 通知设置
|
||||
settings: {
|
||||
// 是否启用桌面通知
|
||||
desktop: true,
|
||||
// 是否启用声音提醒
|
||||
sound: true,
|
||||
// 通知显示时长(毫秒)
|
||||
duration: 4500,
|
||||
// 最大显示数量
|
||||
maxVisible: 5,
|
||||
// 自动清理已读通知(天数)
|
||||
autoCleanDays: 7
|
||||
},
|
||||
|
||||
// 当前显示的toast通知
|
||||
toasts: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 未读通知数量
|
||||
unreadCount: (state) => {
|
||||
return state.notifications.filter(n => !n.read).length
|
||||
},
|
||||
|
||||
// 按类型分组的通知
|
||||
notificationsByType: (state) => {
|
||||
return state.notifications.reduce((acc, notification) => {
|
||||
const type = notification.type
|
||||
if (!acc[type]) {
|
||||
acc[type] = []
|
||||
}
|
||||
acc[type].push(notification)
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
|
||||
// 按分类分组的通知
|
||||
notificationsByCategory: (state) => {
|
||||
return state.notifications.reduce((acc, notification) => {
|
||||
const category = notification.category
|
||||
if (!acc[category]) {
|
||||
acc[category] = []
|
||||
}
|
||||
acc[category].push(notification)
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
|
||||
// 最近的通知(按时间排序)
|
||||
recentNotifications: (state) => {
|
||||
return [...state.notifications]
|
||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
.slice(0, 10)
|
||||
},
|
||||
|
||||
// 未读通知
|
||||
unreadNotifications: (state) => {
|
||||
return state.notifications.filter(n => !n.read)
|
||||
},
|
||||
|
||||
// 今日通知
|
||||
todayNotifications: (state) => {
|
||||
const today = new Date().toDateString()
|
||||
return state.notifications.filter(n =>
|
||||
new Date(n.timestamp).toDateString() === today
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 添加通知
|
||||
* @param {Object} notification - 通知对象
|
||||
*/
|
||||
addNotification(notification) {
|
||||
const newNotification = {
|
||||
id: this.generateId(),
|
||||
type: notification.type || 'info',
|
||||
title: notification.title,
|
||||
content: notification.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
category: notification.category || 'general',
|
||||
...notification
|
||||
}
|
||||
|
||||
this.notifications.unshift(newNotification)
|
||||
|
||||
// 显示toast通知
|
||||
if (notification.showToast !== false) {
|
||||
this.showToast(newNotification)
|
||||
}
|
||||
|
||||
// 桌面通知
|
||||
if (this.settings.desktop && notification.desktop !== false) {
|
||||
this.showDesktopNotification(newNotification)
|
||||
}
|
||||
|
||||
// 声音提醒
|
||||
if (this.settings.sound && notification.sound !== false) {
|
||||
this.playNotificationSound()
|
||||
}
|
||||
|
||||
return newNotification.id
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记通知为已读
|
||||
* @param {string} id - 通知ID
|
||||
*/
|
||||
markAsRead(id) {
|
||||
const notification = this.notifications.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记所有通知为已读
|
||||
*/
|
||||
markAllAsRead() {
|
||||
this.notifications.forEach(notification => {
|
||||
notification.read = true
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除通知
|
||||
* @param {string} id - 通知ID
|
||||
*/
|
||||
removeNotification(id) {
|
||||
const index = this.notifications.findIndex(n => n.id === id)
|
||||
if (index > -1) {
|
||||
this.notifications.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有通知
|
||||
*/
|
||||
clearAllNotifications() {
|
||||
this.notifications = []
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空已读通知
|
||||
*/
|
||||
clearReadNotifications() {
|
||||
this.notifications = this.notifications.filter(n => !n.read)
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示Toast通知
|
||||
* @param {Object} notification - 通知对象
|
||||
*/
|
||||
showToast(notification) {
|
||||
const toast = {
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
content: notification.content,
|
||||
duration: notification.duration || this.settings.duration
|
||||
}
|
||||
|
||||
this.toasts.push(toast)
|
||||
|
||||
// 限制显示数量
|
||||
if (this.toasts.length > this.settings.maxVisible) {
|
||||
this.toasts.shift()
|
||||
}
|
||||
|
||||
// 自动移除
|
||||
if (toast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.removeToast(toast.id)
|
||||
}, toast.duration)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 移除Toast通知
|
||||
* @param {string} id - 通知ID
|
||||
*/
|
||||
removeToast(id) {
|
||||
const index = this.toasts.findIndex(t => t.id === id)
|
||||
if (index > -1) {
|
||||
this.toasts.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示桌面通知
|
||||
* @param {Object} notification - 通知对象
|
||||
*/
|
||||
showDesktopNotification(notification) {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
const desktopNotification = new Notification(notification.title, {
|
||||
body: notification.content,
|
||||
icon: '/favicon.ico',
|
||||
tag: notification.id
|
||||
})
|
||||
|
||||
desktopNotification.onclick = () => {
|
||||
window.focus()
|
||||
this.markAsRead(notification.id)
|
||||
desktopNotification.close()
|
||||
}
|
||||
|
||||
// 自动关闭
|
||||
setTimeout(() => {
|
||||
desktopNotification.close()
|
||||
}, this.settings.duration)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 请求桌面通知权限
|
||||
*/
|
||||
async requestDesktopPermission() {
|
||||
if ('Notification' in window) {
|
||||
const permission = await Notification.requestPermission()
|
||||
this.settings.desktop = permission === 'granted'
|
||||
return permission
|
||||
}
|
||||
return 'denied'
|
||||
},
|
||||
|
||||
/**
|
||||
* 播放通知声音
|
||||
*/
|
||||
playNotificationSound() {
|
||||
try {
|
||||
const audio = new Audio('/sounds/notification.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {
|
||||
// 忽略播放失败的错误
|
||||
})
|
||||
} catch (error) {
|
||||
// 忽略音频播放错误
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新通知设置
|
||||
* @param {Object} newSettings - 新设置
|
||||
*/
|
||||
updateSettings(newSettings) {
|
||||
this.settings = { ...this.settings, ...newSettings }
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @returns {string}
|
||||
*/
|
||||
generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
},
|
||||
|
||||
/**
|
||||
* 按类型筛选通知
|
||||
* @param {string} type - 通知类型
|
||||
* @returns {Array}
|
||||
*/
|
||||
getNotificationsByType(type) {
|
||||
return this.notifications.filter(n => n.type === type)
|
||||
},
|
||||
|
||||
/**
|
||||
* 按分类筛选通知
|
||||
* @param {string} category - 通知分类
|
||||
* @returns {Array}
|
||||
*/
|
||||
getNotificationsByCategory(category) {
|
||||
return this.notifications.filter(n => n.category === category)
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索通知
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {Array}
|
||||
*/
|
||||
searchNotifications(keyword) {
|
||||
if (!keyword) return this.notifications
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return this.notifications.filter(n =>
|
||||
n.title.toLowerCase().includes(lowerKeyword) ||
|
||||
n.content.toLowerCase().includes(lowerKeyword)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 自动清理过期通知
|
||||
*/
|
||||
autoCleanNotifications() {
|
||||
if (this.settings.autoCleanDays <= 0) return
|
||||
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - this.settings.autoCleanDays)
|
||||
|
||||
this.notifications = this.notifications.filter(n => {
|
||||
const notificationDate = new Date(n.timestamp)
|
||||
return !n.read || notificationDate > cutoffDate
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量操作通知
|
||||
* @param {Array} ids - 通知ID列表
|
||||
* @param {string} action - 操作类型 ('read', 'delete')
|
||||
*/
|
||||
batchOperation(ids, action) {
|
||||
ids.forEach(id => {
|
||||
if (action === 'read') {
|
||||
this.markAsRead(id)
|
||||
} else if (action === 'delete') {
|
||||
this.removeNotification(id)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出通知数据
|
||||
* @param {Object} options - 导出选项
|
||||
* @returns {Array}
|
||||
*/
|
||||
exportNotifications(options = {}) {
|
||||
let notifications = [...this.notifications]
|
||||
|
||||
// 按时间范围筛选
|
||||
if (options.startDate) {
|
||||
const startDate = new Date(options.startDate)
|
||||
notifications = notifications.filter(n =>
|
||||
new Date(n.timestamp) >= startDate
|
||||
)
|
||||
}
|
||||
|
||||
if (options.endDate) {
|
||||
const endDate = new Date(options.endDate)
|
||||
notifications = notifications.filter(n =>
|
||||
new Date(n.timestamp) <= endDate
|
||||
)
|
||||
}
|
||||
|
||||
// 按类型筛选
|
||||
if (options.types && options.types.length > 0) {
|
||||
notifications = notifications.filter(n =>
|
||||
options.types.includes(n.type)
|
||||
)
|
||||
}
|
||||
|
||||
// 按分类筛选
|
||||
if (options.categories && options.categories.length > 0) {
|
||||
notifications = notifications.filter(n =>
|
||||
options.categories.includes(n.category)
|
||||
)
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
},
|
||||
|
||||
// 持久化配置
|
||||
persist: {
|
||||
key: 'government-admin-notifications',
|
||||
storage: localStorage,
|
||||
paths: ['notifications', 'settings']
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user