添加银行小程序

This commit is contained in:
2025-09-18 12:33:04 +08:00
parent 473891163c
commit 57adeaa42c
57 changed files with 34190 additions and 2 deletions

View File

@@ -7,7 +7,7 @@ Param(
[string]$AdminPlain = 'Admin123456'
)
Write-Host "Using DB: $Host:$Port/$Database"
Write-Host "Using DB: ${Host}:${Port}/${Database}"
# 生成管理员 bcrypt 哈希
try {
@@ -35,7 +35,7 @@ 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
Get-Content $tmp | mysql --host=$Host --port=$Port --user=$User --database=$Database --default-character-set=utf8mb4 --protocol=TCP
if ($LASTEXITCODE -ne 0) { throw "mysql returned $LASTEXITCODE" }
Write-Host "✅ Schema & seed executed successfully"
} catch {

View File

@@ -0,0 +1,44 @@
# 开发环境配置
NODE_ENV=development
# API 基础地址
VUE_APP_API_BASE_URL=https://dev-api.bank.com/api/v1
# WebSocket 地址
VUE_APP_WS_URL=wss://dev-ws.bank.com
# 应用标题
VUE_APP_TITLE=银行端资产监管系统(开发版)
# 是否启用调试模式
VUE_APP_DEBUG=true
# 是否启用 Mock 数据
VUE_APP_MOCK=true
# 日志级别
VUE_APP_LOG_LEVEL=debug
# 加密密钥(开发环境)
VUE_APP_ENCRYPT_KEY=dev_encrypt_key_2023
# 微信小程序 AppID开发版
VUE_APP_WECHAT_APPID=wx1234567890abcdef
# 百度地图 API Key开发环境
VUE_APP_BAIDU_MAP_KEY=dev_baidu_map_key
# 上传文件大小限制MB
VUE_APP_UPLOAD_SIZE_LIMIT=10
# 分页默认大小
VUE_APP_PAGE_SIZE=20
# 请求超时时间(毫秒)
VUE_APP_REQUEST_TIMEOUT=30000
# 是否启用性能监控
VUE_APP_PERFORMANCE_MONITOR=true
# 错误上报地址
VUE_APP_ERROR_REPORT_URL=https://dev-error.bank.com/report

View File

@@ -0,0 +1,44 @@
# 生产环境配置
NODE_ENV=production
# API 基础地址
VUE_APP_API_BASE_URL=https://api.bank.com/api/v1
# WebSocket 地址
VUE_APP_WS_URL=wss://ws.bank.com
# 应用标题
VUE_APP_TITLE=银行端资产监管系统
# 是否启用调试模式
VUE_APP_DEBUG=false
# 是否启用 Mock 数据
VUE_APP_MOCK=false
# 日志级别
VUE_APP_LOG_LEVEL=error
# 加密密钥(生产环境 - 请在部署时替换)
VUE_APP_ENCRYPT_KEY=prod_encrypt_key_2023_replace_me
# 微信小程序 AppID生产版
VUE_APP_WECHAT_APPID=wxabcdef1234567890
# 百度地图 API Key生产环境
VUE_APP_BAIDU_MAP_KEY=prod_baidu_map_key
# 上传文件大小限制MB
VUE_APP_UPLOAD_SIZE_LIMIT=50
# 分页默认大小
VUE_APP_PAGE_SIZE=20
# 请求超时时间(毫秒)
VUE_APP_REQUEST_TIMEOUT=60000
# 是否启用性能监控
VUE_APP_PERFORMANCE_MONITOR=true
# 错误上报地址
VUE_APP_ERROR_REPORT_URL=https://error.bank.com/report

View File

@@ -0,0 +1,164 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
'vue/setup-compiler-macros': true
},
extends: [
'eslint:recommended',
'@vue/eslint-config-typescript',
'plugin:vue/vue3-essential',
'plugin:vue/vue3-strongly-recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module'
},
plugins: [
'vue',
'@typescript-eslint'
],
rules: {
// Vue 规则
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'vue/require-explicit-emits': 'off',
'vue/html-self-closing': ['error', {
'html': {
'void': 'never',
'normal': 'always',
'component': 'always'
},
'svg': 'always',
'math': 'always'
}],
'vue/max-attributes-per-line': ['error', {
'singleline': 3,
'multiline': 1
}],
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/html-closing-bracket-newline': ['error', {
'singleline': 'never',
'multiline': 'always'
}],
'vue/html-indent': ['error', 2],
'vue/script-indent': ['error', 2, {
'baseIndent': 0,
'switchCase': 1
}],
// TypeScript 规则
'@typescript-eslint/no-unused-vars': ['error', {
'argsIgnorePattern': '^_',
'varsIgnorePattern': '^_'
}],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
// JavaScript 规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'off', // 使用 TypeScript 版本
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
'template-curly-spacing': 'error',
'yield-star-spacing': 'error',
'prefer-rest-params': 'error',
'no-useless-escape': 'off',
'no-prototype-builtins': 'off',
'no-case-declarations': 'off',
// 代码风格
'indent': ['error', 2, {
'SwitchCase': 1,
'VariableDeclarator': 1,
'outerIIFEBody': 1,
'MemberExpression': 1,
'FunctionDeclaration': { 'parameters': 1, 'body': 1 },
'FunctionExpression': { 'parameters': 1, 'body': 1 },
'CallExpression': { 'arguments': 1 },
'ArrayExpression': 1,
'ObjectExpression': 1,
'ImportDeclaration': 1,
'flatTernaryExpressions': false,
'ignoreComments': false
}],
'quotes': ['error', 'single', { 'avoidEscape': true }],
'semi': ['error', 'never'],
'comma-dangle': ['error', 'never'],
'comma-spacing': ['error', { 'before': false, 'after': true }],
'comma-style': ['error', 'last'],
'key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'keyword-spacing': ['error', { 'before': true, 'after': true }],
'object-curly-spacing': ['error', 'always'],
'array-bracket-spacing': ['error', 'never'],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', {
'anonymous': 'always',
'named': 'never',
'asyncArrow': 'always'
}],
'space-in-parens': ['error', 'never'],
'space-infix-ops': 'error',
'space-unary-ops': ['error', { 'words': true, 'nonwords': false }],
'spaced-comment': ['error', 'always', {
'line': { 'markers': ['*package', '!', '/', ',', '='] },
'block': { 'balanced': true, 'markers': ['*package', '!', ',', ':', '::', 'flow-include'], 'exceptions': ['*'] }
}],
'brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'camelcase': ['error', { 'properties': 'never' }],
'eol-last': 'error',
'func-call-spacing': ['error', 'never'],
'new-cap': ['error', { 'newIsCap': true, 'capIsNew': false }],
'new-parens': 'error',
'no-array-constructor': 'error',
'no-mixed-spaces-and-tabs': 'error',
'no-multiple-empty-lines': ['error', { 'max': 1, 'maxEOF': 0 }],
'no-new-object': 'error',
'no-tabs': 'error',
'no-trailing-spaces': 'error',
'no-whitespace-before-property': 'error',
'padded-blocks': ['error', 'never'],
'rest-spread-spacing': ['error', 'never'],
'semi-spacing': ['error', { 'before': false, 'after': true }]
},
globals: {
uni: 'readonly',
wx: 'readonly',
getCurrentPages: 'readonly',
getApp: 'readonly',
App: 'readonly',
Page: 'readonly',
Component: 'readonly',
Behavior: 'readonly',
plus: 'readonly'
},
overrides: [
{
files: ['*.vue'],
rules: {
'indent': 'off'
}
},
{
files: ['**/__tests__/**/*', '**/*.{test,spec}.*'],
env: {
jest: true
}
}
]
}

View File

@@ -0,0 +1,242 @@
# 更新日志
本文档记录了银行端资产监管小程序的所有重要更改。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [未发布]
### 新增
- 初始化项目结构
- 完成基础架构搭建
- 实现用户认证功能
- 添加资产管理模块
- 实现监控中心功能
- 完成数据报表模块
- 添加个人中心页面
- 实现通用组件库
- 配置开发和构建环境
- 添加单元测试框架
### 变更
-
### 修复
-
### 移除
-
## [1.0.0] - 2024-01-20
### 新增
- 🎉 项目初始化
- 📱 基于 uni-app 的跨平台架构
- 🔐 用户登录和认证系统
- 📊 资产管理核心功能
- 🔍 实时监控和预警系统
- 📈 数据统计和报表分析
- 🎨 响应式 UI 设计
- 🛠️ 完整的开发工具链
- 📝 TypeScript 类型支持
- 🧪 单元测试框架
#### 功能模块
**认证模块**
- 用户登录/登出
- 密码修改
- 会话管理
- 权限控制
**资产管理**
- 资产列表展示
- 资产详情查看
- 资产状态管理
- 搜索和筛选功能
**监控中心**
- 实时数据监控
- 预警信息管理
- 设备状态监控
- 异常事件处理
**数据报表**
- 统计数据展示
- 可视化图表
- 报表导出
- 自定义时间范围
**个人中心**
- 用户信息管理
- 系统设置
- 消息通知
- 帮助文档
#### 技术特性
**开发体验**
- 🚀 Vite 快速构建
- 🔥 热重载开发
- 📦 自动化打包
- 🎯 TypeScript 支持
- 🧹 ESLint 代码检查
- 🎨 Prettier 代码格式化
**性能优化**
- 📱 响应式设计
- ⚡ 懒加载
- 🗜️ 代码分割
- 🎯 按需加载
- 💾 本地缓存
- 🔄 请求优化
**用户体验**
- 🎨 现代化 UI 设计
- 📱 移动端适配
- 🔄 加载状态提示
- 💫 流畅动画效果
- 🎯 直观的操作流程
- 📊 丰富的数据可视化
#### 组件库
**通用组件**
- LoadingSpinner - 加载动画组件
- EmptyState - 空状态组件
- StatusTag - 状态标签组件
- ActionSheet - 操作面板组件
**业务组件**
- AssetCard - 资产卡片
- MonitorChart - 监控图表
- ReportTable - 报表表格
- AlertList - 预警列表
#### 工具函数
**网络请求**
- HTTP 请求封装
- 请求拦截器
- 响应处理
- 错误处理
**数据处理**
- 格式化工具
- 验证工具
- 加密工具
- 存储工具
#### 配置文件
**环境配置**
- 开发环境配置
- 生产环境配置
- 测试环境配置
**构建配置**
- Vite 配置
- TypeScript 配置
- ESLint 配置
- Jest 测试配置
### 技术栈
- **前端框架**: Vue 3.3+ with Composition API
- **开发框架**: uni-app 3.0+
- **构建工具**: Vite 4.0+
- **类型系统**: TypeScript 5.0+
- **状态管理**: Pinia 2.0+
- **样式预处理**: SCSS
- **网络请求**: Axios
- **图表库**: ECharts
- **工具库**: dayjs, lodash-es
- **加密库**: crypto-js
- **测试框架**: Jest + Vue Test Utils
- **代码规范**: ESLint + Prettier
### 兼容性
- **微信小程序**: 支持
- **H5**: 支持
- **App**: 支持 (iOS/Android)
- **支付宝小程序**: 计划支持
- **百度小程序**: 计划支持
### 浏览器支持
- Chrome >= 88
- Firefox >= 85
- Safari >= 14
- Edge >= 88
- 微信内置浏览器
- 各平台小程序环境
---
## 版本说明
### 版本号规则
本项目采用语义化版本号 (Semantic Versioning)
- **主版本号 (MAJOR)**: 不兼容的 API 修改
- **次版本号 (MINOR)**: 向下兼容的功能性新增
- **修订号 (PATCH)**: 向下兼容的问题修正
### 发布周期
- **主版本**: 根据重大功能更新发布
- **次版本**: 每月发布一次
- **修订版本**: 根据 bug 修复情况随时发布
### 维护策略
- 当前版本: 持续维护和更新
- 前一个主版本: 提供 bug 修复支持
- 更早版本: 不再维护
---
## 贡献指南
### 如何贡献
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
### 提交信息规范
请使用以下格式提交代码:
```
<type>(<scope>): <subject>
<body>
<footer>
```
**Type 类型:**
- `feat`: 新功能
- `fix`: 修复 bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 代码重构
- `test`: 测试相关
- `chore`: 构建工具或辅助工具的变动
**示例:**
```
feat(auth): 添加用户登录功能
- 实现用户名密码登录
- 添加记住密码功能
- 集成验证码验证
Closes #123
```

295
bank_mini_program/README.md Normal file
View File

@@ -0,0 +1,295 @@
# 银行端资产监管小程序
## 项目简介
银行端资产监管小程序是一个基于 uni-app 框架开发的跨平台应用,主要用于银行对抵押资产进行实时监管和管理。系统提供了资产查看、监控预警、数据统计、报表分析等核心功能。
## 技术栈
- **框架**: uni-app (Vue 3 + TypeScript)
- **构建工具**: Vite 4.x
- **状态管理**: Pinia
- **UI组件**: 自定义组件库
- **样式**: SCSS
- **网络请求**: Axios
- **图表**: ECharts
- **工具库**: dayjs, lodash-es
- **加密**: crypto-js
## 项目结构
```
bank_mini_program/
├── src/ # 源代码目录
│ ├── api/ # API 接口
│ │ ├── auth.ts # 认证相关接口
│ │ ├── asset.ts # 资产相关接口
│ │ ├── monitor.ts # 监控相关接口
│ │ └── report.ts # 报表相关接口
│ ├── components/ # 组件目录
│ │ ├── common/ # 通用组件
│ │ │ ├── LoadingSpinner.vue # 加载动画
│ │ │ ├── EmptyState.vue # 空状态
│ │ │ ├── StatusTag.vue # 状态标签
│ │ │ └── ActionSheet.vue # 操作面板
│ │ └── business/ # 业务组件
│ ├── pages/ # 页面目录
│ │ ├── index/ # 首页
│ │ ├── assets/ # 资产管理
│ │ ├── monitor/ # 监控中心
│ │ ├── reports/ # 数据报表
│ │ ├── profile/ # 个人中心
│ │ └── auth/ # 认证相关
│ ├── stores/ # 状态管理
│ │ ├── auth.ts # 认证状态
│ │ ├── asset.ts # 资产状态
│ │ └── app.ts # 应用状态
│ ├── utils/ # 工具函数
│ │ ├── request.ts # 网络请求
│ │ ├── storage.ts # 本地存储
│ │ ├── crypto.ts # 加密工具
│ │ ├── format.ts # 格式化工具
│ │ └── validate.ts # 验证工具
│ ├── types/ # 类型定义
│ │ ├── api.ts # API 类型
│ │ ├── asset.ts # 资产类型
│ │ └── common.ts # 通用类型
│ ├── styles/ # 样式文件
│ │ ├── variables.scss # SCSS 变量
│ │ ├── mixins.scss # SCSS 混合器
│ │ └── utilities.scss # 工具类样式
│ ├── static/ # 静态资源
│ ├── App.vue # 应用入口
│ ├── main.ts # 主入口文件
│ ├── pages.json # 页面配置
│ ├── manifest.json # 应用配置
│ └── uni.scss # 全局样式
├── tests/ # 测试目录
│ ├── setup.ts # 测试设置
│ └── utils/ # 测试工具
├── .env.development # 开发环境配置
├── .env.production # 生产环境配置
├── .eslintrc.js # ESLint 配置
├── jest.config.ts # Jest 配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── package.json # 项目依赖
```
## 功能特性
### 🏠 首页
- 资产概览统计
- 快速操作入口
- 重要通知展示
- 实时监控状态
### 📊 资产管理
- 资产列表查看
- 资产详情展示
- 资产状态管理
- 资产搜索筛选
### 🔍 监控中心
- 实时监控数据
- 预警信息管理
- 设备状态监控
- 异常事件处理
### 📈 数据报表
- 数据统计分析
- 可视化图表展示
- 报表导出功能
- 自定义时间范围
### 👤 个人中心
- 用户信息管理
- 系统设置
- 消息通知
- 帮助支持
## 快速开始
### 环境要求
- Node.js >= 16.0.0
- npm >= 8.0.0 或 yarn >= 1.22.0
- HBuilderX (推荐) 或 VS Code
### 安装依赖
```bash
# 使用 npm
npm install
# 或使用 yarn
yarn install
```
### 开发调试
```bash
# 启动开发服务器
npm run dev:h5
# 微信小程序开发
npm run dev:mp-weixin
# App 开发
npm run dev:app-plus
```
### 构建打包
```bash
# 构建 H5
npm run build:h5
# 构建微信小程序
npm run build:mp-weixin
# 构建 App
npm run build:app-plus
```
### 代码检查
```bash
# 运行 ESLint
npm run lint
# 自动修复代码风格问题
npm run lint:fix
# 类型检查
npm run type-check
```
### 运行测试
```bash
# 运行单元测试
npm run test
# 监听模式运行测试
npm run test:watch
# 生成测试覆盖率报告
npm run test:coverage
```
## 环境配置
### 开发环境
复制 `.env.development` 文件并根据实际情况修改配置:
```bash
# API 基础地址
VUE_APP_API_BASE_URL=https://dev-api.bank.com/api/v1
# 是否启用 Mock 数据
VUE_APP_MOCK=true
# 调试模式
VUE_APP_DEBUG=true
```
### 生产环境
复制 `.env.production` 文件并配置生产环境参数:
```bash
# API 基础地址
VUE_APP_API_BASE_URL=https://api.bank.com/api/v1
# 关闭 Mock 数据
VUE_APP_MOCK=false
# 关闭调试模式
VUE_APP_DEBUG=false
```
## 开发规范
### 代码风格
- 使用 ESLint + Prettier 进行代码格式化
- 遵循 Vue 3 Composition API 最佳实践
- 使用 TypeScript 进行类型约束
- 组件命名采用 PascalCase
- 文件命名采用 kebab-case
### 提交规范
使用 Conventional Commits 规范:
```
feat: 新功能
fix: 修复问题
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建工具或辅助工具的变动
```
### 组件开发
- 优先使用 Composition API
- 合理使用 Props 和 Emits
- 添加适当的类型定义
- 编写单元测试
## 部署说明
### 微信小程序部署
1. 使用 HBuilderX 打开项目
2. 运行到微信开发者工具
3. 在微信开发者工具中预览和上传
### H5 部署
1. 执行 `npm run build:h5`
2.`dist/build/h5` 目录部署到 Web 服务器
### App 部署
1. 使用 HBuilderX 云打包
2. 或使用离线打包 SDK
## 常见问题
### Q: 如何添加新的 API 接口?
A: 在 `src/api/` 目录下创建对应的接口文件,使用统一的请求工具。
### Q: 如何添加新的页面?
A:
1.`src/pages/` 目录下创建页面文件
2.`pages.json` 中配置页面路由
3. 如需要,在 `manifest.json` 中配置页面权限
### Q: 如何自定义主题?
A: 修改 `src/styles/variables.scss` 文件中的 SCSS 变量。
### Q: 如何处理跨平台兼容性?
A: 使用 uni-app 的条件编译语法,针对不同平台编写特定代码。
## 技术支持
- 项目文档:[查看详细文档](./docs/)
- 问题反馈:[提交 Issue](https://github.com/your-repo/issues)
- 技术交流:联系开发团队
## 许可证
本项目采用 MIT 许可证,详情请查看 [LICENSE](./LICENSE) 文件。
## 更新日志
查看 [CHANGELOG.md](./CHANGELOG.md) 了解版本更新详情。

View File

@@ -0,0 +1,704 @@
# API 接口文档
## 概述
本文档描述了银行端资产监管小程序的所有 API 接口,包括请求格式、响应格式、错误码等详细信息。
## 基础信息
- **Base URL**: `https://api.bank.com/api/v1`
- **Content-Type**: `application/json`
- **字符编码**: `UTF-8`
- **请求方式**: `GET`, `POST`, `PUT`, `DELETE`
## 认证方式
所有需要认证的接口都需要在请求头中携带 JWT Token
```http
Authorization: Bearer <your_jwt_token>
```
## 通用响应格式
### 成功响应
```json
{
"code": 0,
"message": "success",
"data": {
// 具体数据
},
"timestamp": 1640995200000
}
```
### 错误响应
```json
{
"code": -1,
"message": "错误描述",
"data": null,
"timestamp": 1640995200000
}
```
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 0 | 成功 |
| -1 | 系统错误 |
| 1001 | 参数错误 |
| 1002 | 用户未登录 |
| 1003 | 权限不足 |
| 1004 | 用户不存在 |
| 1005 | 密码错误 |
| 1006 | 验证码错误 |
| 1007 | 账号已被锁定 |
| 2001 | 资产不存在 |
| 2002 | 资产状态异常 |
| 3001 | 设备离线 |
| 3002 | 数据异常 |
---
## 认证接口
### 用户登录
**接口地址**: `POST /auth/login`
**请求参数**:
```json
{
"username": "string", // 用户名
"password": "string", // 密码
"captcha": "string", // 验证码
"captchaId": "string" // 验证码ID
}
```
**响应数据**:
```json
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "refresh_token_string",
"expiresIn": 7200,
"user": {
"id": 1,
"username": "admin",
"name": "管理员",
"avatar": "https://example.com/avatar.jpg",
"role": "admin",
"permissions": ["asset:read", "asset:write"]
}
}
}
```
### 刷新Token
**接口地址**: `POST /auth/refresh`
**请求参数**:
```json
{
"refreshToken": "string"
}
```
### 用户登出
**接口地址**: `POST /auth/logout`
**请求头**: 需要 Authorization
---
## 用户管理接口
### 获取用户信息
**接口地址**: `GET /user/profile`
**请求头**: 需要 Authorization
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"username": "admin",
"name": "管理员",
"email": "admin@bank.com",
"phone": "13800138000",
"avatar": "https://example.com/avatar.jpg",
"role": "admin",
"department": "风控部",
"createdAt": "2024-01-01T00:00:00.000Z",
"lastLoginAt": "2024-01-20T10:30:00.000Z"
}
}
```
### 修改用户信息
**接口地址**: `PUT /user/profile`
**请求头**: 需要 Authorization
**请求参数**:
```json
{
"name": "string",
"email": "string",
"phone": "string",
"avatar": "string"
}
```
### 修改密码
**接口地址**: `PUT /user/password`
**请求头**: 需要 Authorization
**请求参数**:
```json
{
"oldPassword": "string",
"newPassword": "string"
}
```
---
## 资产管理接口
### 获取资产列表
**接口地址**: `GET /assets`
**请求头**: 需要 Authorization
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码默认1 |
| pageSize | number | 否 | 每页数量默认20 |
| keyword | string | 否 | 搜索关键词 |
| status | string | 否 | 资产状态 |
| type | string | 否 | 资产类型 |
| farmId | number | 否 | 养殖场ID |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"list": [
{
"id": 1,
"name": "资产名称",
"type": "livestock",
"status": "normal",
"value": 50000,
"quantity": 100,
"farm": {
"id": 1,
"name": "示例养殖场",
"address": "北京市朝阳区"
},
"location": {
"latitude": 39.9042,
"longitude": 116.4074
},
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-20T10:30:00.000Z"
}
],
"total": 100,
"page": 1,
"pageSize": 20,
"totalPages": 5
}
}
```
### 获取资产详情
**接口地址**: `GET /assets/{id}`
**请求头**: 需要 Authorization
**路径参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | number | 是 | 资产ID |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"name": "资产名称",
"type": "livestock",
"status": "normal",
"value": 50000,
"quantity": 100,
"description": "资产描述",
"farm": {
"id": 1,
"name": "示例养殖场",
"address": "北京市朝阳区",
"contact": "张三",
"phone": "13800138000"
},
"location": {
"latitude": 39.9042,
"longitude": 116.4074,
"address": "详细地址"
},
"images": [
"https://example.com/image1.jpg",
"https://example.com/image2.jpg"
],
"devices": [
{
"id": 1,
"name": "监控设备1",
"type": "camera",
"status": "online"
}
],
"insurance": {
"company": "保险公司",
"policyNumber": "保单号",
"amount": 60000,
"startDate": "2024-01-01",
"endDate": "2024-12-31"
},
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-20T10:30:00.000Z"
}
}
```
### 更新资产状态
**接口地址**: `PUT /assets/{id}/status`
**请求头**: 需要 Authorization
**请求参数**:
```json
{
"status": "string", // 新状态
"reason": "string" // 变更原因
}
```
---
## 监控管理接口
### 获取监控数据
**接口地址**: `GET /monitor/data`
**请求头**: 需要 Authorization
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| assetId | number | 否 | 资产ID |
| deviceId | number | 否 | 设备ID |
| startTime | string | 否 | 开始时间 |
| endTime | string | 否 | 结束时间 |
| type | string | 否 | 数据类型 |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"realtime": {
"temperature": 25.5,
"humidity": 60,
"count": 98,
"status": "normal",
"lastUpdate": "2024-01-20T10:30:00.000Z"
},
"history": [
{
"timestamp": "2024-01-20T10:00:00.000Z",
"temperature": 25.0,
"humidity": 58,
"count": 98
}
]
}
}
```
### 获取预警信息
**接口地址**: `GET /monitor/alerts`
**请求头**: 需要 Authorization
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码 |
| pageSize | number | 否 | 每页数量 |
| level | string | 否 | 预警级别 |
| status | string | 否 | 处理状态 |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"list": [
{
"id": 1,
"type": "temperature",
"level": "warning",
"title": "温度异常",
"message": "当前温度超出正常范围",
"value": 35.5,
"threshold": 30,
"asset": {
"id": 1,
"name": "资产名称"
},
"device": {
"id": 1,
"name": "温度传感器"
},
"status": "pending",
"createdAt": "2024-01-20T10:30:00.000Z"
}
],
"total": 50,
"page": 1,
"pageSize": 20
}
}
```
### 处理预警
**接口地址**: `PUT /monitor/alerts/{id}/handle`
**请求头**: 需要 Authorization
**请求参数**:
```json
{
"action": "string", // 处理动作
"remark": "string" // 处理备注
}
```
---
## 报表统计接口
### 获取统计概览
**接口地址**: `GET /reports/overview`
**请求头**: 需要 Authorization
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| startDate | string | 否 | 开始日期 |
| endDate | string | 否 | 结束日期 |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"totalAssets": 1000,
"totalValue": 50000000,
"normalAssets": 950,
"abnormalAssets": 50,
"onlineDevices": 800,
"offlineDevices": 200,
"todayAlerts": 5,
"pendingAlerts": 3
}
}
```
### 获取趋势数据
**接口地址**: `GET /reports/trends`
**请求头**: 需要 Authorization
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | 数据类型 |
| period | string | 是 | 时间周期 |
| startDate | string | 否 | 开始日期 |
| endDate | string | 否 | 结束日期 |
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"labels": ["2024-01-01", "2024-01-02", "2024-01-03"],
"datasets": [
{
"name": "资产数量",
"data": [100, 102, 98]
},
{
"name": "资产价值",
"data": [5000000, 5100000, 4900000]
}
]
}
}
```
### 导出报表
**接口地址**: `POST /reports/export`
**请求头**: 需要 Authorization
**请求参数**:
```json
{
"type": "string", // 报表类型
"format": "string", // 导出格式 (excel/pdf)
"startDate": "string", // 开始日期
"endDate": "string", // 结束日期
"filters": {} // 筛选条件
}
```
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"downloadUrl": "https://example.com/reports/export_20240120.xlsx",
"filename": "资产报表_20240120.xlsx",
"size": 1024000
}
}
```
---
## 系统配置接口
### 获取系统配置
**接口地址**: `GET /system/config`
**请求头**: 需要 Authorization
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": {
"app": {
"name": "银行端资产监管系统",
"version": "1.0.0",
"logo": "https://example.com/logo.png"
},
"features": {
"realTimeMonitor": true,
"alertNotification": true,
"reportExport": true
},
"limits": {
"maxUploadSize": 10485760,
"maxAssets": 10000
}
}
}
```
### 获取字典数据
**接口地址**: `GET /system/dict/{type}`
**请求头**: 需要 Authorization
**响应数据**:
```json
{
"code": 0,
"message": "success",
"data": [
{
"value": "livestock",
"label": "牲畜",
"color": "#1890ff"
},
{
"value": "equipment",
"label": "设备",
"color": "#52c41a"
}
]
}
```
---
## 文件上传接口
### 上传文件
**接口地址**: `POST /upload`
**请求头**: 需要 Authorization, Content-Type: multipart/form-data
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file | File | 是 | 上传的文件 |
| type | string | 否 | 文件类型 |
**响应数据**:
```json
{
"code": 0,
"message": "上传成功",
"data": {
"url": "https://example.com/uploads/image.jpg",
"filename": "image.jpg",
"size": 1024000,
"type": "image/jpeg"
}
}
```
---
## WebSocket 接口
### 连接地址
`wss://ws.bank.com/ws?token=<jwt_token>`
### 消息格式
```json
{
"type": "string", // 消息类型
"data": {}, // 消息数据
"timestamp": 1640995200000
}
```
### 消息类型
| 类型 | 说明 |
|------|------|
| alert | 预警消息 |
| device_status | 设备状态变化 |
| asset_update | 资产信息更新 |
| system_notice | 系统通知 |
### 示例消息
```json
{
"type": "alert",
"data": {
"id": 1,
"level": "warning",
"title": "温度异常",
"assetId": 1,
"deviceId": 1
},
"timestamp": 1640995200000
}
```
---
## 接口测试
### 测试环境
- **Base URL**: `https://dev-api.bank.com/api/v1`
- **测试账号**: `test@bank.com`
- **测试密码**: `123456`
### Postman 集合
可以导入我们提供的 Postman 集合文件进行接口测试:
[下载 Postman 集合](./postman/bank-mini-program.postman_collection.json)
### 接口限制
- 请求频率限制:每分钟最多 100 次请求
- 文件上传大小限制:单个文件最大 10MB
- 并发连接限制:每个用户最多 5 个 WebSocket 连接
---
## 更新说明
本文档会随着 API 的更新而持续维护,请关注版本变更。
最后更新时间2024-01-20

View File

@@ -0,0 +1,741 @@
# 部署指南
本文档详细介绍了银行端资产监管小程序在不同平台的部署方法和注意事项。
## 目录
- [环境要求](#环境要求)
- [构建准备](#构建准备)
- [微信小程序部署](#微信小程序部署)
- [H5 部署](#h5-部署)
- [App 部署](#app-部署)
- [生产环境配置](#生产环境配置)
- [性能优化](#性能优化)
- [监控和日志](#监控和日志)
- [常见问题](#常见问题)
---
## 环境要求
### 开发环境
- **Node.js**: >= 16.0.0
- **npm**: >= 8.0.0 或 **yarn**: >= 1.22.0
- **HBuilderX**: >= 3.6.0 (推荐)
- **微信开发者工具**: 最新稳定版
- **Git**: >= 2.20.0
### 系统要求
- **操作系统**: Windows 10+, macOS 10.15+, Ubuntu 18.04+
- **内存**: >= 8GB
- **存储空间**: >= 10GB 可用空间
---
## 构建准备
### 1. 克隆项目
```bash
git clone https://github.com/your-org/bank-mini-program.git
cd bank-mini-program
```
### 2. 安装依赖
```bash
# 使用 npm
npm install
# 或使用 yarn
yarn install
```
### 3. 环境配置
复制环境配置文件并修改相应配置:
```bash
# 开发环境
cp .env.development .env.local
# 生产环境
cp .env.production .env.production.local
```
### 4. 代码检查
```bash
# 运行代码检查
npm run lint
# 自动修复代码风格问题
npm run lint:fix
# TypeScript 类型检查
npm run type-check
```
### 5. 运行测试
```bash
# 运行单元测试
npm run test
# 生成测试覆盖率报告
npm run test:coverage
```
---
## 微信小程序部署
### 1. 开发环境调试
#### 使用 HBuilderX
1. 打开 HBuilderX导入项目
2. 点击菜单 `运行` -> `运行到小程序模拟器` -> `微信开发者工具`
3. 首次运行会自动打开微信开发者工具
#### 使用命令行
```bash
# 启动微信小程序开发模式
npm run dev:mp-weixin
```
### 2. 生产环境构建
```bash
# 构建微信小程序
npm run build:mp-weixin
```
构建完成后,产物位于 `dist/build/mp-weixin` 目录。
### 3. 微信开发者工具配置
1. 打开微信开发者工具
2. 选择 `导入项目`
3. 项目目录选择 `dist/build/mp-weixin`
4. AppID 填写你的小程序 AppID
5. 项目名称自定义
### 4. 发布到微信小程序
1. 在微信开发者工具中点击 `上传`
2. 填写版本号和项目备注
3. 上传成功后,登录微信公众平台
4.`版本管理` 中提交审核
5. 审核通过后发布
### 5. 微信小程序配置
#### manifest.json 配置
```json
{
"mp-weixin": {
"appid": "your_wechat_appid",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于小程序位置接口的效果展示"
}
},
"requiredPrivateInfos": [
"getLocation"
]
}
}
```
---
## H5 部署
### 1. 开发环境
```bash
# 启动 H5 开发服务器
npm run dev:h5
```
访问 `http://localhost:3000` 查看效果。
### 2. 生产环境构建
```bash
# 构建 H5 版本
npm run build:h5
```
构建完成后,产物位于 `dist/build/h5` 目录。
### 3. 服务器部署
#### Nginx 配置
```nginx
server {
listen 80;
server_name your-domain.com;
root /var/www/bank-mini-program;
index index.html;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
# API 代理
location /api/ {
proxy_pass http://your-api-server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
#### Apache 配置
```apache
<VirtualHost *:80>
ServerName your-domain.com
DocumentRoot /var/www/bank-mini-program
# 启用压缩
LoadModule deflate_module modules/mod_deflate.so
<Location />
SetOutputFilter DEFLATE
SetEnvIfNoCase Request_URI \
\.(?:gif|jpe?g|png)$ no-gzip dont-vary
SetEnvIfNoCase Request_URI \
\.(?:exe|t?gz|zip|bz2|sit|rar)$ no-gzip dont-vary
</Location>
# SPA 路由支持
<Directory "/var/www/bank-mini-program">
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</Directory>
</VirtualHost>
```
### 4. CDN 配置
推荐使用 CDN 加速静态资源访问:
```javascript
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
},
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (process.env.NODE_ENV === 'production') {
return `https://cdn.your-domain.com/${filename}`
}
return filename
}
}
})
```
---
## App 部署
### 1. 开发环境
```bash
# 启动 App 开发模式
npm run dev:app-plus
```
### 2. 生产环境构建
```bash
# 构建 App
npm run build:app-plus
```
### 3. 云打包
#### 使用 HBuilderX 云打包
1. 在 HBuilderX 中打开项目
2. 点击菜单 `发行` -> `原生App-云打包`
3. 选择打包类型(测试包/正式包)
4. 配置证书和描述文件
5. 点击打包
#### 配置 manifest.json
```json
{
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {
"Camera": {},
"Gallery": {},
"Maps": {},
"Geolocation": {},
"Push": {}
},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {
"maps": {
"baidu": {
"appkey_ios": "your_baidu_ios_key",
"appkey_android": "your_baidu_android_key"
}
}
}
}
}
}
```
### 4. 离线打包
#### Android 离线打包
1. 下载 Android 离线 SDK
2. 配置 Android Studio 环境
3. 导入项目到 Android Studio
4. 配置签名文件
5. 构建 APK
#### iOS 离线打包
1. 下载 iOS 离线 SDK
2. 配置 Xcode 环境
3. 导入项目到 Xcode
4. 配置证书和描述文件
5. 构建 IPA
---
## 生产环境配置
### 1. 环境变量配置
创建 `.env.production` 文件:
```bash
# 生产环境配置
NODE_ENV=production
# API 配置
VUE_APP_API_BASE_URL=https://api.bank.com/api/v1
VUE_APP_WS_URL=wss://ws.bank.com
# 应用配置
VUE_APP_TITLE=银行端资产监管系统
VUE_APP_DEBUG=false
VUE_APP_MOCK=false
# 安全配置
VUE_APP_ENCRYPT_KEY=your_production_encrypt_key
# 第三方服务配置
VUE_APP_WECHAT_APPID=your_production_wechat_appid
VUE_APP_BAIDU_MAP_KEY=your_production_baidu_map_key
# 性能配置
VUE_APP_REQUEST_TIMEOUT=60000
VUE_APP_UPLOAD_SIZE_LIMIT=50
```
### 2. 安全配置
#### HTTPS 配置
确保生产环境使用 HTTPS
```nginx
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_prefer_server_ciphers on;
# 其他配置...
}
```
#### 内容安全策略 (CSP)
```nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:;";
```
### 3. 性能优化配置
#### Vite 生产配置
```typescript
// vite.config.ts
export default defineConfig({
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'pinia'],
utils: ['axios', 'dayjs', 'lodash-es']
}
}
},
chunkSizeWarningLimit: 1000
}
})
```
---
## 性能优化
### 1. 代码分割
```typescript
// 路由懒加载
const routes = [
{
path: '/assets',
component: () => import('@/pages/assets/index.vue')
}
]
// 组件懒加载
const LazyComponent = defineAsyncComponent(() => import('@/components/LazyComponent.vue'))
```
### 2. 资源优化
```scss
// 图片优化
.image {
background-image: url('@/assets/images/bg.webp');
@media (max-width: 768px) {
background-image: url('@/assets/images/bg-mobile.webp');
}
}
```
### 3. 缓存策略
```typescript
// 请求缓存
const cache = new Map()
export function cachedRequest(url: string, options: any) {
const key = `${url}_${JSON.stringify(options)}`
if (cache.has(key)) {
return Promise.resolve(cache.get(key))
}
return request(url, options).then(data => {
cache.set(key, data)
return data
})
}
```
---
## 监控和日志
### 1. 错误监控
```typescript
// 全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('Global error:', err, info)
// 上报错误
reportError({
error: err.message,
stack: err.stack,
info,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
})
}
```
### 2. 性能监控
```typescript
// 性能监控
export function performanceMonitor() {
if ('performance' in window) {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const metrics = {
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
tcp: navigation.connectEnd - navigation.connectStart,
ttfb: navigation.responseStart - navigation.requestStart,
download: navigation.responseEnd - navigation.responseStart,
domReady: navigation.domContentLoadedEventEnd - navigation.navigationStart,
loadComplete: navigation.loadEventEnd - navigation.navigationStart
}
// 上报性能数据
reportPerformance(metrics)
}
}
```
### 3. 日志配置
```typescript
// 日志工具
class Logger {
private level: string
constructor(level = 'info') {
this.level = level
}
debug(message: string, ...args: any[]) {
if (this.shouldLog('debug')) {
console.debug(`[DEBUG] ${message}`, ...args)
}
}
info(message: string, ...args: any[]) {
if (this.shouldLog('info')) {
console.info(`[INFO] ${message}`, ...args)
}
}
warn(message: string, ...args: any[]) {
if (this.shouldLog('warn')) {
console.warn(`[WARN] ${message}`, ...args)
}
}
error(message: string, ...args: any[]) {
if (this.shouldLog('error')) {
console.error(`[ERROR] ${message}`, ...args)
// 上报错误日志
this.reportError(message, args)
}
}
private shouldLog(level: string): boolean {
const levels = ['debug', 'info', 'warn', 'error']
return levels.indexOf(level) >= levels.indexOf(this.level)
}
private reportError(message: string, args: any[]) {
// 实现错误上报逻辑
}
}
export const logger = new Logger(process.env.VUE_APP_LOG_LEVEL)
```
---
## 常见问题
### 1. 微信小程序相关
**Q: 小程序上传失败,提示代码包过大**
A:
- 检查是否开启了代码压缩
- 使用分包加载减少主包大小
- 移除未使用的依赖和资源
**Q: 小程序真机调试时网络请求失败**
A:
- 检查服务器域名是否已在微信公众平台配置
- 确认服务器支持 HTTPS
- 检查请求域名是否在合法域名列表中
### 2. H5 相关
**Q: H5 页面在微信浏览器中无法正常显示**
A:
- 检查是否使用了微信不支持的 API
- 确认页面是否适配移动端
- 检查 CSP 配置是否过于严格
**Q: 静态资源加载失败**
A:
- 检查资源路径是否正确
- 确认 CDN 配置是否正确
- 检查服务器 CORS 配置
### 3. App 相关
**Q: App 打包失败**
A:
- 检查证书和描述文件是否正确
- 确认 manifest.json 配置是否完整
- 检查是否有语法错误
**Q: App 安装后闪退**
A:
- 检查权限配置是否正确
- 确认第三方 SDK 配置是否正确
- 查看设备日志定位问题
### 4. 通用问题
**Q: 构建时内存不足**
A:
```bash
# 增加 Node.js 内存限制
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build
```
**Q: 依赖安装失败**
A:
```bash
# 清除缓存重新安装
npm cache clean --force
rm -rf node_modules package-lock.json
npm install
```
---
## 部署检查清单
### 部署前检查
- [ ] 代码已通过所有测试
- [ ] 已更新版本号
- [ ] 环境配置已正确设置
- [ ] 安全配置已检查
- [ ] 性能优化已完成
- [ ] 文档已更新
### 部署后检查
- [ ] 应用可以正常访问
- [ ] 所有功能正常工作
- [ ] 性能指标符合预期
- [ ] 错误监控正常工作
- [ ] 日志记录正常
- [ ] 备份策略已执行
---
## 联系支持
如果在部署过程中遇到问题,请联系技术支持团队:
- **邮箱**: support@bank.com
- **电话**: 400-xxx-xxxx
- **文档**: [在线文档](https://docs.bank.com)
- **问题反馈**: [GitHub Issues](https://github.com/your-org/bank-mini-program/issues)

View File

@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>银行监管服务</title>
<meta name="description" content="专业的银行监管服务平台,提供信贷管理、风险监控、客户服务等功能">
<meta name="keywords" content="银行,监管,信贷,风险,客户管理">
<!-- iOS Safari 配置 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="银行监管服务">
<!-- Android Chrome 配置 -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1890ff">
<!-- 防止页面缓存 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
#app {
width: 100%;
height: 100vh;
overflow: hidden;
}
.loading-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-logo {
width: 80px;
height: 80px;
margin-bottom: 20px;
background: linear-gradient(135deg, #1890ff, #40a9ff);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32px;
font-weight: bold;
box-shadow: 0 4px 20px rgba(24, 144, 255, 0.3);
}
.loading-text {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #fff;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
}
.error-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
background-color: #ff4d4f;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32px;
}
.error-title {
font-size: 18px;
color: #333;
margin-bottom: 10px;
}
.error-message {
font-size: 14px;
color: #666;
margin-bottom: 30px;
}
.retry-button {
padding: 12px 24px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
.retry-button:hover {
background-color: #40a9ff;
}
</style>
</head>
<body>
<div id="app">
<!-- 加载中界面 -->
<div class="loading-container" id="loading">
<div class="loading-logo"></div>
<div class="loading-text">银行监管服务正在启动...</div>
<div class="loading-spinner"></div>
</div>
<!-- 错误界面 -->
<div class="error-container" id="error">
<div class="error-icon">!</div>
<div class="error-title">应用加载失败</div>
<div class="error-message">请检查网络连接或稍后重试</div>
<button class="retry-button" onclick="location.reload()">重新加载</button>
</div>
</div>
<script>
// 错误处理
window.addEventListener('error', function(e) {
console.error('应用加载错误:', e.error);
showError();
});
window.addEventListener('unhandledrejection', function(e) {
console.error('未处理的Promise错误:', e.reason);
showError();
});
function showError() {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'flex';
}
// 超时处理
setTimeout(function() {
if (document.getElementById('loading').style.display !== 'none') {
console.warn('应用加载超时');
showError();
}
}, 30000); // 30秒超时
// 页面可见性变化处理
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
// 页面重新可见时,可以在这里添加刷新逻辑
console.log('页面重新可见');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
// 根目录
rootDir: '.',
// 测试文件匹配模式
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(ts|js)',
'<rootDir>/src/**/*.(test|spec).(ts|js)',
'<rootDir>/tests/**/*.(test|spec).(ts|js)'
],
// 模块文件扩展名
moduleFileExtensions: [
'js',
'ts',
'json',
'vue'
],
// 模块名映射
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@/components/(.*)$': '<rootDir>/src/components/$1',
'^@/pages/(.*)$': '<rootDir>/src/pages/$1',
'^@/utils/(.*)$': '<rootDir>/src/utils/$1',
'^@/api/(.*)$': '<rootDir>/src/api/$1',
'^@/stores/(.*)$': '<rootDir>/src/stores/$1',
'^@/styles/(.*)$': '<rootDir>/src/styles/$1',
'^@/types/(.*)$': '<rootDir>/src/types/$1',
'^@/assets/(.*)$': '<rootDir>/src/assets/$1'
},
// 转换配置
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.(ts|tsx)$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest'
},
// 转换忽略模式
transformIgnorePatterns: [
'node_modules/(?!(axios|dayjs|lodash-es)/)'
],
// 设置文件
setupFilesAfterEnv: [
'<rootDir>/tests/setup.ts'
],
// 覆盖率配置
collectCoverage: false,
collectCoverageFrom: [
'src/**/*.{ts,vue}',
'!src/**/*.d.ts',
'!src/main.ts',
'!src/App.vue',
'!src/pages.json',
'!src/manifest.json'
],
coverageDirectory: 'coverage',
coverageReporters: [
'text',
'lcov',
'html'
],
// 全局变量
globals: {
'ts-jest': {
useESM: true
}
},
// 测试环境配置
testEnvironmentOptions: {
customExportConditions: ['node', 'node-addons']
},
// 清除模拟
clearMocks: true,
// 详细输出
verbose: true,
// 错误时停止
bail: false,
// 最大工作进程数
maxWorkers: '50%',
// 缓存目录
cacheDirectory: '<rootDir>/node_modules/.cache/jest'
}
export default config

12285
bank_mini_program/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
{
"name": "bank-miniprogram",
"version": "1.0.0",
"description": "银行端微信小程序 - 基于Vue.js 3.x + uni-app",
"scripts": {
"serve": "npm run dev:mp-weixin",
"build": "npm run build:mp-weixin",
"dev:mp-weixin": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin uni",
"build:mp-weixin": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin uni build",
"dev:h5": "cross-env NODE_ENV=development UNI_PLATFORM=h5 uni",
"build:h5": "cross-env NODE_ENV=production UNI_PLATFORM=h5 uni build",
"dev:app-plus": "cross-env NODE_ENV=development UNI_PLATFORM=app-plus uni",
"build:app-plus": "cross-env NODE_ENV=production UNI_PLATFORM=app-plus uni build",
"lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --ext .js,.vue . --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"type-check": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-3081220230817001",
"@dcloudio/uni-mp-weixin": "3.0.0-3081220230817001",
"@dcloudio/uni-components": "3.0.0-3081220230817001",
"vue": "^3.3.4",
"pinia": "^2.1.6",
"@vant/weapp": "^1.11.6",
"axios": "^1.5.0",
"dayjs": "^1.11.9",
"lodash-es": "^4.17.21",
"echarts": "^5.4.3",
"crypto-js": "^4.1.1"
},
"devDependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-3081220230817001",
"@dcloudio/vite-plugin-uni": "3.0.0-3081220230817001",
"@dcloudio/uni-automator": "3.0.0-3081220230817001",
"@vue/runtime-core": "^3.3.4",
"vite": "^4.4.9",
"sass": "^1.64.1",
"cross-env": "^7.0.3",
"@types/crypto-js": "^4.1.1",
"@types/lodash-es": "^4.17.8",
"@types/node": "^20.5.0",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"@vue/eslint-config-typescript": "^11.0.3",
"eslint-plugin-vue": "^9.17.0",
"jest": "^29.6.2",
"typescript": "^5.1.6",
"vue-tsc": "^1.8.8",
"@vue/test-utils": "^2.4.1",
"jest-environment-jsdom": "^29.6.2"
},
"engines": {
"node": "16.20.2",
"npm": ">=8.0.0"
},
"author": "Bank Development Team",
"license": "MIT",
"keywords": [
"bank",
"miniprogram",
"vue3",
"uni-app",
"weixin",
"finance",
"credit-management"
]
}

View File

@@ -0,0 +1,118 @@
<template>
<view id="app">
<!-- 小程序页面内容 -->
</view>
</template>
<script>
export default {
name: 'App',
onLaunch() {
console.log('银行端小程序启动')
// 检查登录状态
this.checkLoginStatus()
// 初始化应用配置
this.initAppConfig()
},
onShow() {
console.log('银行端小程序显示')
},
onHide() {
console.log('银行端小程序隐藏')
},
methods: {
checkLoginStatus() {
// 开发环境暂时跳过登录检查
if (process.env.NODE_ENV === 'development') {
return
}
const token = uni.getStorageSync('token')
if (!token) {
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/login'
})
}
},
initAppConfig() {
// 设置全局配置
uni.setStorageSync('appConfig', {
version: '1.0.0',
apiBaseUrl: 'http://localhost:3002'
})
}
}
}
</script>
<style lang="scss">
@import '@/styles/base.scss';
#app {
font-family: 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 全局样式 */
page {
background-color: #f5f7fa;
font-size: 28rpx;
line-height: 1.6;
}
/* 通用类 */
.container {
padding: 20rpx;
}
.card {
background: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
margin-bottom: 20rpx;
padding: 24rpx;
}
.btn-primary {
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
color: #fff;
border: none;
border-radius: 8rpx;
padding: 24rpx 48rpx;
font-size: 28rpx;
font-weight: 500;
}
.btn-secondary {
background: #f8f9fa;
color: #2c5aa0;
border: 2rpx solid #2c5aa0;
border-radius: 8rpx;
padding: 22rpx 46rpx;
font-size: 28rpx;
}
.text-primary {
color: #2c5aa0;
}
.text-success {
color: #52c41a;
}
.text-warning {
color: #faad14;
}
.text-danger {
color: #ff4d4f;
}
.text-muted {
color: #999;
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<view v-if="hasAccess">
<slot></slot>
</view>
<view v-else-if="showFallback" class="auth-fallback">
<slot name="fallback">
<view class="fallback-content">
<view class="fallback-icon">
<text class="iconfont icon-lock"></text>
</view>
<text class="fallback-text">{{ fallbackText }}</text>
<view class="fallback-actions" v-if="showActions">
<button class="fallback-btn" @click="handleLogin" v-if="!isLoggedIn">
登录
</button>
<button class="fallback-btn secondary" @click="handleBack" v-else>
返回
</button>
</view>
</view>
</slot>
</view>
</template>
<script>
import { computed, onMounted } from 'vue'
import { useUserStore } from '@/store'
import {
isLoggedIn,
hasPermission,
hasRole,
hasAnyPermission,
hasAnyRole,
redirectToLogin
} from '@/utils/auth'
export default {
name: 'AuthGuard',
props: {
// 是否需要登录
requireAuth: {
type: Boolean,
default: true
},
// 必需的权限(字符串或数组)
permissions: {
type: [String, Array],
default: () => []
},
// 必需的角色(字符串或数组)
roles: {
type: [String, Array],
default: () => []
},
// 权限检查模式:'any' 任一权限,'all' 所有权限
permissionMode: {
type: String,
default: 'any',
validator: (value) => ['any', 'all'].includes(value)
},
// 角色检查模式:'any' 任一角色,'all' 所有角色
roleMode: {
type: String,
default: 'any',
validator: (value) => ['any', 'all'].includes(value)
},
// 是否显示无权限时的后备内容
showFallback: {
type: Boolean,
default: true
},
// 后备内容文本
fallbackText: {
type: String,
default: '权限不足'
},
// 是否显示操作按钮
showActions: {
type: Boolean,
default: true
},
// 权限不足时的回调
onDenied: {
type: Function,
default: null
},
// 是否自动重定向到登录页
autoRedirect: {
type: Boolean,
default: false
}
},
emits: ['access-denied', 'access-granted'],
setup(props, { emit }) {
const userStore = useUserStore()
// 计算是否有访问权限
const hasAccess = computed(() => {
// 如果不需要认证,直接通过
if (!props.requireAuth) {
return true
}
// 检查登录状态
if (!isLoggedIn()) {
return false
}
// 检查权限
if (props.permissions && props.permissions.length > 0) {
const permissions = Array.isArray(props.permissions)
? props.permissions
: [props.permissions]
let hasRequiredPermission = false
if (props.permissionMode === 'all') {
hasRequiredPermission = permissions.every(permission =>
hasPermission(permission)
)
} else {
hasRequiredPermission = hasAnyPermission(permissions)
}
if (!hasRequiredPermission) {
return false
}
}
// 检查角色
if (props.roles && props.roles.length > 0) {
const roles = Array.isArray(props.roles)
? props.roles
: [props.roles]
let hasRequiredRole = false
if (props.roleMode === 'all') {
hasRequiredRole = roles.every(role => hasRole(role))
} else {
hasRequiredRole = hasAnyRole(roles)
}
if (!hasRequiredRole) {
return false
}
}
return true
})
// 处理权限检查结果
const handleAccessCheck = () => {
if (hasAccess.value) {
emit('access-granted')
} else {
emit('access-denied', {
isLoggedIn: isLoggedIn(),
permissions: props.permissions,
roles: props.roles
})
// 执行自定义回调
if (typeof props.onDenied === 'function') {
props.onDenied({
isLoggedIn: isLoggedIn(),
permissions: props.permissions,
roles: props.roles
})
}
// 自动重定向
if (props.autoRedirect && !isLoggedIn()) {
setTimeout(() => {
redirectToLogin()
}, 1000)
}
}
}
// 处理登录按钮点击
const handleLogin = () => {
redirectToLogin()
}
// 处理返回按钮点击
const handleBack = () => {
uni.navigateBack({
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
// 生命周期
onMounted(() => {
handleAccessCheck()
})
// 监听用户状态变化
userStore.$subscribe((mutation, state) => {
handleAccessCheck()
})
return {
hasAccess,
isLoggedIn: isLoggedIn(),
handleLogin,
handleBack
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.auth-fallback {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl;
}
.fallback-content {
text-align: center;
.fallback-icon {
width: 80px;
height: 80px;
margin: 0 auto $spacing-lg;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: rgba($text-color-placeholder, 0.1);
.iconfont {
font-size: 40px;
color: $text-color-placeholder;
}
}
.fallback-text {
display: block;
font-size: $font-size-lg;
color: $text-color-secondary;
margin-bottom: $spacing-lg;
}
.fallback-actions {
display: flex;
justify-content: center;
gap: $spacing-md;
.fallback-btn {
padding: $spacing-sm $spacing-lg;
border-radius: $border-radius-md;
font-size: $font-size-md;
border: none;
background-color: $primary-color;
color: white;
&.secondary {
background-color: $background-color;
color: $text-color-secondary;
border: 1px solid $border-color;
}
&:active {
opacity: 0.8;
}
}
}
}
</style>

View File

@@ -0,0 +1,508 @@
<template>
<view class="action-sheet-overlay" :class="{ 'show': visible }" @click="handleOverlayClick">
<view class="action-sheet" :class="{ 'show': visible }" @click.stop>
<!-- 标题区域 -->
<view class="action-sheet-header" v-if="title || description">
<text class="action-sheet-title" v-if="title">{{ title }}</text>
<text class="action-sheet-description" v-if="description">{{ description }}</text>
</view>
<!-- 操作列表 -->
<view class="action-sheet-body">
<view
class="action-item"
:class="{
'disabled': item.disabled,
'destructive': item.destructive,
'loading': item.loading
}"
v-for="(item, index) in actions"
:key="index"
@click="handleActionClick(item, index)"
>
<!-- 图标 -->
<text
class="iconfont action-icon"
:class="item.icon"
v-if="item.icon && !item.loading"
></text>
<!-- 加载图标 -->
<text
class="iconfont action-icon icon-loading"
v-if="item.loading"
></text>
<!-- 文本 -->
<text class="action-text">{{ item.text }}</text>
<!-- 描述 -->
<text class="action-description" v-if="item.description">{{ item.description }}</text>
<!-- 右侧内容 -->
<view class="action-suffix" v-if="item.suffix">
<text class="action-suffix-text">{{ item.suffix }}</text>
</view>
<!-- 右侧图标 -->
<text
class="iconfont action-suffix-icon"
:class="item.suffixIcon"
v-if="item.suffixIcon"
></text>
</view>
</view>
<!-- 取消按钮 -->
<view class="action-sheet-footer" v-if="showCancel">
<view class="action-item cancel-item" @click="handleCancel">
<text class="action-text">{{ cancelText }}</text>
</view>
</view>
<!-- 安全区域 -->
<view class="safe-area-bottom"></view>
</view>
</view>
</template>
<script>
export default {
name: 'ActionSheet',
props: {
// 是否显示
visible: {
type: Boolean,
default: false
},
// 标题
title: {
type: String,
default: ''
},
// 描述
description: {
type: String,
default: ''
},
// 操作列表
actions: {
type: Array,
default: () => []
},
// 是否显示取消按钮
showCancel: {
type: Boolean,
default: true
},
// 取消按钮文本
cancelText: {
type: String,
default: '取消'
},
// 点击遮罩是否关闭
closeOnClickOverlay: {
type: Boolean,
default: true
},
// 是否显示圆角
round: {
type: Boolean,
default: true
},
// 安全区域适配
safeAreaInsetBottom: {
type: Boolean,
default: true
}
},
emits: ['update:visible', 'select', 'cancel', 'close'],
setup(props, { emit }) {
const handleOverlayClick = () => {
if (props.closeOnClickOverlay) {
handleClose()
}
}
const handleActionClick = (item, index) => {
if (item.disabled || item.loading) {
return
}
emit('select', { item, index })
if (!item.keepOpen) {
handleClose()
}
}
const handleCancel = () => {
emit('cancel')
handleClose()
}
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
return {
handleOverlayClick,
handleActionClick,
handleCancel,
handleClose
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.action-sheet-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
&.show {
opacity: 1;
visibility: visible;
}
}
.action-sheet {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: $border-radius-lg $border-radius-lg 0 0;
transform: translateY(100%);
transition: transform 0.3s ease;
max-height: 80vh;
overflow: hidden;
&.show {
transform: translateY(0);
}
}
.action-sheet-header {
padding: $spacing-lg $spacing-lg $spacing-md;
text-align: center;
border-bottom: 1px solid $border-color-light;
.action-sheet-title {
display: block;
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
line-height: 1.4;
margin-bottom: $spacing-xs;
}
.action-sheet-description {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
line-height: 1.5;
}
}
.action-sheet-body {
max-height: 60vh;
overflow-y: auto;
.action-item {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
background: white;
border-bottom: 1px solid $border-color-light;
transition: background-color 0.2s ease;
cursor: pointer;
min-height: 56px;
&:last-child {
border-bottom: none;
}
&:active:not(.disabled):not(.loading) {
background: $bg-color-light;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.destructive {
.action-text {
color: $error-color;
}
.action-icon {
color: $error-color;
}
}
&.loading {
cursor: not-allowed;
.action-icon.icon-loading {
animation: spin 1s linear infinite;
}
}
.action-icon {
font-size: 18px;
color: $text-color-secondary;
margin-right: $spacing-sm;
flex-shrink: 0;
}
.action-text {
flex: 1;
font-size: $font-size-md;
color: $text-color-primary;
line-height: 1.4;
}
.action-description {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
line-height: 1.3;
margin-top: 2px;
}
.action-suffix {
margin-left: $spacing-sm;
.action-suffix-text {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.action-suffix-icon {
font-size: 16px;
color: $text-color-secondary;
margin-left: $spacing-sm;
flex-shrink: 0;
}
}
}
.action-sheet-footer {
border-top: 8px solid $bg-color-light;
.cancel-item {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-md $spacing-lg;
background: white;
cursor: pointer;
min-height: 56px;
&:active {
background: $bg-color-light;
}
.action-text {
font-size: $font-size-md;
color: $text-color-secondary;
font-weight: 500;
}
}
}
.safe-area-bottom {
height: env(safe-area-inset-bottom);
background: white;
}
// 动画
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 无圆角样式
.action-sheet:not(.round) {
border-radius: 0;
}
// 滚动条样式
.action-sheet-body::-webkit-scrollbar {
width: 4px;
}
.action-sheet-body::-webkit-scrollbar-track {
background: transparent;
}
.action-sheet-body::-webkit-scrollbar-thumb {
background: rgba($text-color-secondary, 0.3);
border-radius: 2px;
}
.action-sheet-body::-webkit-scrollbar-thumb:hover {
background: rgba($text-color-secondary, 0.5);
}
// 特殊布局
.action-item {
&.with-description {
flex-direction: column;
align-items: flex-start;
.action-main {
display: flex;
align-items: center;
width: 100%;
.action-icon {
margin-right: $spacing-sm;
}
.action-text {
flex: 1;
}
.action-suffix,
.action-suffix-icon {
margin-left: $spacing-sm;
}
}
.action-description {
margin-top: $spacing-xs;
margin-left: 26px; // 图标宽度 + 间距
}
}
}
// 响应式适配
@media (max-width: 480px) {
.action-sheet {
max-height: 85vh;
.action-sheet-header {
padding: $spacing-md $spacing-md $spacing-sm;
.action-sheet-title {
font-size: $font-size-md;
}
}
.action-sheet-body {
max-height: 65vh;
.action-item {
padding: $spacing-sm $spacing-md;
min-height: 48px;
.action-icon {
font-size: 16px;
}
.action-text {
font-size: $font-size-sm;
}
}
}
.action-sheet-footer {
.cancel-item {
padding: $spacing-sm $spacing-md;
min-height: 48px;
.action-text {
font-size: $font-size-sm;
}
}
}
}
}
// 暗色主题适配
@media (prefers-color-scheme: dark) {
.action-sheet-overlay {
background: rgba(0, 0, 0, 0.7);
}
.action-sheet {
background: #1f1f1f;
.action-sheet-header {
border-bottom-color: #333;
.action-sheet-title {
color: #fff;
}
.action-sheet-description {
color: #999;
}
}
.action-sheet-body {
.action-item {
background: #1f1f1f;
border-bottom-color: #333;
&:active:not(.disabled):not(.loading) {
background: #333;
}
.action-text {
color: #fff;
}
.action-description,
.action-suffix-text {
color: #999;
}
.action-icon,
.action-suffix-icon {
color: #999;
}
}
}
.action-sheet-footer {
border-top-color: #333;
.cancel-item {
background: #1f1f1f;
&:active {
background: #333;
}
.action-text {
color: #999;
}
}
}
.safe-area-bottom {
background: #1f1f1f;
}
}
}
</style>

View File

@@ -0,0 +1,492 @@
<template>
<view class="empty-state" :class="[size, { 'with-background': withBackground }]">
<view class="empty-container">
<!-- 图标或图片 -->
<view class="empty-icon" v-if="icon || image">
<image
v-if="image"
:src="image"
class="empty-image"
mode="aspectFit"
/>
<text
v-else-if="icon"
class="iconfont empty-icon-text"
:class="icon"
:style="{ color: iconColor, fontSize: iconSize }"
></text>
</view>
<!-- 默认图标 -->
<view class="empty-icon" v-if="!icon && !image">
<view class="default-empty-icon">
<view class="icon-circle">
<text class="iconfont icon-inbox"></text>
</view>
</view>
</view>
<!-- 标题 -->
<text class="empty-title" v-if="title">{{ title }}</text>
<!-- 描述 -->
<text class="empty-description" v-if="description">{{ description }}</text>
<!-- 操作按钮 -->
<view class="empty-actions" v-if="$slots.actions || actionText">
<slot name="actions">
<view
class="action-button"
:class="actionType"
@click="handleAction"
v-if="actionText"
>
<text class="iconfont" :class="actionIcon" v-if="actionIcon"></text>
<text class="action-text">{{ actionText }}</text>
</view>
</slot>
</view>
<!-- 自定义内容插槽 -->
<view class="empty-custom" v-if="$slots.default">
<slot></slot>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'EmptyState',
props: {
// 图标
icon: {
type: String,
default: ''
},
// 图片
image: {
type: String,
default: ''
},
// 图标颜色
iconColor: {
type: String,
default: '#d9d9d9'
},
// 图标大小
iconSize: {
type: String,
default: '48px'
},
// 标题
title: {
type: String,
default: '暂无数据'
},
// 描述
description: {
type: String,
default: ''
},
// 尺寸
size: {
type: String,
default: 'medium', // small, medium, large
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
// 是否显示背景
withBackground: {
type: Boolean,
default: false
},
// 操作按钮文本
actionText: {
type: String,
default: ''
},
// 操作按钮图标
actionIcon: {
type: String,
default: ''
},
// 操作按钮类型
actionType: {
type: String,
default: 'primary', // primary, default, text
validator: (value) => ['primary', 'default', 'text'].includes(value)
}
},
emits: ['action'],
setup(props, { emit }) {
const handleAction = () => {
emit('action')
}
return {
handleAction
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 200px;
padding: $spacing-lg;
&.with-background {
background: white;
border-radius: $border-radius-lg;
box-shadow: $shadow-light;
}
&.small {
min-height: 120px;
padding: $spacing-md;
.empty-container {
.empty-icon {
margin-bottom: $spacing-sm;
.empty-image {
width: 60px;
height: 60px;
}
.empty-icon-text {
font-size: 32px;
}
.default-empty-icon {
.icon-circle {
width: 60px;
height: 60px;
.iconfont {
font-size: 24px;
}
}
}
}
.empty-title {
font-size: $font-size-md;
margin-bottom: $spacing-xs;
}
.empty-description {
font-size: $font-size-sm;
margin-bottom: $spacing-sm;
}
.empty-actions {
.action-button {
padding: $spacing-xs $spacing-sm;
font-size: $font-size-sm;
.iconfont {
font-size: 14px;
}
}
}
}
}
&.medium {
min-height: 200px;
padding: $spacing-lg;
.empty-container {
.empty-icon {
margin-bottom: $spacing-md;
.empty-image {
width: 80px;
height: 80px;
}
.empty-icon-text {
font-size: 48px;
}
.default-empty-icon {
.icon-circle {
width: 80px;
height: 80px;
.iconfont {
font-size: 32px;
}
}
}
}
.empty-title {
font-size: $font-size-lg;
margin-bottom: $spacing-sm;
}
.empty-description {
font-size: $font-size-md;
margin-bottom: $spacing-md;
}
.empty-actions {
.action-button {
padding: $spacing-sm $spacing-md;
font-size: $font-size-md;
.iconfont {
font-size: 16px;
}
}
}
}
}
&.large {
min-height: 300px;
padding: $spacing-xl;
.empty-container {
.empty-icon {
margin-bottom: $spacing-lg;
.empty-image {
width: 120px;
height: 120px;
}
.empty-icon-text {
font-size: 64px;
}
.default-empty-icon {
.icon-circle {
width: 120px;
height: 120px;
.iconfont {
font-size: 48px;
}
}
}
}
.empty-title {
font-size: $font-size-xl;
margin-bottom: $spacing-md;
}
.empty-description {
font-size: $font-size-lg;
margin-bottom: $spacing-lg;
}
.empty-actions {
.action-button {
padding: $spacing-md $spacing-lg;
font-size: $font-size-lg;
.iconfont {
font-size: 18px;
}
}
}
}
}
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 400px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
.empty-image {
display: block;
}
.empty-icon-text {
display: block;
}
.default-empty-icon {
.icon-circle {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-color-light;
color: $text-color-secondary;
.iconfont {
display: block;
}
}
}
}
.empty-title {
color: $text-color-primary;
font-weight: 600;
line-height: 1.4;
display: block;
}
.empty-description {
color: $text-color-secondary;
line-height: 1.5;
display: block;
}
.empty-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-sm;
.action-button {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
border-radius: $border-radius-sm;
transition: all 0.3s ease;
cursor: pointer;
&.primary {
background: $primary-color;
color: white;
border: 1px solid $primary-color;
&:active {
background: $primary-color-dark;
border-color: $primary-color-dark;
}
}
&.default {
background: white;
color: $text-color-primary;
border: 1px solid $border-color;
&:active {
background: $bg-color-light;
border-color: $primary-color;
color: $primary-color;
}
}
&.text {
background: transparent;
color: $primary-color;
border: none;
&:active {
background: rgba($primary-color, 0.1);
}
}
.iconfont {
display: block;
}
.action-text {
display: block;
}
}
}
.empty-custom {
margin-top: $spacing-md;
width: 100%;
}
// 预设主题
.empty-state {
&.theme-no-data {
.default-empty-icon .icon-circle {
background: rgba($info-color, 0.1);
color: $info-color;
}
}
&.theme-no-network {
.default-empty-icon .icon-circle {
background: rgba($warning-color, 0.1);
color: $warning-color;
}
}
&.theme-error {
.default-empty-icon .icon-circle {
background: rgba($error-color, 0.1);
color: $error-color;
}
}
&.theme-success {
.default-empty-icon .icon-circle {
background: rgba($success-color, 0.1);
color: $success-color;
}
}
}
// 响应式适配
@media (max-width: 480px) {
.empty-state {
&.large {
min-height: 240px;
padding: $spacing-lg;
.empty-container {
.empty-icon {
.empty-image {
width: 100px;
height: 100px;
}
.empty-icon-text {
font-size: 56px;
}
.default-empty-icon {
.icon-circle {
width: 100px;
height: 100px;
.iconfont {
font-size: 40px;
}
}
}
}
.empty-title {
font-size: $font-size-lg;
}
.empty-description {
font-size: $font-size-md;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<view class="loading-spinner" :class="{ 'full-screen': fullScreen }" v-if="visible">
<view class="spinner-container" :class="size">
<view class="spinner" :class="type">
<view class="spinner-dot" v-if="type === 'dots'"></view>
<view class="spinner-dot" v-if="type === 'dots'"></view>
<view class="spinner-dot" v-if="type === 'dots'"></view>
<view class="spinner-circle" v-if="type === 'circle'">
<view class="circle-path"></view>
</view>
<view class="spinner-wave" v-if="type === 'wave'">
<view class="wave-bar"></view>
<view class="wave-bar"></view>
<view class="wave-bar"></view>
<view class="wave-bar"></view>
<view class="wave-bar"></view>
</view>
<view class="spinner-pulse" v-if="type === 'pulse'"></view>
</view>
<text class="loading-text" v-if="text">{{ text }}</text>
</view>
<view class="loading-overlay" v-if="overlay" @click="handleOverlayClick"></view>
</view>
</template>
<script>
export default {
name: 'LoadingSpinner',
props: {
visible: {
type: Boolean,
default: true
},
type: {
type: String,
default: 'circle', // circle, dots, wave, pulse
validator: (value) => ['circle', 'dots', 'wave', 'pulse'].includes(value)
},
size: {
type: String,
default: 'medium', // small, medium, large
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
text: {
type: String,
default: ''
},
fullScreen: {
type: Boolean,
default: false
},
overlay: {
type: Boolean,
default: false
},
overlayClickable: {
type: Boolean,
default: false
},
color: {
type: String,
default: '#1890ff'
}
},
emits: ['overlay-click'],
setup(props, { emit }) {
const handleOverlayClick = () => {
if (props.overlayClickable) {
emit('overlay-click')
}
}
return {
handleOverlayClick
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
&.full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(255, 255, 255, 0.9);
}
}
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&.small {
.spinner {
width: 20px;
height: 20px;
}
.loading-text {
font-size: $font-size-sm;
margin-top: $spacing-xs;
}
}
&.medium {
.spinner {
width: 32px;
height: 32px;
}
.loading-text {
font-size: $font-size-md;
margin-top: $spacing-sm;
}
}
&.large {
.spinner {
width: 48px;
height: 48px;
}
.loading-text {
font-size: $font-size-lg;
margin-top: $spacing-md;
}
}
}
.spinner {
position: relative;
// 圆形加载动画
&.circle {
.spinner-circle {
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid rgba($primary-color, 0.2);
border-top-color: $primary-color;
animation: spin 1s linear infinite;
.circle-path {
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: $primary-color;
animation: spin 1s linear infinite;
}
}
}
// 点状加载动画
&.dots {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.spinner-dot {
width: 25%;
height: 100%;
background: $primary-color;
border-radius: 50%;
animation: dot-bounce 1.4s ease-in-out infinite both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
}
// 波浪加载动画
&.wave {
.spinner-wave {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
.wave-bar {
width: 15%;
height: 100%;
background: $primary-color;
border-radius: 2px;
animation: wave-scale 1.2s ease-in-out infinite;
&:nth-child(1) {
animation-delay: -1.2s;
}
&:nth-child(2) {
animation-delay: -1.1s;
}
&:nth-child(3) {
animation-delay: -1.0s;
}
&:nth-child(4) {
animation-delay: -0.9s;
}
&:nth-child(5) {
animation-delay: -0.8s;
}
}
}
}
// 脉冲加载动画
&.pulse {
.spinner-pulse {
width: 100%;
height: 100%;
background: $primary-color;
border-radius: 50%;
animation: pulse-scale 1s ease-in-out infinite;
}
}
}
.loading-text {
color: $text-color-primary;
text-align: center;
white-space: nowrap;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: -1;
}
// 动画定义
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes dot-bounce {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes wave-scale {
0%, 40%, 100% {
transform: scaleY(0.4);
}
20% {
transform: scaleY(1);
}
}
@keyframes pulse-scale {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
// 主题色变量支持
.loading-spinner[data-color="success"] {
.spinner-dot,
.spinner-circle,
.circle-path,
.wave-bar,
.spinner-pulse {
border-color: $success-color;
background-color: $success-color;
}
.spinner-circle {
border-color: rgba($success-color, 0.2);
border-top-color: $success-color;
}
}
.loading-spinner[data-color="warning"] {
.spinner-dot,
.spinner-circle,
.circle-path,
.wave-bar,
.spinner-pulse {
border-color: $warning-color;
background-color: $warning-color;
}
.spinner-circle {
border-color: rgba($warning-color, 0.2);
border-top-color: $warning-color;
}
}
.loading-spinner[data-color="error"] {
.spinner-dot,
.spinner-circle,
.circle-path,
.wave-bar,
.spinner-pulse {
border-color: $error-color;
background-color: $error-color;
}
.spinner-circle {
border-color: rgba($error-color, 0.2);
border-top-color: $error-color;
}
}
// 响应式适配
@media (max-width: 480px) {
.spinner-container {
&.small {
.spinner {
width: 16px;
height: 16px;
}
}
&.medium {
.spinner {
width: 24px;
height: 24px;
}
}
&.large {
.spinner {
width: 36px;
height: 36px;
}
}
}
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<view
class="status-tag"
:class="[
`status-${status}`,
`size-${size}`,
`type-${type}`,
{
'with-icon': showIcon,
'with-dot': showDot,
'clickable': clickable,
'bordered': bordered
}
]"
@click="handleClick"
>
<!-- 状态点 -->
<view class="status-dot" v-if="showDot"></view>
<!-- 图标 -->
<text
class="iconfont status-icon"
:class="iconClass"
v-if="showIcon && iconClass"
></text>
<!-- 文本内容 -->
<text class="status-text">{{ text || statusText }}</text>
<!-- 右侧图标 -->
<text
class="iconfont status-suffix-icon"
:class="suffixIcon"
v-if="suffixIcon"
></text>
</view>
</template>
<script>
export default {
name: 'StatusTag',
props: {
// 状态类型
status: {
type: String,
default: 'default',
validator: (value) => [
'default', 'primary', 'success', 'warning', 'error', 'info',
'pending', 'processing', 'approved', 'rejected', 'cancelled',
'active', 'inactive', 'online', 'offline', 'normal', 'abnormal'
].includes(value)
},
// 显示文本
text: {
type: String,
default: ''
},
// 尺寸
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
// 类型样式
type: {
type: String,
default: 'filled',
validator: (value) => ['filled', 'outlined', 'light'].includes(value)
},
// 是否显示图标
showIcon: {
type: Boolean,
default: false
},
// 是否显示状态点
showDot: {
type: Boolean,
default: false
},
// 自定义图标
icon: {
type: String,
default: ''
},
// 后缀图标
suffixIcon: {
type: String,
default: ''
},
// 是否可点击
clickable: {
type: Boolean,
default: false
},
// 是否显示边框
bordered: {
type: Boolean,
default: true
}
},
emits: ['click'],
computed: {
// 状态对应的默认文本
statusText() {
const statusMap = {
default: '默认',
primary: '主要',
success: '成功',
warning: '警告',
error: '错误',
info: '信息',
pending: '待处理',
processing: '处理中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已取消',
active: '活跃',
inactive: '非活跃',
online: '在线',
offline: '离线',
normal: '正常',
abnormal: '异常'
}
return statusMap[this.status] || this.status
},
// 状态对应的图标
iconClass() {
if (this.icon) {
return this.icon
}
const iconMap = {
success: 'icon-check-circle',
warning: 'icon-warning-circle',
error: 'icon-close-circle',
info: 'icon-info-circle',
pending: 'icon-clock-circle',
processing: 'icon-loading',
approved: 'icon-check',
rejected: 'icon-close',
cancelled: 'icon-stop',
active: 'icon-play-circle',
inactive: 'icon-pause-circle',
online: 'icon-wifi',
offline: 'icon-disconnect',
normal: 'icon-check-circle',
abnormal: 'icon-warning-circle'
}
return iconMap[this.status] || ''
}
},
setup(props, { emit }) {
const handleClick = () => {
if (props.clickable) {
emit('click')
}
}
return {
handleClick
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: $border-radius-sm;
font-weight: 500;
line-height: 1;
white-space: nowrap;
transition: all 0.3s ease;
position: relative;
&.clickable {
cursor: pointer;
&:active {
transform: scale(0.95);
}
}
// 尺寸样式
&.size-small {
padding: 2px 6px;
font-size: $font-size-xs;
min-height: 20px;
.status-dot {
width: 4px;
height: 4px;
}
.status-icon,
.status-suffix-icon {
font-size: 10px;
}
}
&.size-medium {
padding: 4px 8px;
font-size: $font-size-sm;
min-height: 24px;
.status-dot {
width: 6px;
height: 6px;
}
.status-icon,
.status-suffix-icon {
font-size: 12px;
}
}
&.size-large {
padding: 6px 12px;
font-size: $font-size-md;
min-height: 32px;
.status-dot {
width: 8px;
height: 8px;
}
.status-icon,
.status-suffix-icon {
font-size: 14px;
}
}
// 状态颜色 - filled 类型
&.type-filled {
color: white;
&.status-default {
background: $text-color-secondary;
border: 1px solid $text-color-secondary;
}
&.status-primary {
background: $primary-color;
border: 1px solid $primary-color;
}
&.status-success,
&.status-approved,
&.status-normal,
&.status-online,
&.status-active {
background: $success-color;
border: 1px solid $success-color;
}
&.status-warning,
&.status-pending,
&.status-abnormal {
background: $warning-color;
border: 1px solid $warning-color;
}
&.status-error,
&.status-rejected,
&.status-cancelled,
&.status-offline {
background: $error-color;
border: 1px solid $error-color;
}
&.status-info,
&.status-processing {
background: $info-color;
border: 1px solid $info-color;
}
&.status-inactive {
background: $text-color-disabled;
border: 1px solid $text-color-disabled;
}
}
// 状态颜色 - outlined 类型
&.type-outlined {
background: white;
&.status-default {
color: $text-color-secondary;
border: 1px solid $text-color-secondary;
}
&.status-primary {
color: $primary-color;
border: 1px solid $primary-color;
}
&.status-success,
&.status-approved,
&.status-normal,
&.status-online,
&.status-active {
color: $success-color;
border: 1px solid $success-color;
}
&.status-warning,
&.status-pending,
&.status-abnormal {
color: $warning-color;
border: 1px solid $warning-color;
}
&.status-error,
&.status-rejected,
&.status-cancelled,
&.status-offline {
color: $error-color;
border: 1px solid $error-color;
}
&.status-info,
&.status-processing {
color: $info-color;
border: 1px solid $info-color;
}
&.status-inactive {
color: $text-color-disabled;
border: 1px solid $text-color-disabled;
}
}
// 状态颜色 - light 类型
&.type-light {
border: none;
&.status-default {
color: $text-color-secondary;
background: rgba($text-color-secondary, 0.1);
}
&.status-primary {
color: $primary-color;
background: rgba($primary-color, 0.1);
}
&.status-success,
&.status-approved,
&.status-normal,
&.status-online,
&.status-active {
color: $success-color;
background: rgba($success-color, 0.1);
}
&.status-warning,
&.status-pending,
&.status-abnormal {
color: $warning-color;
background: rgba($warning-color, 0.1);
}
&.status-error,
&.status-rejected,
&.status-cancelled,
&.status-offline {
color: $error-color;
background: rgba($error-color, 0.1);
}
&.status-info,
&.status-processing {
color: $info-color;
background: rgba($info-color, 0.1);
}
&.status-inactive {
color: $text-color-disabled;
background: rgba($text-color-disabled, 0.1);
}
}
// 无边框样式
&:not(.bordered) {
border: none !important;
}
}
.status-dot {
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-icon,
.status-suffix-icon {
display: block;
flex-shrink: 0;
&.icon-loading {
animation: spin 1s linear infinite;
}
}
.status-text {
display: block;
flex-shrink: 0;
}
// 动画
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 特殊状态的脉冲动画
.status-tag {
&.status-processing {
&.type-filled,
&.type-light {
animation: pulse 2s infinite;
}
}
&.status-online {
.status-dot {
animation: pulse-dot 2s infinite;
}
}
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes pulse-dot {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
// 组合样式
.status-tag {
&.with-icon.with-dot {
.status-dot {
margin-right: 2px;
}
}
// 悬浮效果
&.clickable {
&:hover {
transform: translateY(-1px);
box-shadow: $shadow-light;
}
}
}
// 响应式适配
@media (max-width: 480px) {
.status-tag {
&.size-large {
padding: 4px 10px;
font-size: $font-size-sm;
min-height: 28px;
.status-icon,
.status-suffix-icon {
font-size: 12px;
}
}
}
}
</style>

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach } from '@jest/globals'
import { mountComponent, expectElementExists, expectElementText } from '../../../tests/utils/test-utils'
import StatusTag from '../StatusTag.vue'
describe('StatusTag.vue', () => {
let wrapper: any
beforeEach(() => {
wrapper = null
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
it('renders with default props', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '成功'
}
})
expectElementExists(wrapper, '.status-tag')
expectElementText(wrapper, '.status-tag__text', '成功')
expect(wrapper.classes()).toContain('status-tag--success')
})
it('renders different status types correctly', () => {
const statusTypes = [
{ status: 'success', class: 'status-tag--success' },
{ status: 'error', class: 'status-tag--error' },
{ status: 'warning', class: 'status-tag--warning' },
{ status: 'info', class: 'status-tag--info' },
{ status: 'pending', class: 'status-tag--pending' }
]
statusTypes.forEach(({ status, class: expectedClass }) => {
wrapper = mountComponent(StatusTag, {
props: {
status,
text: '测试'
}
})
expect(wrapper.classes()).toContain(expectedClass)
wrapper.unmount()
})
})
it('renders different sizes correctly', () => {
const sizes = ['small', 'medium', 'large']
sizes.forEach(size => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
size
}
})
expect(wrapper.classes()).toContain(`status-tag--${size}`)
wrapper.unmount()
})
})
it('shows dot when showDot is true', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
showDot: true
}
})
expectElementExists(wrapper, '.status-tag__dot')
})
it('shows icon when icon prop is provided', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
icon: 'check'
}
})
expectElementExists(wrapper, '.status-tag__icon')
})
it('shows right icon when rightIcon prop is provided', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
rightIcon: 'arrow-right'
}
})
expectElementExists(wrapper, '.status-tag__right-icon')
})
it('applies custom color when color prop is provided', () => {
const customColor = '#ff0000'
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
color: customColor
}
})
const element = wrapper.find('.status-tag')
expect(element.attributes('style')).toContain(`--status-color: ${customColor}`)
})
it('emits click event when clicked and clickable is true', async () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
clickable: true
}
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('does not emit click event when clickable is false', async () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
clickable: false
}
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('applies disabled class when disabled is true', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
disabled: true
}
})
expect(wrapper.classes()).toContain('status-tag--disabled')
})
it('applies round class when round is true', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
round: true
}
})
expect(wrapper.classes()).toContain('status-tag--round')
})
it('applies plain class when plain is true', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: '测试',
plain: true
}
})
expect(wrapper.classes()).toContain('status-tag--plain')
})
it('computes status text correctly for predefined statuses', () => {
const statusTextMap = [
{ status: 'active', expectedText: '活跃' },
{ status: 'inactive', expectedText: '非活跃' },
{ status: 'online', expectedText: '在线' },
{ status: 'offline', expectedText: '离线' },
{ status: 'approved', expectedText: '已审批' },
{ status: 'rejected', expectedText: '已拒绝' },
{ status: 'processing', expectedText: '处理中' },
{ status: 'completed', expectedText: '已完成' },
{ status: 'cancelled', expectedText: '已取消' }
]
statusTextMap.forEach(({ status, expectedText }) => {
wrapper = mountComponent(StatusTag, {
props: { status }
})
expectElementText(wrapper, '.status-tag__text', expectedText)
wrapper.unmount()
})
})
it('uses custom text when provided', () => {
const customText = '自定义状态'
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
text: customText
}
})
expectElementText(wrapper, '.status-tag__text', customText)
})
it('computes status icon correctly for predefined statuses', () => {
wrapper = mountComponent(StatusTag, {
props: {
status: 'success',
showIcon: true
}
})
expectElementExists(wrapper, '.status-tag__icon')
})
})

View File

@@ -0,0 +1,14 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return {
app
}
}

View File

@@ -0,0 +1,47 @@
{
"name": "银行监管服务小程序",
"appid": "your-bank-miniprogram-appid",
"description": "专业的银行监管服务平台,提供信贷管理、风险监控、客户服务等功能",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true
},
"h5": {
"title": "银行监管服务",
"router": {
"mode": "hash",
"base": "/"
}
},
"mp-weixin": {
"appid": "your-bank-miniprogram-appid",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "获取位置信息用于抵押物地理位置监控"
},
"scope.camera": {
"desc": "拍照功能用于现场核查和资产确认"
}
},
"requiredBackgroundModes": ["location"],
"plugins": {
"WechatSI": {
"version": "0.3.3",
"provider": "wx069ba97219f66d99"
}
}
},
"mp-alipay": {
"usingComponents": true
},
"quickapp": {}
}

View File

@@ -0,0 +1,141 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "银行监管",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/dashboard/dashboard",
"style": {
"navigationBarTitleText": "工作台",
"enablePullDownRefresh": true
}
},
{
"path": "pages/customers/customers",
"style": {
"navigationBarTitleText": "客户管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/customers/customer-detail",
"style": {
"navigationBarTitleText": "客户详情"
}
},
{
"path": "pages/assets/assets",
"style": {
"navigationBarTitleText": "资产监管",
"enablePullDownRefresh": true
}
},
{
"path": "pages/assets/asset-detail",
"style": {
"navigationBarTitleText": "资产详情"
}
},
{
"path": "pages/transactions/transactions",
"style": {
"navigationBarTitleText": "交易管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/transactions/transaction-detail",
"style": {
"navigationBarTitleText": "交易详情"
}
},
{
"path": "pages/risk/risk",
"style": {
"navigationBarTitleText": "风险监控",
"enablePullDownRefresh": true
}
},
{
"path": "pages/risk/risk-detail",
"style": {
"navigationBarTitleText": "风险详情"
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "个人中心",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "银行监管",
"navigationBarBackgroundColor": "#2c5aa0",
"backgroundColor": "#f5f7fa",
"backgroundTextStyle": "dark",
"app-plus": {
"background": "#f5f7fa"
}
},
"tabBar": {
"color": "#666666",
"selectedColor": "#2c5aa0",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "50px",
"fontSize": "12px",
"iconWidth": "24px",
"spacing": "3px",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/images/tab-home.png",
"selectedIconPath": "static/images/tab-home-active.png"
},
{
"pagePath": "pages/dashboard/dashboard",
"text": "工作台",
"iconPath": "static/images/tab-dashboard.png",
"selectedIconPath": "static/images/tab-dashboard-active.png"
},
{
"pagePath": "pages/customers/customers",
"text": "客户",
"iconPath": "static/images/tab-customers.png",
"selectedIconPath": "static/images/tab-customers-active.png"
},
{
"pagePath": "pages/risk/risk",
"text": "风控",
"iconPath": "static/images/tab-risk.png",
"selectedIconPath": "static/images/tab-risk-active.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "static/images/tab-profile.png",
"selectedIconPath": "static/images/tab-profile-active.png"
}
]
},
"condition": {
"current": 0,
"list": []
}
}

View File

@@ -0,0 +1,983 @@
<template>
<view class="assets-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<text class="navbar-title">资产监管</text>
<view class="navbar-actions">
<view class="action-btn" @click="showFilter = true">
<text class="iconfont icon-filter"></text>
</view>
<view class="action-btn" @click="refreshData">
<text class="iconfont icon-refresh"></text>
</view>
</view>
</view>
</view>
<!-- 资产概览 -->
<view class="overview-section">
<view class="overview-card">
<view class="card-header">
<text class="card-title">资产概览</text>
<text class="update-time">更新时间{{ updateTime }}</text>
</view>
<view class="overview-grid">
<view class="overview-item" v-for="item in overviewData" :key="item.key">
<view class="item-value" :class="item.trend">{{ item.value }}</view>
<view class="item-label">{{ item.label }}</view>
<view class="item-change" :class="item.trend">
<text class="trend-icon">{{ item.trend === 'up' ? '↗' : item.trend === 'down' ? '↘' : '→' }}</text>
<text>{{ item.change }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 监管指标 -->
<view class="indicators-section">
<view class="section-header">
<text class="section-title">监管指标</text>
<view class="header-actions">
<picker mode="selector" :value="selectedPeriod" :range="periodOptions" @change="onPeriodChange">
<view class="picker-btn">
<text>{{ periodOptions[selectedPeriod] }}</text>
<text class="iconfont icon-arrow-down"></text>
</view>
</picker>
</view>
</view>
<view class="indicators-grid">
<view class="indicator-card" v-for="indicator in indicators" :key="indicator.id">
<view class="indicator-header">
<text class="indicator-name">{{ indicator.name }}</text>
<view class="indicator-status" :class="indicator.status">
{{ indicator.statusText }}
</view>
</view>
<view class="indicator-value">
<text class="value">{{ indicator.value }}</text>
<text class="unit">{{ indicator.unit }}</text>
</view>
<view class="indicator-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: indicator.progress + '%' }"></view>
</view>
<text class="progress-text">{{ indicator.progress }}%</text>
</view>
<view class="indicator-target">
<text>目标值{{ indicator.target }}{{ indicator.unit }}</text>
</view>
</view>
</view>
</view>
<!-- 资产列表 -->
<view class="assets-list-section">
<view class="section-header">
<text class="section-title">资产列表</text>
<view class="list-stats">
<text> {{ totalAssets }} 项资产</text>
</view>
</view>
<view class="filter-tabs">
<view
class="tab-item"
:class="{ active: activeTab === tab.key }"
v-for="tab in filterTabs"
:key="tab.key"
@click="switchTab(tab.key)"
>
<text>{{ tab.label }}</text>
<view class="tab-count">{{ tab.count }}</view>
</view>
</view>
<scroll-view
class="assets-list"
scroll-y
@scrolltolower="loadMore"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<view class="asset-item" v-for="asset in assetsList" :key="asset.id" @click="viewAssetDetail(asset)">
<view class="asset-header">
<view class="asset-info">
<text class="asset-name">{{ asset.name }}</text>
<text class="asset-code">编号{{ asset.code }}</text>
</view>
<view class="asset-status" :class="asset.status">
{{ asset.statusText }}
</view>
</view>
<view class="asset-details">
<view class="detail-row">
<text class="label">资产类型</text>
<text class="value">{{ asset.type }}</text>
</view>
<view class="detail-row">
<text class="label">评估价值</text>
<text class="value amount">¥{{ asset.value }}</text>
</view>
<view class="detail-row">
<text class="label">抵押率</text>
<text class="value">{{ asset.mortgageRate }}%</text>
</view>
<view class="detail-row">
<text class="label">更新时间</text>
<text class="value">{{ asset.updateTime }}</text>
</view>
</view>
<view class="asset-actions">
<view class="action-btn primary" @click.stop="monitorAsset(asset)">
<text>监控</text>
</view>
<view class="action-btn" @click.stop="evaluateAsset(asset)">
<text>评估</text>
</view>
</view>
</view>
<view class="load-more" v-if="hasMore">
<text>加载更多...</text>
</view>
<view class="no-more" v-else-if="assetsList.length > 0">
<text>没有更多数据了</text>
</view>
</scroll-view>
</view>
<!-- 筛选弹窗 -->
<uni-popup ref="filterPopup" type="bottom" :mask-click="false">
<view class="filter-popup">
<view class="popup-header">
<text class="popup-title">筛选条件</text>
<view class="popup-actions">
<text class="action-text" @click="resetFilter">重置</text>
<text class="action-text primary" @click="applyFilter">确定</text>
</view>
</view>
<view class="filter-content">
<view class="filter-group">
<text class="group-title">资产类型</text>
<view class="checkbox-group">
<label class="checkbox-item" v-for="type in assetTypes" :key="type.value">
<checkbox :value="type.value" :checked="filterForm.types.includes(type.value)" />
<text>{{ type.label }}</text>
</label>
</view>
</view>
<view class="filter-group">
<text class="group-title">资产状态</text>
<view class="checkbox-group">
<label class="checkbox-item" v-for="status in assetStatuses" :key="status.value">
<checkbox :value="status.value" :checked="filterForm.statuses.includes(status.value)" />
<text>{{ status.label }}</text>
</label>
</view>
</view>
<view class="filter-group">
<text class="group-title">价值范围</text>
<view class="range-inputs">
<input
class="range-input"
type="number"
placeholder="最小值"
v-model="filterForm.minValue"
/>
<text class="range-separator">-</text>
<input
class="range-input"
type="number"
placeholder="最大值"
v-model="filterForm.maxValue"
/>
</view>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import { ref, reactive, onMounted, computed } from 'vue'
import { request } from '@/utils/request'
export default {
name: 'AssetsPage',
setup() {
// 响应式数据
const loading = ref(false)
const refreshing = ref(false)
const showFilter = ref(false)
const selectedPeriod = ref(0)
const activeTab = ref('all')
const updateTime = ref('')
const totalAssets = ref(0)
const hasMore = ref(true)
const currentPage = ref(1)
// 概览数据
const overviewData = ref([
{ key: 'total', label: '总资产价值', value: '¥2,580.5万', change: '+12.5%', trend: 'up' },
{ key: 'mortgage', label: '抵押资产', value: '¥1,850.2万', change: '+8.3%', trend: 'up' },
{ key: 'available', label: '可用资产', value: '¥730.3万', change: '-2.1%', trend: 'down' },
{ key: 'risk', label: '风险资产', value: '¥125.6万', change: '+5.2%', trend: 'up' }
])
// 监管指标
const indicators = ref([
{
id: 1,
name: '资产覆盖率',
value: '125.8',
unit: '%',
progress: 85,
target: '120',
status: 'normal',
statusText: '正常'
},
{
id: 2,
name: '抵押率',
value: '68.5',
unit: '%',
progress: 68,
target: '80',
status: 'normal',
statusText: '正常'
},
{
id: 3,
name: '风险资产占比',
value: '4.9',
unit: '%',
progress: 49,
target: '10',
status: 'warning',
statusText: '预警'
},
{
id: 4,
name: '资产流动性',
value: '28.3',
unit: '%',
progress: 28,
target: '30',
status: 'risk',
statusText: '风险'
}
])
// 资产列表
const assetsList = ref([])
// 筛选相关
const periodOptions = ['近7天', '近30天', '近3个月', '近6个月', '近1年']
const filterTabs = ref([
{ key: 'all', label: '全部', count: 0 },
{ key: 'normal', label: '正常', count: 0 },
{ key: 'warning', label: '预警', count: 0 },
{ key: 'risk', label: '风险', count: 0 }
])
const assetTypes = [
{ value: 'livestock', label: '牲畜资产' },
{ value: 'equipment', label: '设备资产' },
{ value: 'land', label: '土地资产' },
{ value: 'building', label: '建筑资产' }
]
const assetStatuses = [
{ value: 'normal', label: '正常' },
{ value: 'warning', label: '预警' },
{ value: 'risk', label: '风险' },
{ value: 'frozen', label: '冻结' }
]
const filterForm = reactive({
types: [],
statuses: [],
minValue: '',
maxValue: ''
})
// 方法
const initPageData = async () => {
loading.value = true
try {
await Promise.all([
getOverviewData(),
getIndicators(),
getAssetsList()
])
updateTime.value = new Date().toLocaleString()
} catch (error) {
console.error('初始化页面数据失败:', error)
uni.showToast({
title: '数据加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
const getOverviewData = async () => {
const response = await request.get('/api/assets/overview')
if (response.success) {
overviewData.value = response.data
}
}
const getIndicators = async () => {
const response = await request.get('/api/assets/indicators', {
period: periodOptions[selectedPeriod.value]
})
if (response.success) {
indicators.value = response.data
}
}
const getAssetsList = async (reset = false) => {
if (reset) {
currentPage.value = 1
assetsList.value = []
}
const response = await request.get('/api/assets/list', {
page: currentPage.value,
pageSize: 20,
status: activeTab.value === 'all' ? '' : activeTab.value,
...filterForm
})
if (response.success) {
const { list, total, hasMore: more } = response.data
if (reset) {
assetsList.value = list
} else {
assetsList.value.push(...list)
}
totalAssets.value = total
hasMore.value = more
// 更新标签计数
updateTabCounts(response.data.statusCounts)
}
}
const updateTabCounts = (counts) => {
filterTabs.value.forEach(tab => {
tab.count = counts[tab.key] || 0
})
}
const refreshData = async () => {
await initPageData()
uni.showToast({
title: '刷新成功',
icon: 'success'
})
}
const onPeriodChange = (e) => {
selectedPeriod.value = e.detail.value
getIndicators()
}
const switchTab = (tabKey) => {
activeTab.value = tabKey
getAssetsList(true)
}
const loadMore = () => {
if (hasMore.value && !loading.value) {
currentPage.value++
getAssetsList()
}
}
const onRefresh = async () => {
refreshing.value = true
await getAssetsList(true)
refreshing.value = false
}
const viewAssetDetail = (asset) => {
uni.navigateTo({
url: `/pages/assets/detail?id=${asset.id}`
})
}
const monitorAsset = (asset) => {
uni.navigateTo({
url: `/pages/assets/monitor?id=${asset.id}`
})
}
const evaluateAsset = (asset) => {
uni.navigateTo({
url: `/pages/assets/evaluate?id=${asset.id}`
})
}
const applyFilter = () => {
showFilter.value = false
getAssetsList(true)
}
const resetFilter = () => {
Object.assign(filterForm, {
types: [],
statuses: [],
minValue: '',
maxValue: ''
})
}
// 生命周期
onMounted(() => {
initPageData()
})
return {
loading,
refreshing,
showFilter,
selectedPeriod,
activeTab,
updateTime,
totalAssets,
hasMore,
overviewData,
indicators,
assetsList,
periodOptions,
filterTabs,
assetTypes,
assetStatuses,
filterForm,
initPageData,
refreshData,
onPeriodChange,
switchTab,
loadMore,
onRefresh,
viewAssetDetail,
monitorAsset,
evaluateAsset,
applyFilter,
resetFilter
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.assets-page {
min-height: 100vh;
background-color: $bg-color-light;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(135deg, $primary-color, $primary-color-light);
padding-top: var(--status-bar-height);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $spacing-md;
.navbar-title {
font-size: $font-size-lg;
font-weight: 600;
color: white;
}
.navbar-actions {
display: flex;
gap: $spacing-sm;
.action-btn {
width: 32px;
height: 32px;
border-radius: $border-radius-sm;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
}
}
}
.overview-section {
margin-top: calc(44px + var(--status-bar-height));
padding: $spacing-md;
.overview-card {
background: white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-light;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
.card-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.update-time {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.overview-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-lg;
.overview-item {
text-align: center;
.item-value {
font-size: $font-size-xl;
font-weight: 600;
margin-bottom: $spacing-xs;
&.up { color: $success-color; }
&.down { color: $error-color; }
}
.item-label {
font-size: $font-size-sm;
color: $text-color-secondary;
margin-bottom: $spacing-xs;
}
.item-change {
font-size: $font-size-sm;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
&.up { color: $success-color; }
&.down { color: $error-color; }
.trend-icon {
font-size: 12px;
}
}
}
}
}
}
.indicators-section {
padding: 0 $spacing-md $spacing-md;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
.section-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.picker-btn {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-sm;
background: white;
border-radius: $border-radius-sm;
border: 1px solid $border-color;
font-size: $font-size-sm;
color: $text-color-primary;
}
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-md;
.indicator-card {
background: white;
border-radius: $border-radius-md;
padding: $spacing-md;
box-shadow: $shadow-light;
.indicator-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
.indicator-name {
font-size: $font-size-sm;
color: $text-color-secondary;
}
.indicator-status {
padding: 2px 6px;
border-radius: $border-radius-xs;
font-size: 10px;
&.normal {
background: rgba($success-color, 0.1);
color: $success-color;
}
&.warning {
background: rgba($warning-color, 0.1);
color: $warning-color;
}
&.risk {
background: rgba($error-color, 0.1);
color: $error-color;
}
}
}
.indicator-value {
display: flex;
align-items: baseline;
gap: 2px;
margin-bottom: $spacing-sm;
.value {
font-size: $font-size-xl;
font-weight: 600;
color: $text-color-primary;
}
.unit {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.indicator-progress {
display: flex;
align-items: center;
gap: $spacing-xs;
margin-bottom: $spacing-xs;
.progress-bar {
flex: 1;
height: 4px;
background: $bg-color-light;
border-radius: 2px;
overflow: hidden;
.progress-fill {
height: 100%;
background: $primary-color;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 10px;
color: $text-color-secondary;
}
}
.indicator-target {
font-size: 10px;
color: $text-color-secondary;
}
}
}
}
.assets-list-section {
padding: 0 $spacing-md;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
.section-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.list-stats {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.filter-tabs {
display: flex;
background: white;
border-radius: $border-radius-md;
padding: 4px;
margin-bottom: $spacing-md;
box-shadow: $shadow-light;
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
padding: $spacing-sm;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
color: $text-color-secondary;
transition: all 0.3s ease;
&.active {
background: $primary-color;
color: white;
.tab-count {
background: rgba(255, 255, 255, 0.2);
}
}
.tab-count {
padding: 2px 6px;
border-radius: $border-radius-xs;
background: $bg-color-light;
font-size: 10px;
min-width: 16px;
text-align: center;
}
}
}
.assets-list {
height: calc(100vh - 400px);
.asset-item {
background: white;
border-radius: $border-radius-md;
padding: $spacing-md;
margin-bottom: $spacing-md;
box-shadow: $shadow-light;
.asset-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
.asset-info {
.asset-name {
font-size: $font-size-md;
font-weight: 600;
color: $text-color-primary;
display: block;
margin-bottom: $spacing-xs;
}
.asset-code {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.asset-status {
padding: 4px 8px;
border-radius: $border-radius-xs;
font-size: $font-size-xs;
&.normal {
background: rgba($success-color, 0.1);
color: $success-color;
}
&.warning {
background: rgba($warning-color, 0.1);
color: $warning-color;
}
&.risk {
background: rgba($error-color, 0.1);
color: $error-color;
}
}
}
.asset-details {
margin-bottom: $spacing-md;
.detail-row {
display: flex;
justify-content: space-between;
margin-bottom: $spacing-xs;
.label {
font-size: $font-size-sm;
color: $text-color-secondary;
}
.value {
font-size: $font-size-sm;
color: $text-color-primary;
&.amount {
font-weight: 600;
color: $primary-color;
}
}
}
}
.asset-actions {
display: flex;
gap: $spacing-sm;
.action-btn {
flex: 1;
padding: $spacing-sm;
border-radius: $border-radius-sm;
text-align: center;
font-size: $font-size-sm;
border: 1px solid $border-color;
color: $text-color-primary;
&.primary {
background: $primary-color;
border-color: $primary-color;
color: white;
}
}
}
}
.load-more, .no-more {
text-align: center;
padding: $spacing-lg;
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
}
.filter-popup {
background: white;
border-radius: $border-radius-lg $border-radius-lg 0 0;
max-height: 80vh;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-lg;
border-bottom: 1px solid $border-color;
.popup-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.popup-actions {
display: flex;
gap: $spacing-lg;
.action-text {
font-size: $font-size-md;
color: $text-color-secondary;
&.primary {
color: $primary-color;
}
}
}
}
.filter-content {
padding: $spacing-lg;
max-height: 60vh;
overflow-y: auto;
.filter-group {
margin-bottom: $spacing-xl;
.group-title {
font-size: $font-size-md;
font-weight: 600;
color: $text-color-primary;
margin-bottom: $spacing-md;
display: block;
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-md;
.checkbox-item {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: $font-size-sm;
color: $text-color-primary;
}
}
.range-inputs {
display: flex;
align-items: center;
gap: $spacing-sm;
.range-input {
flex: 1;
padding: $spacing-sm;
border: 1px solid $border-color;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
}
.range-separator {
color: $text-color-secondary;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,866 @@
<template>
<view class="customers-container">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="navbar-left" @click="goBack">
<text class="iconfont icon-arrow-left"></text>
</view>
<text class="navbar-title">客户管理</text>
<view class="navbar-right">
<view class="action-item" @click="showSearchModal">
<text class="iconfont icon-search"></text>
</view>
</view>
</view>
</view>
<!-- 页面内容 -->
<view class="page-content">
<!-- 筛选栏 -->
<view class="filter-bar">
<scroll-view class="filter-scroll" scroll-x>
<view class="filter-list">
<view
class="filter-item"
:class="{ active: activeFilter === item.value }"
v-for="(item, index) in filterOptions"
:key="index"
@click="changeFilter(item.value)"
>
<text class="filter-text">{{ item.label }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 统计信息 -->
<view class="stats-bar">
<view class="stats-item">
<text class="stats-value">{{ totalCount }}</text>
<text class="stats-label">总客户数</text>
</view>
<view class="stats-item">
<text class="stats-value">{{ activeCount }}</text>
<text class="stats-label">活跃客户</text>
</view>
<view class="stats-item">
<text class="stats-value">{{ riskCount }}</text>
<text class="stats-label">风险客户</text>
</view>
</view>
<!-- 客户列表 -->
<scroll-view
class="customer-list"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view
class="customer-item"
v-for="(customer, index) in customerList"
:key="customer.id"
@click="viewCustomerDetail(customer)"
>
<view class="customer-avatar">
<image
v-if="customer.avatar"
:src="customer.avatar"
class="avatar-image"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-text">{{ customer.name.charAt(0) }}</text>
</view>
</view>
<view class="customer-info">
<view class="customer-header">
<text class="customer-name">{{ customer.name }}</text>
<view class="customer-status" :class="customer.statusClass">
<text class="status-text">{{ customer.statusText }}</text>
</view>
</view>
<view class="customer-details">
<text class="detail-item">{{ customer.phone }}</text>
<text class="detail-item">{{ customer.businessType }}</text>
</view>
<view class="customer-metrics">
<view class="metric-item">
<text class="metric-label">贷款余额</text>
<text class="metric-value">¥{{ formatAmount(customer.loanBalance) }}</text>
</view>
<view class="metric-item">
<text class="metric-label">风险等级</text>
<text class="metric-value" :class="customer.riskLevelClass">{{ customer.riskLevel }}</text>
</view>
</view>
</view>
<view class="customer-actions">
<view class="action-btn" @click.stop="callCustomer(customer)">
<text class="iconfont icon-phone"></text>
</view>
<view class="action-btn" @click.stop="messageCustomer(customer)">
<text class="iconfont icon-message"></text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore">
<text class="load-text">{{ loading ? '加载中...' : '上拉加载更多' }}</text>
</view>
<!-- 没有更多数据 -->
<view class="no-more" v-if="!hasMore && customerList.length > 0">
<text class="no-more-text">没有更多数据了</text>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && customerList.length === 0">
<text class="empty-text">暂无客户数据</text>
</view>
</scroll-view>
</view>
<!-- 搜索弹窗 -->
<uni-popup ref="searchPopup" type="top">
<view class="search-modal">
<view class="search-header">
<view class="search-input-wrapper">
<text class="iconfont icon-search search-icon"></text>
<input
class="search-input"
placeholder="搜索客户姓名、手机号"
v-model="searchKeyword"
@input="onSearchInput"
@confirm="performSearch"
/>
<text class="search-cancel" @click="hideSearchModal">取消</text>
</view>
</view>
<!-- 搜索历史 -->
<view class="search-history" v-if="searchHistory.length > 0 && !searchKeyword">
<view class="history-header">
<text class="history-title">搜索历史</text>
<text class="history-clear" @click="clearSearchHistory">清空</text>
</view>
<view class="history-list">
<view
class="history-item"
v-for="(item, index) in searchHistory"
:key="index"
@click="searchByHistory(item)"
>
<text class="history-text">{{ item }}</text>
</view>
</view>
</view>
<!-- 搜索建议 -->
<view class="search-suggestions" v-if="searchSuggestions.length > 0 && searchKeyword">
<view
class="suggestion-item"
v-for="(suggestion, index) in searchSuggestions"
:key="index"
@click="searchBySuggestion(suggestion)"
>
<text class="iconfont icon-search suggestion-icon"></text>
<text class="suggestion-text">{{ suggestion }}</text>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue'
import { useUserStore, useAppStore } from '@/store'
import { get } from '@/utils/request'
export default {
name: 'Customers',
setup() {
const userStore = useUserStore()
const appStore = useAppStore()
// 响应式数据
const refreshing = ref(false)
const loading = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = ref(20)
const activeFilter = ref('all')
const searchKeyword = ref('')
const searchHistory = ref([])
const searchSuggestions = ref([])
const customerList = ref([])
const totalCount = ref(0)
const activeCount = ref(0)
const riskCount = ref(0)
const filterOptions = ref([
{ label: '全部', value: 'all' },
{ label: '活跃', value: 'active' },
{ label: '风险', value: 'risk' },
{ label: '养殖业', value: 'breeding' },
{ label: '种植业', value: 'planting' },
{ label: '加工业', value: 'processing' }
])
// 计算属性
const filteredCustomers = computed(() => {
if (activeFilter.value === 'all') {
return customerList.value
}
return customerList.value.filter(customer => {
switch (activeFilter.value) {
case 'active':
return customer.status === 'active'
case 'risk':
return customer.riskLevel === '高风险'
case 'breeding':
return customer.businessType.includes('养殖')
case 'planting':
return customer.businessType.includes('种植')
case 'processing':
return customer.businessType.includes('加工')
default:
return true
}
})
})
// 方法
const initPageData = async () => {
try {
loading.value = true
await Promise.all([
getCustomerList(),
getCustomerStats()
])
} catch (error) {
console.error('初始化页面数据失败:', error)
appStore.showToast('数据加载失败', 'error')
} finally {
loading.value = false
}
}
const getCustomerList = async (isLoadMore = false) => {
try {
const params = {
page: isLoadMore ? currentPage.value : 1,
pageSize: pageSize.value,
filter: activeFilter.value,
keyword: searchKeyword.value
}
const response = await get('/api/customers', params)
if (response.success) {
const newData = response.data.list || []
if (isLoadMore) {
customerList.value = [...customerList.value, ...newData]
} else {
customerList.value = newData
currentPage.value = 1
}
hasMore.value = newData.length === pageSize.value
totalCount.value = response.data.total || 0
}
} catch (error) {
console.error('获取客户列表失败:', error)
throw error
}
}
const getCustomerStats = async () => {
try {
const response = await get('/api/customers/stats')
if (response.success) {
totalCount.value = response.data.total || 0
activeCount.value = response.data.active || 0
riskCount.value = response.data.risk || 0
}
} catch (error) {
console.error('获取客户统计失败:', error)
}
}
const onRefresh = async () => {
refreshing.value = true
currentPage.value = 1
await initPageData()
refreshing.value = false
}
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
currentPage.value++
try {
await getCustomerList(true)
} catch (error) {
currentPage.value--
} finally {
loading.value = false
}
}
const changeFilter = async (filterValue) => {
if (activeFilter.value === filterValue) return
activeFilter.value = filterValue
currentPage.value = 1
await getCustomerList()
}
const viewCustomerDetail = (customer) => {
uni.navigateTo({
url: `/pages/customers/detail?id=${customer.id}`
})
}
const callCustomer = (customer) => {
uni.makePhoneCall({
phoneNumber: customer.phone,
fail: (error) => {
appStore.showToast('拨打电话失败', 'error')
}
})
}
const messageCustomer = (customer) => {
uni.navigateTo({
url: `/pages/message/chat?customerId=${customer.id}&customerName=${customer.name}`
})
}
const showSearchModal = () => {
// 加载搜索历史
const history = uni.getStorageSync('customerSearchHistory') || []
searchHistory.value = history
// 显示搜索弹窗
this.$refs.searchPopup.open()
}
const hideSearchModal = () => {
this.$refs.searchPopup.close()
searchKeyword.value = ''
searchSuggestions.value = []
}
const onSearchInput = async () => {
if (!searchKeyword.value.trim()) {
searchSuggestions.value = []
return
}
// 获取搜索建议
try {
const response = await get('/api/customers/search-suggestions', {
keyword: searchKeyword.value
})
if (response.success) {
searchSuggestions.value = response.data || []
}
} catch (error) {
console.error('获取搜索建议失败:', error)
}
}
const performSearch = async () => {
if (!searchKeyword.value.trim()) return
// 保存搜索历史
saveSearchHistory(searchKeyword.value)
// 执行搜索
hideSearchModal()
currentPage.value = 1
await getCustomerList()
}
const searchByHistory = (keyword) => {
searchKeyword.value = keyword
performSearch()
}
const searchBySuggestion = (suggestion) => {
searchKeyword.value = suggestion
performSearch()
}
const saveSearchHistory = (keyword) => {
let history = uni.getStorageSync('customerSearchHistory') || []
// 移除重复项
history = history.filter(item => item !== keyword)
// 添加到开头
history.unshift(keyword)
// 限制历史记录数量
if (history.length > 10) {
history = history.slice(0, 10)
}
uni.setStorageSync('customerSearchHistory', history)
searchHistory.value = history
}
const clearSearchHistory = () => {
uni.removeStorageSync('customerSearchHistory')
searchHistory.value = []
}
const formatAmount = (amount) => {
if (!amount) return '0'
const num = parseFloat(amount)
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toLocaleString()
}
const goBack = () => {
uni.navigateBack()
}
// 生命周期
onMounted(() => {
initPageData()
})
return {
refreshing,
loading,
hasMore,
activeFilter,
searchKeyword,
searchHistory,
searchSuggestions,
customerList,
totalCount,
activeCount,
riskCount,
filterOptions,
filteredCustomers,
onRefresh,
loadMore,
changeFilter,
viewCustomerDetail,
callCustomer,
messageCustomer,
showSearchModal,
hideSearchModal,
onSearchInput,
performSearch,
searchByHistory,
searchBySuggestion,
clearSearchHistory,
formatAmount,
goBack
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.customers-container {
min-height: 100vh;
background-color: $background-color;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: white;
border-bottom: 1px solid $border-color;
padding-top: var(--status-bar-height);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $spacing-md;
.navbar-left, .navbar-right {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: $font-size-lg;
color: $text-color-primary;
}
}
.navbar-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.action-item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-sm;
background-color: $background-color;
.iconfont {
font-size: $font-size-md;
color: $text-color-secondary;
}
}
}
}
.page-content {
padding-top: calc(var(--status-bar-height) + 44px);
}
.filter-bar {
background-color: white;
border-bottom: 1px solid $border-color;
.filter-scroll {
white-space: nowrap;
}
.filter-list {
display: flex;
padding: $spacing-sm $spacing-md;
.filter-item {
flex-shrink: 0;
padding: $spacing-sm $spacing-md;
margin-right: $spacing-sm;
border-radius: $border-radius-lg;
background-color: $background-color;
border: 1px solid transparent;
&.active {
background-color: $primary-color;
border-color: $primary-color;
.filter-text {
color: white;
}
}
.filter-text {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
}
}
.stats-bar {
display: flex;
background-color: white;
padding: $spacing-md;
border-bottom: 1px solid $border-color;
.stats-item {
flex: 1;
text-align: center;
.stats-value {
display: block;
font-size: $font-size-xl;
font-weight: 600;
color: $primary-color;
line-height: 1.2;
}
.stats-label {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
margin-top: 4px;
}
}
}
.customer-list {
flex: 1;
padding: $spacing-sm;
}
.customer-item {
background-color: white;
border-radius: $border-radius-md;
padding: $spacing-md;
margin-bottom: $spacing-sm;
box-shadow: $box-shadow-light;
display: flex;
align-items: center;
.customer-avatar {
width: 60px;
height: 60px;
margin-right: $spacing-md;
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: $primary-color;
display: flex;
align-items: center;
justify-content: center;
.avatar-text {
color: white;
font-size: $font-size-lg;
font-weight: 600;
}
}
}
.customer-info {
flex: 1;
.customer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-sm;
.customer-name {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.customer-status {
padding: 2px 8px;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
&.active {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&.inactive {
background-color: rgba($text-color-placeholder, 0.1);
color: $text-color-placeholder;
}
&.risk {
background-color: rgba($danger-color, 0.1);
color: $danger-color;
}
}
}
.customer-details {
display: flex;
margin-bottom: $spacing-sm;
.detail-item {
font-size: $font-size-sm;
color: $text-color-secondary;
margin-right: $spacing-md;
}
}
.customer-metrics {
display: flex;
.metric-item {
margin-right: $spacing-lg;
.metric-label {
display: block;
font-size: $font-size-xs;
color: $text-color-placeholder;
}
.metric-value {
display: block;
font-size: $font-size-sm;
font-weight: 500;
color: $text-color-primary;
margin-top: 2px;
&.low-risk { color: $success-color; }
&.medium-risk { color: $warning-color; }
&.high-risk { color: $danger-color; }
}
}
}
}
.customer-actions {
display: flex;
flex-direction: column;
.action-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-sm;
background-color: $background-color;
margin-bottom: $spacing-sm;
&:last-child {
margin-bottom: 0;
}
.iconfont {
font-size: $font-size-md;
color: $text-color-secondary;
}
}
}
}
.load-more, .no-more, .empty-state {
text-align: center;
padding: $spacing-lg;
.load-text, .no-more-text, .empty-text {
font-size: $font-size-sm;
color: $text-color-placeholder;
}
}
.search-modal {
background-color: white;
min-height: 50vh;
.search-header {
padding: $spacing-md;
border-bottom: 1px solid $border-color;
.search-input-wrapper {
display: flex;
align-items: center;
background-color: $background-color;
border-radius: $border-radius-md;
padding: $spacing-sm $spacing-md;
.search-icon {
font-size: $font-size-md;
color: $text-color-placeholder;
margin-right: $spacing-sm;
}
.search-input {
flex: 1;
font-size: $font-size-md;
color: $text-color-primary;
background-color: transparent;
border: none;
outline: none;
}
.search-cancel {
font-size: $font-size-md;
color: $primary-color;
margin-left: $spacing-sm;
}
}
}
.search-history {
padding: $spacing-md;
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-md;
.history-title {
font-size: $font-size-md;
color: $text-color-primary;
font-weight: 500;
}
.history-clear {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.history-list {
display: flex;
flex-wrap: wrap;
.history-item {
padding: $spacing-sm $spacing-md;
margin-right: $spacing-sm;
margin-bottom: $spacing-sm;
background-color: $background-color;
border-radius: $border-radius-md;
.history-text {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
}
}
.search-suggestions {
.suggestion-item {
display: flex;
align-items: center;
padding: $spacing-md;
border-bottom: 1px solid $border-color;
.suggestion-icon {
font-size: $font-size-md;
color: $text-color-placeholder;
margin-right: $spacing-md;
}
.suggestion-text {
font-size: $font-size-md;
color: $text-color-primary;
}
}
}
}
</style>

View File

@@ -0,0 +1,833 @@
<template>
<view class="dashboard-container">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<text class="navbar-title">监管仪表盘</text>
<view class="navbar-actions">
<view class="action-item" @click="refreshData">
<text class="iconfont icon-refresh"></text>
</view>
</view>
</view>
</view>
<!-- 页面内容 -->
<scroll-view
class="page-content"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 统计概览 -->
<view class="stats-overview">
<view class="stats-grid">
<view
class="stats-item"
v-for="(item, index) in statsData"
:key="index"
@click="navigateToDetail(item.type)"
>
<view class="stats-icon" :class="item.iconClass">
<text class="iconfont" :class="item.icon"></text>
</view>
<view class="stats-info">
<text class="stats-value">{{ item.value }}</text>
<text class="stats-label">{{ item.label }}</text>
</view>
<view class="stats-trend" :class="item.trendClass">
<text class="trend-text">{{ item.trend }}</text>
</view>
</view>
</view>
</view>
<!-- 监管指标 -->
<view class="section">
<view class="section-header">
<text class="section-title">监管指标</text>
<text class="section-more" @click="navigateTo('/pages/risk/risk')">查看更多</text>
</view>
<view class="indicators-list">
<view
class="indicator-item"
v-for="(indicator, index) in indicators"
:key="index"
>
<view class="indicator-info">
<text class="indicator-name">{{ indicator.name }}</text>
<text class="indicator-desc">{{ indicator.description }}</text>
</view>
<view class="indicator-value">
<text class="value-text" :class="indicator.statusClass">{{ indicator.value }}</text>
<text class="value-unit">{{ indicator.unit }}</text>
</view>
<view class="indicator-status" :class="indicator.statusClass">
<text class="status-text">{{ indicator.status }}</text>
</view>
</view>
</view>
</view>
<!-- 资产分布 -->
<view class="section">
<view class="section-header">
<text class="section-title">资产分布</text>
<text class="section-more" @click="navigateTo('/pages/assets/assets')">查看详情</text>
</view>
<view class="asset-distribution">
<view class="chart-container">
<!-- 这里可以集成图表组件 -->
<view class="chart-placeholder">
<text class="placeholder-text">资产分布图表</text>
</view>
</view>
<view class="asset-legend">
<view
class="legend-item"
v-for="(item, index) in assetDistribution"
:key="index"
>
<view class="legend-color" :style="{ backgroundColor: item.color }"></view>
<text class="legend-label">{{ item.label }}</text>
<text class="legend-value">{{ item.percentage }}%</text>
</view>
</view>
</view>
</view>
<!-- 最新动态 -->
<view class="section">
<view class="section-header">
<text class="section-title">最新动态</text>
<text class="section-more" @click="navigateTo('/pages/news/news')">查看全部</text>
</view>
<view class="news-list">
<view
class="news-item"
v-for="(news, index) in newsList"
:key="index"
@click="viewNewsDetail(news)"
>
<view class="news-content">
<text class="news-title">{{ news.title }}</text>
<text class="news-summary">{{ news.summary }}</text>
<view class="news-meta">
<text class="news-time">{{ formatTime(news.createTime) }}</text>
<text class="news-category" :class="news.categoryClass">{{ news.category }}</text>
</view>
</view>
<view class="news-arrow">
<text class="iconfont icon-arrow-right"></text>
</view>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="section">
<view class="section-header">
<text class="section-title">快捷操作</text>
</view>
<view class="quick-actions">
<view
class="action-item"
v-for="(action, index) in quickActions"
:key="index"
@click="handleQuickAction(action)"
>
<view class="action-icon" :class="action.iconClass">
<text class="iconfont" :class="action.icon"></text>
</view>
<text class="action-label">{{ action.label }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { useUserStore, useAppStore } from '@/store'
import { get } from '@/utils/request'
export default {
name: 'Dashboard',
setup() {
const userStore = useUserStore()
const appStore = useAppStore()
// 响应式数据
const refreshing = ref(false)
const statsData = ref([
{
type: 'customers',
label: '监管客户',
value: '1,234',
trend: '+12%',
icon: 'icon-user',
iconClass: 'primary',
trendClass: 'up'
},
{
type: 'assets',
label: '资产总额',
value: '¥8.9亿',
trend: '+5.6%',
icon: 'icon-money',
iconClass: 'success',
trendClass: 'up'
},
{
type: 'transactions',
label: '今日交易',
value: '567',
trend: '-2.1%',
icon: 'icon-transaction',
iconClass: 'warning',
trendClass: 'down'
},
{
type: 'risk',
label: '风险预警',
value: '23',
trend: '+8',
icon: 'icon-warning',
iconClass: 'danger',
trendClass: 'up'
}
])
const indicators = ref([
{
name: '资本充足率',
description: '核心一级资本充足率',
value: '12.5',
unit: '%',
status: '正常',
statusClass: 'normal'
},
{
name: '不良贷款率',
description: '不良贷款占比',
value: '1.8',
unit: '%',
status: '关注',
statusClass: 'warning'
},
{
name: '流动性比率',
description: '流动性覆盖率',
value: '125.6',
unit: '%',
status: '正常',
statusClass: 'normal'
}
])
const assetDistribution = ref([
{ label: '养殖业', percentage: 45, color: '#1890ff' },
{ label: '种植业', percentage: 30, color: '#52c41a' },
{ label: '农产品加工', percentage: 15, color: '#faad14' },
{ label: '其他', percentage: 10, color: '#f5222d' }
])
const newsList = ref([
{
id: 1,
title: '农业银行发布新版监管政策',
summary: '针对农业贷款风险管控的新政策正式实施',
category: '政策',
categoryClass: 'policy',
createTime: new Date().getTime() - 3600000
},
{
id: 2,
title: '养殖业贷款违约率上升预警',
summary: '近期养殖业贷款违约率有所上升,需加强监管',
category: '预警',
categoryClass: 'warning',
createTime: new Date().getTime() - 7200000
},
{
id: 3,
title: '数字化监管系统升级完成',
summary: '新版监管系统已上线,功能更加完善',
category: '系统',
categoryClass: 'system',
createTime: new Date().getTime() - 10800000
}
])
const quickActions = ref([
{
label: '客户管理',
icon: 'icon-user-manage',
iconClass: 'primary',
action: 'customers'
},
{
label: '资产监控',
icon: 'icon-monitor',
iconClass: 'success',
action: 'assets'
},
{
label: '风险评估',
icon: 'icon-risk',
iconClass: 'warning',
action: 'risk'
},
{
label: '报表生成',
icon: 'icon-report',
iconClass: 'info',
action: 'report'
}
])
// 方法
const initPageData = async () => {
try {
appStore.setLoading(true)
// 并发获取数据
await Promise.all([
getDashboardStats(),
getIndicators(),
getAssetDistribution(),
getNewsList()
])
} catch (error) {
console.error('初始化页面数据失败:', error)
appStore.showToast('数据加载失败', 'error')
} finally {
appStore.setLoading(false)
}
}
const getDashboardStats = async () => {
try {
const response = await get('/api/dashboard/stats')
if (response.success) {
statsData.value = response.data
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
const getIndicators = async () => {
try {
const response = await get('/api/dashboard/indicators')
if (response.success) {
indicators.value = response.data
}
} catch (error) {
console.error('获取监管指标失败:', error)
}
}
const getAssetDistribution = async () => {
try {
const response = await get('/api/dashboard/asset-distribution')
if (response.success) {
assetDistribution.value = response.data
}
} catch (error) {
console.error('获取资产分布失败:', error)
}
}
const getNewsList = async () => {
try {
const response = await get('/api/news/latest', { limit: 3 })
if (response.success) {
newsList.value = response.data
}
} catch (error) {
console.error('获取新闻列表失败:', error)
}
}
const onRefresh = async () => {
refreshing.value = true
await initPageData()
refreshing.value = false
}
const refreshData = () => {
initPageData()
}
const navigateToDetail = (type) => {
const routeMap = {
customers: '/pages/customers/customers',
assets: '/pages/assets/assets',
transactions: '/pages/transactions/transactions',
risk: '/pages/risk/risk'
}
const route = routeMap[type]
if (route) {
navigateTo(route)
}
}
const navigateTo = (url) => {
uni.navigateTo({ url })
}
const viewNewsDetail = (news) => {
uni.navigateTo({
url: `/pages/news/detail?id=${news.id}`
})
}
const handleQuickAction = (action) => {
const actionMap = {
customers: '/pages/customers/customers',
assets: '/pages/assets/assets',
risk: '/pages/risk/risk',
report: '/pages/report/report'
}
const route = actionMap[action.action]
if (route) {
navigateTo(route)
}
}
const formatTime = (timestamp) => {
const now = new Date().getTime()
const diff = now - timestamp
if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 24小时内
return `${Math.floor(diff / 3600000)}小时前`
} else {
const date = new Date(timestamp)
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
// 生命周期
onMounted(() => {
initPageData()
})
return {
refreshing,
statsData,
indicators,
assetDistribution,
newsList,
quickActions,
onRefresh,
refreshData,
navigateToDetail,
navigateTo,
viewNewsDetail,
handleQuickAction,
formatTime
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.dashboard-container {
min-height: 100vh;
background-color: $background-color;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: $primary-color;
padding-top: var(--status-bar-height);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $spacing-md;
.navbar-title {
color: white;
font-size: $font-size-lg;
font-weight: 600;
}
.navbar-actions {
display: flex;
align-items: center;
.action-item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-sm;
background-color: rgba(255, 255, 255, 0.1);
.iconfont {
color: white;
font-size: $font-size-md;
}
}
}
}
}
.page-content {
padding-top: calc(var(--status-bar-height) + 44px);
padding-bottom: $spacing-lg;
}
.stats-overview {
padding: $spacing-md;
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
.stats-item {
background-color: white;
border-radius: $border-radius-md;
padding: $spacing-md;
box-shadow: $box-shadow-light;
display: flex;
align-items: center;
.stats-icon {
width: 48px;
height: 48px;
border-radius: $border-radius-md;
display: flex;
align-items: center;
justify-content: center;
margin-right: $spacing-sm;
&.primary { background-color: rgba($primary-color, 0.1); }
&.success { background-color: rgba($success-color, 0.1); }
&.warning { background-color: rgba($warning-color, 0.1); }
&.danger { background-color: rgba($danger-color, 0.1); }
.iconfont {
font-size: 24px;
}
&.primary .iconfont { color: $primary-color; }
&.success .iconfont { color: $success-color; }
&.warning .iconfont { color: $warning-color; }
&.danger .iconfont { color: $danger-color; }
}
.stats-info {
flex: 1;
.stats-value {
display: block;
font-size: $font-size-xl;
font-weight: 600;
color: $text-color-primary;
line-height: 1.2;
}
.stats-label {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
margin-top: 2px;
}
}
.stats-trend {
font-size: $font-size-sm;
font-weight: 500;
&.up { color: $success-color; }
&.down { color: $danger-color; }
}
}
}
}
.section {
margin: $spacing-md;
background-color: white;
border-radius: $border-radius-md;
box-shadow: $box-shadow-light;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md;
border-bottom: 1px solid $border-color;
.section-title {
font-size: $font-size-lg;
font-weight: 600;
color: $text-color-primary;
}
.section-more {
font-size: $font-size-sm;
color: $primary-color;
}
}
}
.indicators-list {
.indicator-item {
display: flex;
align-items: center;
padding: $spacing-md;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
.indicator-info {
flex: 1;
.indicator-name {
display: block;
font-size: $font-size-md;
color: $text-color-primary;
font-weight: 500;
}
.indicator-desc {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
margin-top: 2px;
}
}
.indicator-value {
margin-right: $spacing-md;
text-align: right;
.value-text {
display: block;
font-size: $font-size-lg;
font-weight: 600;
&.normal { color: $success-color; }
&.warning { color: $warning-color; }
&.danger { color: $danger-color; }
}
.value-unit {
font-size: $font-size-sm;
color: $text-color-secondary;
}
}
.indicator-status {
padding: 4px 8px;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
&.normal {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&.warning {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&.danger {
background-color: rgba($danger-color, 0.1);
color: $danger-color;
}
}
}
}
.asset-distribution {
padding: $spacing-md;
.chart-container {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background-color: $background-color;
border-radius: $border-radius-md;
margin-bottom: $spacing-md;
.placeholder-text {
color: $text-color-secondary;
font-size: $font-size-md;
}
}
.asset-legend {
.legend-item {
display: flex;
align-items: center;
margin-bottom: $spacing-sm;
&:last-child {
margin-bottom: 0;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: $spacing-sm;
}
.legend-label {
flex: 1;
font-size: $font-size-sm;
color: $text-color-primary;
}
.legend-value {
font-size: $font-size-sm;
color: $text-color-secondary;
font-weight: 500;
}
}
}
}
.news-list {
.news-item {
display: flex;
align-items: center;
padding: $spacing-md;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
.news-content {
flex: 1;
.news-title {
display: block;
font-size: $font-size-md;
color: $text-color-primary;
font-weight: 500;
line-height: 1.4;
margin-bottom: 4px;
}
.news-summary {
display: block;
font-size: $font-size-sm;
color: $text-color-secondary;
line-height: 1.4;
margin-bottom: $spacing-sm;
}
.news-meta {
display: flex;
align-items: center;
.news-time {
font-size: $font-size-xs;
color: $text-color-placeholder;
margin-right: $spacing-sm;
}
.news-category {
padding: 2px 6px;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
&.policy {
background-color: rgba($primary-color, 0.1);
color: $primary-color;
}
&.warning {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&.system {
background-color: rgba($info-color, 0.1);
color: $info-color;
}
}
}
}
.news-arrow {
margin-left: $spacing-sm;
.iconfont {
color: $text-color-placeholder;
font-size: $font-size-sm;
}
}
}
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: $spacing-md;
.action-item {
display: flex;
flex-direction: column;
align-items: center;
padding: $spacing-md $spacing-sm;
.action-icon {
width: 48px;
height: 48px;
border-radius: $border-radius-md;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: $spacing-sm;
&.primary { background-color: rgba($primary-color, 0.1); }
&.success { background-color: rgba($success-color, 0.1); }
&.warning { background-color: rgba($warning-color, 0.1); }
&.info { background-color: rgba($info-color, 0.1); }
.iconfont {
font-size: 24px;
}
&.primary .iconfont { color: $primary-color; }
&.success .iconfont { color: $success-color; }
&.warning .iconfont { color: $warning-color; }
&.info .iconfont { color: $info-color; }
}
.action-label {
font-size: $font-size-sm;
color: $text-color-primary;
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<view class="index-page">
<!-- 头部欢迎区域 -->
<view class="header-section">
<view class="welcome-card">
<view class="user-info">
<image class="avatar" :src="userInfo.avatar || '/static/images/default-avatar.svg'" mode="aspectFill"></image>
<view class="user-details">
<text class="username">{{ userInfo.realName || '银行用户' }}</text>
<text class="role">{{ userInfo.roleName || '信贷经理' }}</text>
</view>
</view>
<view class="weather-info">
<text class="date">{{ currentDate }}</text>
<text class="weather">{{ weather }}</text>
</view>
</view>
</view>
<!-- 数据概览 -->
<view class="stats-section">
<view class="stats-grid">
<view class="stat-item" @click="navigateTo('/pages/customers/customers')">
<view class="stat-number text-primary">{{ stats.totalCustomers }}</view>
<view class="stat-label">客户总数</view>
</view>
<view class="stat-item" @click="navigateTo('/pages/assets/assets')">
<view class="stat-number text-success">{{ stats.totalAssets }}</view>
<view class="stat-label">监管资产</view>
</view>
<view class="stat-item" @click="navigateTo('/pages/risk/risk')">
<view class="stat-number text-warning">{{ stats.riskAlerts }}</view>
<view class="stat-label">风险预警</view>
</view>
<view class="stat-item" @click="navigateTo('/pages/transactions/transactions')">
<view class="stat-number text-info">{{ stats.todayTransactions }}</view>
<view class="stat-label">今日交易</view>
</view>
</view>
</view>
<!-- 快捷功能 -->
<view class="quick-actions">
<view class="section-title">快捷功能</view>
<view class="action-grid">
<view class="action-item" @click="navigateTo('/pages/customers/customers')">
<image class="action-icon" src="/static/images/icon-customers.svg" mode="aspectFit"></image>
<text class="action-text">客户管理</text>
</view>
<view class="action-item" @click="navigateTo('/pages/assets/assets')">
<image class="action-icon" src="/static/images/icon-assets.svg" mode="aspectFit"></image>
<text class="action-text">资产监管</text>
</view>
<view class="action-item" @click="navigateTo('/pages/transactions/transactions')">
<image class="action-icon" src="/static/images/icon-transactions.svg" mode="aspectFit"></image>
<text class="action-text">交易管理</text>
</view>
<view class="action-item" @click="navigateTo('/pages/risk/risk')">
<image class="action-icon" src="/static/images/icon-risk.svg" mode="aspectFit"></image>
<text class="action-text">风险监控</text>
</view>
</view>
</view>
<!-- 最新动态 -->
<view class="news-section">
<view class="section-title">最新动态</view>
<view class="news-list">
<view class="news-item" v-for="item in newsList" :key="item.id" @click="viewNewsDetail(item)">
<view class="news-content">
<view class="news-title">{{ item.title }}</view>
<view class="news-summary">{{ item.summary }}</view>
<view class="news-time">{{ item.createTime }}</view>
</view>
<view class="news-arrow">></view>
</view>
</view>
</view>
</view>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
export default {
name: 'IndexPage',
setup() {
const userInfo = reactive({
avatar: '',
realName: '',
roleName: ''
})
const stats = reactive({
totalCustomers: 0,
totalAssets: 0,
riskAlerts: 0,
todayTransactions: 0
})
const newsList = ref([])
const currentDate = ref('')
const weather = ref('晴朗')
// 初始化页面数据
const initPageData = async () => {
// 获取当前日期
const now = new Date()
currentDate.value = `${now.getMonth() + 1}${now.getDate()}`
// 获取用户信息
await getUserInfo()
// 获取统计数据
await getStatsData()
// 获取最新动态
await getNewsList()
}
// 获取用户信息
const getUserInfo = async () => {
try {
// 这里应该调用实际的API
userInfo.realName = '张经理'
userInfo.roleName = '信贷经理'
userInfo.avatar = '/static/images/default-avatar.png'
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// 获取统计数据
const getStatsData = async () => {
try {
// 这里应该调用实际的API
stats.totalCustomers = 156
stats.totalAssets = 89
stats.riskAlerts = 3
stats.todayTransactions = 24
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取最新动态
const getNewsList = async () => {
try {
// 这里应该调用实际的API
newsList.value = [
{
id: 1,
title: '新增客户风险预警',
summary: '客户张三的抵押物价值出现异常波动',
createTime: '2小时前'
},
{
id: 2,
title: '系统维护通知',
summary: '系统将于今晚22:00-24:00进行维护升级',
createTime: '4小时前'
},
{
id: 3,
title: '新功能上线',
summary: '资产监控模块新增实时定位功能',
createTime: '1天前'
}
]
} catch (error) {
console.error('获取最新动态失败:', error)
}
}
// 页面导航
const navigateTo = (url) => {
uni.navigateTo({ url })
}
// 查看动态详情
const viewNewsDetail = (item) => {
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
// 下拉刷新
const onPullDownRefresh = async () => {
await initPageData()
uni.stopPullDownRefresh()
}
onMounted(() => {
initPageData()
})
return {
userInfo,
stats,
newsList,
currentDate,
weather,
navigateTo,
viewNewsDetail,
onPullDownRefresh
}
},
onPullDownRefresh() {
this.onPullDownRefresh()
}
}
</script>
<style lang="scss" scoped>
.index-page {
min-height: 100vh;
background: linear-gradient(180deg, #2c5aa0 0%, #f5f7fa 30%);
}
.header-section {
padding: 40rpx 20rpx 20rpx;
}
.welcome-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
align-items: center;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
margin-right: 20rpx;
}
.user-details {
display: flex;
flex-direction: column;
}
.username {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.role {
font-size: 24rpx;
color: #666;
}
.weather-info {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.date {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.weather {
font-size: 24rpx;
color: #666;
}
.stats-section {
padding: 0 20rpx 20rpx;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.stat-item {
background: #fff;
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
.stat-number {
font-size: 48rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 24rpx;
color: #666;
}
.quick-actions, .news-section {
background: #fff;
margin: 20rpx;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 24rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
}
.action-icon {
width: 64rpx;
height: 64rpx;
margin-bottom: 12rpx;
}
.action-text {
font-size: 24rpx;
color: #333;
}
.news-list {
display: flex;
flex-direction: column;
}
.news-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.news-content {
flex: 1;
}
.news-title {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.news-summary {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.news-time {
font-size: 20rpx;
color: #999;
}
.news-arrow {
font-size: 24rpx;
color: #ccc;
margin-left: 20rpx;
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<view class="login-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<text class="navbar-title">银行监管系统</text>
</view>
</view>
<!-- 登录表单 -->
<view class="login-container">
<!-- Logo区域 -->
<view class="logo-section">
<image class="logo" src="/static/images/bank-logo.png" mode="aspectFit"></image>
<text class="app-name">银行监管小程序</text>
<text class="app-desc">专业的银行信贷风险管理平台</text>
</view>
<!-- 登录表单 -->
<view class="form-section">
<view class="form-item">
<view class="form-label">
<image class="form-icon" src="/static/images/icon-user.png" mode="aspectFit"></image>
</view>
<input
class="form-input"
type="text"
placeholder="请输入用户名"
v-model="loginForm.username"
:disabled="loading"
/>
</view>
<view class="form-item">
<view class="form-label">
<image class="form-icon" src="/static/images/icon-password.png" mode="aspectFit"></image>
</view>
<input
class="form-input"
type="password"
placeholder="请输入密码"
v-model="loginForm.password"
:disabled="loading"
/>
</view>
<!-- 登录按钮 -->
<button
class="login-btn"
:class="{ 'login-btn-disabled': loading }"
:disabled="loading"
@click="handleLogin"
>
<text v-if="loading">登录中...</text>
<text v-else>登录</text>
</button>
<!-- 微信登录 -->
<view class="wechat-login">
<view class="divider">
<text class="divider-text"></text>
</view>
<button
class="wechat-btn"
open-type="getUserInfo"
@getuserinfo="handleWechatLogin"
:disabled="loading"
>
<image class="wechat-icon" src="/static/images/icon-wechat.png" mode="aspectFit"></image>
<text>微信快速登录</text>
</button>
</view>
</view>
<!-- 底部信息 -->
<view class="footer-section">
<text class="footer-text">仅限银行内部人员使用</text>
<text class="version-text">版本 v1.0.0</text>
</view>
</view>
</view>
</template>
<script>
import { reactive, ref } from 'vue'
import { useUserStore } from '@/store/user'
export default {
name: 'LoginPage',
setup() {
const userStore = useUserStore()
const loginForm = reactive({
username: '',
password: ''
})
const loading = ref(false)
// 用户名密码登录
const handleLogin = async () => {
if (!loginForm.username.trim()) {
uni.showToast({
title: '请输入用户名',
icon: 'none'
})
return
}
if (!loginForm.password.trim()) {
uni.showToast({
title: '请输入密码',
icon: 'none'
})
return
}
loading.value = true
try {
// 调用登录API
const result = await userStore.login({
username: loginForm.username,
password: loginForm.password
})
if (result.success) {
uni.showToast({
title: '登录成功',
icon: 'success'
})
// 跳转到首页
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1500)
} else {
uni.showToast({
title: result.message || '登录失败',
icon: 'none'
})
}
} catch (error) {
console.error('登录失败:', error)
uni.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 微信登录
const handleWechatLogin = async (e) => {
const { userInfo } = e.detail
if (!userInfo) {
uni.showToast({
title: '授权失败',
icon: 'none'
})
return
}
loading.value = true
try {
// 获取微信登录code
const loginRes = await uni.login({
provider: 'weixin'
})
if (loginRes.code) {
// 调用微信登录API
const result = await userStore.wechatLogin({
code: loginRes.code,
userInfo: userInfo
})
if (result.success) {
uni.showToast({
title: '登录成功',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1500)
} else {
uni.showToast({
title: result.message || '微信登录失败',
icon: 'none'
})
}
}
} catch (error) {
console.error('微信登录失败:', error)
uni.showToast({
title: '微信登录失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
return {
loginForm,
loading,
handleLogin,
handleWechatLogin
}
}
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
}
.custom-navbar {
height: 88rpx;
padding-top: var(--status-bar-height);
background: transparent;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.navbar-title {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.login-container {
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
min-height: calc(100vh - 88rpx);
}
.logo-section {
text-align: center;
margin-bottom: 80rpx;
}
.logo {
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
}
.app-name {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #fff;
margin-bottom: 12rpx;
}
.app-desc {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.form-section {
flex: 1;
}
.form-item {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
margin-bottom: 32rpx;
padding: 0 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.2);
}
.form-label {
width: 60rpx;
display: flex;
justify-content: center;
}
.form-icon {
width: 32rpx;
height: 32rpx;
}
.form-input {
flex: 1;
height: 88rpx;
font-size: 28rpx;
color: #fff;
padding-left: 20rpx;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.login-btn {
width: 100%;
height: 88rpx;
background: #fff;
color: #2c5aa0;
border: none;
border-radius: 12rpx;
font-size: 32rpx;
font-weight: 600;
margin-top: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.login-btn-disabled {
opacity: 0.6;
}
.wechat-login {
margin-top: 60rpx;
}
.divider {
text-align: center;
margin-bottom: 40rpx;
position: relative;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1rpx;
background: rgba(255, 255, 255, 0.3);
}
.divider-text {
background: #2c5aa0;
color: rgba(255, 255, 255, 0.8);
padding: 0 20rpx;
font-size: 24rpx;
position: relative;
z-index: 1;
}
.wechat-btn {
width: 100%;
height: 88rpx;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 2rpx solid rgba(255, 255, 255, 0.3);
border-radius: 12rpx;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-icon {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.footer-section {
text-align: center;
margin-top: 60rpx;
}
.footer-text {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12rpx;
}
.version-text {
display: block;
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
<!-- SVG placeholder for default avatar -->
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="40" fill="#f0f0f0"/>
<circle cx="40" cy="30" r="12" fill="#d0d0d0"/>
<path d="M20 65c0-11 9-20 20-20s20 9 20 20" fill="#d0d0d0"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@@ -0,0 +1,5 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="40" fill="#f0f0f0"/>
<circle cx="40" cy="30" r="12" fill="#d0d0d0"/>
<path d="M20 65c0-11 9-20 20-20s20 9 20 20" fill="#d0d0d0"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="8" fill="#50C878"/>
<path d="M14 16h20c1.1 0 2 .9 2 2v16c0 1.1-.9 2-2 2H14c-1.1 0-2-.9-2-2V18c0-1.1.9-2 2-2zm0 2v4h20v-4H14zm0 6v10h20V24H14zm4 2h8v2h-8v-2zm0 4h6v2h-6v-2z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="8" fill="#4A90E2"/>
<path d="M24 14c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6 2.69-6 6-6zm0 20c-6.63 0-12-3.37-12-7.5V24c0-1.1.9-2 2-2h20c1.1 0 2 .9 2 2v2.5c0 4.13-5.37 7.5-12 7.5z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="8" fill="#FF4757"/>
<path d="M24 14l8 14H16l8-14zm-2 6v4h4v-4h-4zm0 6v2h4v-2h-4z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="8" fill="#FF6B35"/>
<path d="M24 12l8 8h-4v8h-8v-8h-4l8-8zm-8 20h16v4H16v-4z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -0,0 +1,305 @@
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
// 应用配置
appConfig: {
version: '1.0.0',
name: '银行监管小程序',
theme: 'light'
},
// 系统信息
systemInfo: null,
// 网络状态
networkStatus: 'unknown',
// 加载状态
loading: false,
// 全局提示信息
toast: {
show: false,
message: '',
type: 'info' // info, success, warning, error
},
// 页面栈
pageStack: [],
// 当前页面
currentPage: '',
// 全局数据缓存
globalCache: new Map()
}),
getters: {
// 是否为开发环境
isDevelopment: () => process.env.NODE_ENV === 'development',
// 获取应用版本
appVersion: (state) => state.appConfig.version,
// 获取应用名称
appName: (state) => state.appConfig.name,
// 是否在线
isOnline: (state) => state.networkStatus !== 'none',
// 获取缓存数据
getCacheData: (state) => (key) => {
return state.globalCache.get(key)
}
},
actions: {
/**
* 初始化应用
*/
async initApp() {
try {
// 获取系统信息
await this.getSystemInfo()
// 监听网络状态
this.watchNetworkStatus()
// 初始化应用配置
await this.loadAppConfig()
console.log('应用初始化完成')
} catch (error) {
console.error('应用初始化失败:', error)
}
},
/**
* 获取系统信息
*/
async getSystemInfo() {
return new Promise((resolve) => {
uni.getSystemInfo({
success: (res) => {
this.systemInfo = res
resolve(res)
},
fail: (error) => {
console.error('获取系统信息失败:', error)
resolve(null)
}
})
})
},
/**
* 监听网络状态
*/
watchNetworkStatus() {
// 获取当前网络状态
uni.getNetworkType({
success: (res) => {
this.networkStatus = res.networkType
}
})
// 监听网络状态变化
uni.onNetworkStatusChange((res) => {
this.networkStatus = res.networkType
if (!res.isConnected) {
this.showToast('网络连接已断开', 'warning')
} else {
this.showToast('网络连接已恢复', 'success')
}
})
},
/**
* 加载应用配置
*/
async loadAppConfig() {
try {
// 从本地存储加载配置
const localConfig = uni.getStorageSync('appConfig')
if (localConfig) {
this.appConfig = { ...this.appConfig, ...localConfig }
}
// 这里可以从服务器获取最新配置
// const serverConfig = await get('/api/app/config')
// if (serverConfig.success) {
// this.appConfig = { ...this.appConfig, ...serverConfig.data }
// uni.setStorageSync('appConfig', this.appConfig)
// }
} catch (error) {
console.error('加载应用配置失败:', error)
}
},
/**
* 更新应用配置
*/
updateAppConfig(config) {
this.appConfig = { ...this.appConfig, ...config }
uni.setStorageSync('appConfig', this.appConfig)
},
/**
* 设置加载状态
*/
setLoading(loading) {
this.loading = loading
if (loading) {
uni.showLoading({
title: '加载中...',
mask: true
})
} else {
uni.hideLoading()
}
},
/**
* 显示提示信息
*/
showToast(message, type = 'info', duration = 2000) {
this.toast = {
show: true,
message,
type
}
// 使用uni-app的提示
const iconMap = {
success: 'success',
error: 'error',
warning: 'none',
info: 'none'
}
uni.showToast({
title: message,
icon: iconMap[type] || 'none',
duration,
mask: false
})
// 自动隐藏
setTimeout(() => {
this.toast.show = false
}, duration)
},
/**
* 隐藏提示信息
*/
hideToast() {
this.toast.show = false
uni.hideToast()
},
/**
* 更新页面栈
*/
updatePageStack() {
const pages = getCurrentPages()
this.pageStack = pages.map(page => page.route)
this.currentPage = pages[pages.length - 1]?.route || ''
},
/**
* 设置缓存数据
*/
setCacheData(key, data, expireTime = null) {
const cacheItem = {
data,
timestamp: Date.now(),
expireTime
}
this.globalCache.set(key, cacheItem)
},
/**
* 获取缓存数据
*/
getCacheData(key) {
const cacheItem = this.globalCache.get(key)
if (!cacheItem) {
return null
}
// 检查是否过期
if (cacheItem.expireTime && Date.now() > cacheItem.expireTime) {
this.globalCache.delete(key)
return null
}
return cacheItem.data
},
/**
* 清除缓存数据
*/
clearCacheData(key) {
if (key) {
this.globalCache.delete(key)
} else {
this.globalCache.clear()
}
},
/**
* 检查应用更新
*/
async checkAppUpdate() {
// #ifdef MP-WEIXIN
if (uni.canIUse('getUpdateManager')) {
const updateManager = uni.getUpdateManager()
updateManager.onCheckForUpdate((res) => {
if (res.hasUpdate) {
this.showToast('发现新版本,正在下载...', 'info')
}
})
updateManager.onUpdateReady(() => {
uni.showModal({
title: '更新提示',
content: '新版本已准备好,是否重启应用?',
success: (res) => {
if (res.confirm) {
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(() => {
this.showToast('新版本下载失败', 'error')
})
}
// #endif
},
/**
* 获取设备信息
*/
getDeviceInfo() {
return {
platform: this.systemInfo?.platform || 'unknown',
system: this.systemInfo?.system || 'unknown',
version: this.systemInfo?.version || 'unknown',
model: this.systemInfo?.model || 'unknown',
brand: this.systemInfo?.brand || 'unknown',
screenWidth: this.systemInfo?.screenWidth || 0,
screenHeight: this.systemInfo?.screenHeight || 0,
windowWidth: this.systemInfo?.windowWidth || 0,
windowHeight: this.systemInfo?.windowHeight || 0
}
}
}
})

View File

@@ -0,0 +1,9 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
// 导出所有store
export { useUserStore } from './user'
export { useAppStore } from './app'

View File

@@ -0,0 +1,216 @@
import { defineStore } from 'pinia'
import { post, get } from '@/utils/request'
export const useUserStore = defineStore('user', {
state: () => ({
// 用户信息
userInfo: null,
// 登录状态
isLoggedIn: false,
// 用户权限
permissions: [],
// 用户角色
roles: []
}),
getters: {
// 获取用户ID
userId: (state) => state.userInfo?.id,
// 获取用户名
username: (state) => state.userInfo?.username,
// 获取真实姓名
realName: (state) => state.userInfo?.realName,
// 获取用户角色名称
roleName: (state) => state.userInfo?.roleName,
// 检查是否有特定权限
hasPermission: (state) => (permission) => {
return state.permissions.includes(permission)
},
// 检查是否有特定角色
hasRole: (state) => (role) => {
return state.roles.includes(role)
}
},
actions: {
/**
* 用户名密码登录
*/
async login(loginData) {
try {
const response = await post('/api/auth/login', loginData)
if (response.success) {
// 保存token
uni.setStorageSync('token', response.data.token)
// 保存用户信息
this.userInfo = response.data.userInfo
this.isLoggedIn = true
this.permissions = response.data.permissions || []
this.roles = response.data.roles || []
// 持久化用户信息
uni.setStorageSync('userInfo', this.userInfo)
return { success: true }
} else {
return { success: false, message: response.message }
}
} catch (error) {
console.error('登录失败:', error)
return { success: false, message: error.message }
}
},
/**
* 微信登录
*/
async wechatLogin(wechatData) {
try {
const response = await post('/api/auth/wechat-login', wechatData)
if (response.success) {
// 保存token
uni.setStorageSync('token', response.data.token)
// 保存用户信息
this.userInfo = response.data.userInfo
this.isLoggedIn = true
this.permissions = response.data.permissions || []
this.roles = response.data.roles || []
// 持久化用户信息
uni.setStorageSync('userInfo', this.userInfo)
return { success: true }
} else {
return { success: false, message: response.message }
}
} catch (error) {
console.error('微信登录失败:', error)
return { success: false, message: error.message }
}
},
/**
* 退出登录
*/
async logout() {
try {
// 调用退出登录接口
await post('/api/auth/logout')
} catch (error) {
console.error('退出登录接口调用失败:', error)
} finally {
// 清除本地数据
this.clearUserData()
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/login'
})
}
},
/**
* 获取用户信息
*/
async getUserInfo() {
try {
const response = await get('/api/auth/profile')
if (response.success) {
this.userInfo = response.data
this.permissions = response.data.permissions || []
this.roles = response.data.roles || []
// 更新本地存储
uni.setStorageSync('userInfo', this.userInfo)
return { success: true, data: response.data }
} else {
return { success: false, message: response.message }
}
} catch (error) {
console.error('获取用户信息失败:', error)
return { success: false, message: error.message }
}
},
/**
* 更新用户信息
*/
async updateUserInfo(updateData) {
try {
const response = await post('/api/users/update-profile', updateData)
if (response.success) {
// 更新本地用户信息
this.userInfo = { ...this.userInfo, ...response.data }
uni.setStorageSync('userInfo', this.userInfo)
return { success: true }
} else {
return { success: false, message: response.message }
}
} catch (error) {
console.error('更新用户信息失败:', error)
return { success: false, message: error.message }
}
},
/**
* 检查登录状态
*/
checkLoginStatus() {
const token = uni.getStorageSync('token')
const userInfo = uni.getStorageSync('userInfo')
if (token && userInfo) {
this.userInfo = userInfo
this.isLoggedIn = true
return true
} else {
this.clearUserData()
return false
}
},
/**
* 清除用户数据
*/
clearUserData() {
this.userInfo = null
this.isLoggedIn = false
this.permissions = []
this.roles = []
// 清除本地存储
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
},
/**
* 初始化用户状态
*/
initUserState() {
// 从本地存储恢复用户状态
const token = uni.getStorageSync('token')
const userInfo = uni.getStorageSync('userInfo')
if (token && userInfo) {
this.userInfo = userInfo
this.isLoggedIn = true
// 异步获取最新用户信息
this.getUserInfo()
}
}
}
})

View File

@@ -0,0 +1,184 @@
@import './variables.scss';
/* 重置样式 */
* {
box-sizing: border-box;
}
page {
background-color: $bg-color;
font-size: $font-size-base;
line-height: 1.6;
color: $text-color;
}
/* 布局类 */
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.align-center {
align-items: center;
}
.align-start {
align-items: flex-start;
}
.align-end {
align-items: flex-end;
}
.flex-1 {
flex: 1;
}
/* 文本类 */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-xs {
font-size: $font-size-xs;
}
.text-sm {
font-size: $font-size-sm;
}
.text-base {
font-size: $font-size-base;
}
.text-lg {
font-size: $font-size-lg;
}
.text-xl {
font-size: $font-size-xl;
}
.text-xxl {
font-size: $font-size-xxl;
}
.font-bold {
font-weight: bold;
}
.font-medium {
font-weight: 500;
}
.font-normal {
font-weight: normal;
}
/* 间距类 */
.m-0 { margin: 0; }
.m-xs { margin: $spacing-xs; }
.m-sm { margin: $spacing-sm; }
.m-base { margin: $spacing-base; }
.m-lg { margin: $spacing-lg; }
.m-xl { margin: $spacing-xl; }
.m-xxl { margin: $spacing-xxl; }
.mt-0 { margin-top: 0; }
.mt-xs { margin-top: $spacing-xs; }
.mt-sm { margin-top: $spacing-sm; }
.mt-base { margin-top: $spacing-base; }
.mt-lg { margin-top: $spacing-lg; }
.mt-xl { margin-top: $spacing-xl; }
.mt-xxl { margin-top: $spacing-xxl; }
.mb-0 { margin-bottom: 0; }
.mb-xs { margin-bottom: $spacing-xs; }
.mb-sm { margin-bottom: $spacing-sm; }
.mb-base { margin-bottom: $spacing-base; }
.mb-lg { margin-bottom: $spacing-lg; }
.mb-xl { margin-bottom: $spacing-xl; }
.mb-xxl { margin-bottom: $spacing-xxl; }
.ml-0 { margin-left: 0; }
.ml-xs { margin-left: $spacing-xs; }
.ml-sm { margin-left: $spacing-sm; }
.ml-base { margin-left: $spacing-base; }
.ml-lg { margin-left: $spacing-lg; }
.ml-xl { margin-left: $spacing-xl; }
.ml-xxl { margin-left: $spacing-xxl; }
.mr-0 { margin-right: 0; }
.mr-xs { margin-right: $spacing-xs; }
.mr-sm { margin-right: $spacing-sm; }
.mr-base { margin-right: $spacing-base; }
.mr-lg { margin-right: $spacing-lg; }
.mr-xl { margin-right: $spacing-xl; }
.mr-xxl { margin-right: $spacing-xxl; }
.p-0 { padding: 0; }
.p-xs { padding: $spacing-xs; }
.p-sm { padding: $spacing-sm; }
.p-base { padding: $spacing-base; }
.p-lg { padding: $spacing-lg; }
.p-xl { padding: $spacing-xl; }
.p-xxl { padding: $spacing-xxl; }
.pt-0 { padding-top: 0; }
.pt-xs { padding-top: $spacing-xs; }
.pt-sm { padding-top: $spacing-sm; }
.pt-base { padding-top: $spacing-base; }
.pt-lg { padding-top: $spacing-lg; }
.pt-xl { padding-top: $spacing-xl; }
.pt-xxl { padding-top: $spacing-xxl; }
.pb-0 { padding-bottom: 0; }
.pb-xs { padding-bottom: $spacing-xs; }
.pb-sm { padding-bottom: $spacing-sm; }
.pb-base { padding-bottom: $spacing-base; }
.pb-lg { padding-bottom: $spacing-lg; }
.pb-xl { padding-bottom: $spacing-xl; }
.pb-xxl { padding-bottom: $spacing-xxl; }
.pl-0 { padding-left: 0; }
.pl-xs { padding-left: $spacing-xs; }
.pl-sm { padding-left: $spacing-sm; }
.pl-base { padding-left: $spacing-base; }
.pl-lg { padding-left: $spacing-lg; }
.pl-xl { padding-left: $spacing-xl; }
.pl-xxl { padding-left: $spacing-xxl; }
.pr-0 { padding-right: 0; }
.pr-xs { padding-right: $spacing-xs; }
.pr-sm { padding-right: $spacing-sm; }
.pr-base { padding-right: $spacing-base; }
.pr-lg { padding-right: $spacing-lg; }
.pr-xl { padding-right: $spacing-xl; }
.pr-xxl { padding-right: $spacing-xxl; }

View File

@@ -0,0 +1,544 @@
// 混合器文件 - 提供常用的样式混合器和工具类
@import './variables.scss';
// 文本省略
@mixin text-ellipsis($lines: 1) {
@if $lines == 1 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 清除浮动
@mixin clearfix {
&::after {
content: '';
display: table;
clear: both;
}
}
// 居中对齐
@mixin center($type: 'both') {
position: absolute;
@if $type == 'both' {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
} @else if $type == 'horizontal' {
left: 50%;
transform: translateX(-50%);
} @else if $type == 'vertical' {
top: 50%;
transform: translateY(-50%);
}
}
// Flex 布局
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $wrap: nowrap) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
flex-wrap: $wrap;
}
// Flex 居中
@mixin flex-center {
@include flex(row, center, center);
}
// 响应式断点
@mixin respond-to($breakpoint) {
@if $breakpoint == 'mobile' {
@media (max-width: 767px) {
@content;
}
} @else if $breakpoint == 'tablet' {
@media (min-width: 768px) and (max-width: 1023px) {
@content;
}
} @else if $breakpoint == 'desktop' {
@media (min-width: 1024px) {
@content;
}
} @else if $breakpoint == 'large-desktop' {
@media (min-width: 1200px) {
@content;
}
}
}
// 按钮样式
@mixin button-style($type: 'primary', $size: 'medium') {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: $border-radius-sm;
font-weight: 500;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
// 尺寸
@if $size == 'small' {
padding: $spacing-xs $spacing-sm;
font-size: $font-size-sm;
min-height: 32px;
} @else if $size == 'medium' {
padding: $spacing-sm $spacing-md;
font-size: $font-size-md;
min-height: 40px;
} @else if $size == 'large' {
padding: $spacing-md $spacing-lg;
font-size: $font-size-lg;
min-height: 48px;
}
// 类型
@if $type == 'primary' {
background: $primary-color;
color: white;
&:hover {
background: $primary-color-light;
}
&:active {
background: $primary-color-dark;
}
&:disabled {
background: $text-color-disabled;
cursor: not-allowed;
}
} @else if $type == 'secondary' {
background: white;
color: $primary-color;
border: 1px solid $primary-color;
&:hover {
background: rgba($primary-color, 0.05);
}
&:active {
background: rgba($primary-color, 0.1);
}
&:disabled {
color: $text-color-disabled;
border-color: $text-color-disabled;
cursor: not-allowed;
}
} @else if $type == 'text' {
background: transparent;
color: $primary-color;
&:hover {
background: rgba($primary-color, 0.05);
}
&:active {
background: rgba($primary-color, 0.1);
}
&:disabled {
color: $text-color-disabled;
cursor: not-allowed;
}
}
}
// 卡片样式
@mixin card-style($shadow: true, $border: true, $radius: true) {
background: white;
@if $border {
border: 1px solid $border-color-light;
}
@if $radius {
border-radius: $border-radius-md;
}
@if $shadow {
box-shadow: $shadow-light;
}
}
// 输入框样式
@mixin input-style($size: 'medium') {
width: 100%;
border: 1px solid $border-color;
border-radius: $border-radius-sm;
background: white;
color: $text-color-primary;
font-size: $font-size-md;
transition: all 0.3s ease;
@if $size == 'small' {
padding: $spacing-xs $spacing-sm;
font-size: $font-size-sm;
min-height: 32px;
} @else if $size == 'medium' {
padding: $spacing-sm $spacing-md;
font-size: $font-size-md;
min-height: 40px;
} @else if $size == 'large' {
padding: $spacing-md $spacing-lg;
font-size: $font-size-lg;
min-height: 48px;
}
&::placeholder {
color: $text-color-placeholder;
}
&:focus {
border-color: $primary-color;
outline: none;
box-shadow: 0 0 0 2px rgba($primary-color, 0.2);
}
&:disabled {
background: $bg-color-light;
color: $text-color-disabled;
cursor: not-allowed;
}
&.error {
border-color: $error-color;
&:focus {
box-shadow: 0 0 0 2px rgba($error-color, 0.2);
}
}
}
// 滚动条样式
@mixin scrollbar($width: 6px, $track-color: transparent, $thumb-color: rgba(0, 0, 0, 0.2)) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: $track-color;
border-radius: $width / 2;
}
&::-webkit-scrollbar-thumb {
background: $thumb-color;
border-radius: $width / 2;
&:hover {
background: rgba(0, 0, 0, 0.4);
}
}
}
// 动画
@mixin fade-in($duration: 0.3s) {
animation: fadeIn $duration ease-in-out;
}
@mixin fade-out($duration: 0.3s) {
animation: fadeOut $duration ease-in-out;
}
@mixin slide-up($duration: 0.3s) {
animation: slideUp $duration ease-out;
}
@mixin slide-down($duration: 0.3s) {
animation: slideDown $duration ease-out;
}
@mixin bounce-in($duration: 0.5s) {
animation: bounceIn $duration ease-out;
}
// 关键帧动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes bounceIn {
0% {
transform: scale(0.3);
opacity: 0;
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
// 阴影
@mixin shadow($level: 1) {
@if $level == 1 {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} @else if $level == 2 {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
} @else if $level == 3 {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
} @else if $level == 4 {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
}
// 边框
@mixin border($position: 'all', $color: $border-color, $width: 1px, $style: solid) {
@if $position == 'all' {
border: $width $style $color;
} @else if $position == 'top' {
border-top: $width $style $color;
} @else if $position == 'right' {
border-right: $width $style $color;
} @else if $position == 'bottom' {
border-bottom: $width $style $color;
} @else if $position == 'left' {
border-left: $width $style $color;
} @else if $position == 'horizontal' {
border-left: $width $style $color;
border-right: $width $style $color;
} @else if $position == 'vertical' {
border-top: $width $style $color;
border-bottom: $width $style $color;
}
}
// 渐变背景
@mixin gradient($direction: 'to right', $colors...) {
background: linear-gradient(#{$direction}, $colors);
}
// 毛玻璃效果
@mixin glass($blur: 10px, $opacity: 0.8) {
backdrop-filter: blur($blur);
background: rgba(255, 255, 255, $opacity);
}
// 文本样式
@mixin text-style($size: 'medium', $weight: normal, $color: $text-color-primary) {
@if $size == 'xs' {
font-size: $font-size-xs;
} @else if $size == 'sm' {
font-size: $font-size-sm;
} @else if $size == 'md' {
font-size: $font-size-md;
} @else if $size == 'lg' {
font-size: $font-size-lg;
} @else if $size == 'xl' {
font-size: $font-size-xl;
}
font-weight: $weight;
color: $color;
line-height: 1.5;
}
// 安全区域适配
@mixin safe-area($position: 'bottom', $property: 'padding') {
@if $position == 'top' {
#{$property}-top: env(safe-area-inset-top);
} @else if $position == 'bottom' {
#{$property}-bottom: env(safe-area-inset-bottom);
} @else if $position == 'left' {
#{$property}-left: env(safe-area-inset-left);
} @else if $position == 'right' {
#{$property}-right: env(safe-area-inset-right);
}
}
// 1px 边框解决方案
@mixin hairline($position: 'all', $color: $border-color) {
position: relative;
&::after {
content: '';
position: absolute;
pointer-events: none;
@if $position == 'all' {
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 1px solid $color;
transform-origin: 0 0;
transform: scale(0.5);
} @else if $position == 'top' {
top: 0;
left: 0;
right: 0;
height: 1px;
background: $color;
transform-origin: 0 0;
transform: scaleY(0.5);
} @else if $position == 'bottom' {
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: $color;
transform-origin: 0 100%;
transform: scaleY(0.5);
} @else if $position == 'left' {
top: 0;
left: 0;
bottom: 0;
width: 1px;
background: $color;
transform-origin: 0 0;
transform: scaleX(0.5);
} @else if $position == 'right' {
top: 0;
right: 0;
bottom: 0;
width: 1px;
background: $color;
transform-origin: 100% 0;
transform: scaleX(0.5);
}
}
}
// 网格布局
@mixin grid($columns: 2, $gap: $spacing-md) {
display: grid;
grid-template-columns: repeat($columns, 1fr);
gap: $gap;
}
// 固定宽高比
@mixin aspect-ratio($ratio: 1) {
position: relative;
&::before {
content: '';
display: block;
padding-top: percentage(1 / $ratio);
}
> * {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
// 隐藏元素但保持可访问性
@mixin visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
// 重置按钮样式
@mixin reset-button {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
outline: none;
}
// 重置列表样式
@mixin reset-list {
list-style: none;
padding: 0;
margin: 0;
}
// 图片适配
@mixin image-fit($fit: cover) {
width: 100%;
height: 100%;
object-fit: $fit;
object-position: center;
}

View File

@@ -0,0 +1,476 @@
// 工具类样式文件 - 提供常用的原子化CSS类
@import './variables.scss';
@import './mixins.scss';
// 间距工具类
@each $name, $value in (
'xs': $spacing-xs,
'sm': $spacing-sm,
'md': $spacing-md,
'lg': $spacing-lg,
'xl': $spacing-xl,
'xxl': $spacing-xxl
) {
// 内边距
.p-#{$name} { padding: $value !important; }
.pt-#{$name} { padding-top: $value !important; }
.pr-#{$name} { padding-right: $value !important; }
.pb-#{$name} { padding-bottom: $value !important; }
.pl-#{$name} { padding-left: $value !important; }
.px-#{$name} {
padding-left: $value !important;
padding-right: $value !important;
}
.py-#{$name} {
padding-top: $value !important;
padding-bottom: $value !important;
}
// 外边距
.m-#{$name} { margin: $value !important; }
.mt-#{$name} { margin-top: $value !important; }
.mr-#{$name} { margin-right: $value !important; }
.mb-#{$name} { margin-bottom: $value !important; }
.ml-#{$name} { margin-left: $value !important; }
.mx-#{$name} {
margin-left: $value !important;
margin-right: $value !important;
}
.my-#{$name} {
margin-top: $value !important;
margin-bottom: $value !important;
}
}
// 特殊间距
.p-0 { padding: 0 !important; }
.m-0 { margin: 0 !important; }
.m-auto { margin: auto !important; }
.mx-auto {
margin-left: auto !important;
margin-right: auto !important;
}
.my-auto {
margin-top: auto !important;
margin-bottom: auto !important;
}
// 文本工具类
.text-left { text-align: left !important; }
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-justify { text-align: justify !important; }
// 文本颜色
.text-primary { color: $primary-color !important; }
.text-secondary { color: $text-color-secondary !important; }
.text-success { color: $success-color !important; }
.text-warning { color: $warning-color !important; }
.text-error { color: $error-color !important; }
.text-info { color: $info-color !important; }
.text-disabled { color: $text-color-disabled !important; }
.text-white { color: white !important; }
.text-black { color: black !important; }
// 文本大小
.text-xs { font-size: $font-size-xs !important; }
.text-sm { font-size: $font-size-sm !important; }
.text-md { font-size: $font-size-md !important; }
.text-lg { font-size: $font-size-lg !important; }
.text-xl { font-size: $font-size-xl !important; }
// 文本粗细
.font-thin { font-weight: 100 !important; }
.font-light { font-weight: 300 !important; }
.font-normal { font-weight: 400 !important; }
.font-medium { font-weight: 500 !important; }
.font-semibold { font-weight: 600 !important; }
.font-bold { font-weight: 700 !important; }
.font-extrabold { font-weight: 800 !important; }
.font-black { font-weight: 900 !important; }
// 文本装饰
.underline { text-decoration: underline !important; }
.line-through { text-decoration: line-through !important; }
.no-underline { text-decoration: none !important; }
// 文本省略
.text-ellipsis { @include text-ellipsis(1); }
.text-ellipsis-2 { @include text-ellipsis(2); }
.text-ellipsis-3 { @include text-ellipsis(3); }
// 文本换行
.text-nowrap { white-space: nowrap !important; }
.text-wrap { white-space: normal !important; }
.text-break { word-break: break-all !important; }
// 背景颜色
.bg-primary { background-color: $primary-color !important; }
.bg-success { background-color: $success-color !important; }
.bg-warning { background-color: $warning-color !important; }
.bg-error { background-color: $error-color !important; }
.bg-info { background-color: $info-color !important; }
.bg-white { background-color: white !important; }
.bg-light { background-color: $bg-color-light !important; }
.bg-gray { background-color: $bg-color-gray !important; }
.bg-transparent { background-color: transparent !important; }
// 边框
.border { border: 1px solid $border-color !important; }
.border-t { border-top: 1px solid $border-color !important; }
.border-r { border-right: 1px solid $border-color !important; }
.border-b { border-bottom: 1px solid $border-color !important; }
.border-l { border-left: 1px solid $border-color !important; }
.border-0 { border: none !important; }
// 边框颜色
.border-primary { border-color: $primary-color !important; }
.border-success { border-color: $success-color !important; }
.border-warning { border-color: $warning-color !important; }
.border-error { border-color: $error-color !important; }
.border-info { border-color: $info-color !important; }
.border-light { border-color: $border-color-light !important; }
// 圆角
.rounded-none { border-radius: 0 !important; }
.rounded-sm { border-radius: $border-radius-sm !important; }
.rounded { border-radius: $border-radius-md !important; }
.rounded-lg { border-radius: $border-radius-lg !important; }
.rounded-full { border-radius: 50% !important; }
// 阴影
.shadow-none { box-shadow: none !important; }
.shadow-sm { @include shadow(1); }
.shadow { @include shadow(2); }
.shadow-lg { @include shadow(3); }
.shadow-xl { @include shadow(4); }
// 显示/隐藏
.block { display: block !important; }
.inline { display: inline !important; }
.inline-block { display: inline-block !important; }
.flex { display: flex !important; }
.inline-flex { display: inline-flex !important; }
.grid { display: grid !important; }
.hidden { display: none !important; }
// Flex 布局
.flex-row { flex-direction: row !important; }
.flex-col { flex-direction: column !important; }
.flex-wrap { flex-wrap: wrap !important; }
.flex-nowrap { flex-wrap: nowrap !important; }
// Flex 对齐
.justify-start { justify-content: flex-start !important; }
.justify-end { justify-content: flex-end !important; }
.justify-center { justify-content: center !important; }
.justify-between { justify-content: space-between !important; }
.justify-around { justify-content: space-around !important; }
.justify-evenly { justify-content: space-evenly !important; }
.items-start { align-items: flex-start !important; }
.items-end { align-items: flex-end !important; }
.items-center { align-items: center !important; }
.items-baseline { align-items: baseline !important; }
.items-stretch { align-items: stretch !important; }
// Flex 项目
.flex-1 { flex: 1 1 0% !important; }
.flex-auto { flex: 1 1 auto !important; }
.flex-initial { flex: 0 1 auto !important; }
.flex-none { flex: none !important; }
.flex-shrink-0 { flex-shrink: 0 !important; }
.flex-grow { flex-grow: 1 !important; }
// 定位
.relative { position: relative !important; }
.absolute { position: absolute !important; }
.fixed { position: fixed !important; }
.sticky { position: sticky !important; }
.static { position: static !important; }
// 定位偏移
.top-0 { top: 0 !important; }
.right-0 { right: 0 !important; }
.bottom-0 { bottom: 0 !important; }
.left-0 { left: 0 !important; }
// 宽度
.w-full { width: 100% !important; }
.w-auto { width: auto !important; }
.w-0 { width: 0 !important; }
@for $i from 1 through 12 {
.w-#{$i} { width: percentage($i / 12) !important; }
}
// 高度
.h-full { height: 100% !important; }
.h-auto { height: auto !important; }
.h-0 { height: 0 !important; }
.h-screen { height: 100vh !important; }
// 最大/最小宽高
.max-w-full { max-width: 100% !important; }
.max-h-full { max-height: 100% !important; }
.min-w-0 { min-width: 0 !important; }
.min-h-0 { min-height: 0 !important; }
// 溢出
.overflow-auto { overflow: auto !important; }
.overflow-hidden { overflow: hidden !important; }
.overflow-visible { overflow: visible !important; }
.overflow-scroll { overflow: scroll !important; }
.overflow-x-auto { overflow-x: auto !important; }
.overflow-y-auto { overflow-y: auto !important; }
.overflow-x-hidden { overflow-x: hidden !important; }
.overflow-y-hidden { overflow-y: hidden !important; }
// 透明度
.opacity-0 { opacity: 0 !important; }
.opacity-25 { opacity: 0.25 !important; }
.opacity-50 { opacity: 0.5 !important; }
.opacity-75 { opacity: 0.75 !important; }
.opacity-100 { opacity: 1 !important; }
// 层级
.z-0 { z-index: 0 !important; }
.z-10 { z-index: 10 !important; }
.z-20 { z-index: 20 !important; }
.z-30 { z-index: 30 !important; }
.z-40 { z-index: 40 !important; }
.z-50 { z-index: 50 !important; }
.z-auto { z-index: auto !important; }
// 光标
.cursor-auto { cursor: auto !important; }
.cursor-default { cursor: default !important; }
.cursor-pointer { cursor: pointer !important; }
.cursor-wait { cursor: wait !important; }
.cursor-text { cursor: text !important; }
.cursor-move { cursor: move !important; }
.cursor-not-allowed { cursor: not-allowed !important; }
// 用户选择
.select-none { user-select: none !important; }
.select-text { user-select: text !important; }
.select-all { user-select: all !important; }
.select-auto { user-select: auto !important; }
// 指针事件
.pointer-events-none { pointer-events: none !important; }
.pointer-events-auto { pointer-events: auto !important; }
// 变换
.transform { transform: translateZ(0) !important; }
.scale-0 { transform: scale(0) !important; }
.scale-50 { transform: scale(0.5) !important; }
.scale-75 { transform: scale(0.75) !important; }
.scale-90 { transform: scale(0.9) !important; }
.scale-95 { transform: scale(0.95) !important; }
.scale-100 { transform: scale(1) !important; }
.scale-105 { transform: scale(1.05) !important; }
.scale-110 { transform: scale(1.1) !important; }
.scale-125 { transform: scale(1.25) !important; }
.scale-150 { transform: scale(1.5) !important; }
// 旋转
.rotate-0 { transform: rotate(0deg) !important; }
.rotate-45 { transform: rotate(45deg) !important; }
.rotate-90 { transform: rotate(90deg) !important; }
.rotate-180 { transform: rotate(180deg) !important; }
.rotate-270 { transform: rotate(270deg) !important; }
// 过渡
.transition-none { transition: none !important; }
.transition-all { transition: all 0.3s ease !important; }
.transition-colors { transition: color 0.3s ease, background-color 0.3s ease, border-color 0.3s ease !important; }
.transition-opacity { transition: opacity 0.3s ease !important; }
.transition-shadow { transition: box-shadow 0.3s ease !important; }
.transition-transform { transition: transform 0.3s ease !important; }
// 动画
.animate-spin { animation: spin 1s linear infinite !important; }
.animate-ping { animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite !important; }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite !important; }
.animate-bounce { animation: bounce 1s infinite !important; }
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
// 滤镜
.blur-none { filter: blur(0) !important; }
.blur-sm { filter: blur(4px) !important; }
.blur { filter: blur(8px) !important; }
.blur-lg { filter: blur(16px) !important; }
.blur-xl { filter: blur(24px) !important; }
// 亮度
.brightness-0 { filter: brightness(0) !important; }
.brightness-50 { filter: brightness(0.5) !important; }
.brightness-75 { filter: brightness(0.75) !important; }
.brightness-90 { filter: brightness(0.9) !important; }
.brightness-95 { filter: brightness(0.95) !important; }
.brightness-100 { filter: brightness(1) !important; }
.brightness-105 { filter: brightness(1.05) !important; }
.brightness-110 { filter: brightness(1.1) !important; }
.brightness-125 { filter: brightness(1.25) !important; }
.brightness-150 { filter: brightness(1.5) !important; }
.brightness-200 { filter: brightness(2) !important; }
// 对比度
.contrast-0 { filter: contrast(0) !important; }
.contrast-50 { filter: contrast(0.5) !important; }
.contrast-75 { filter: contrast(0.75) !important; }
.contrast-100 { filter: contrast(1) !important; }
.contrast-125 { filter: contrast(1.25) !important; }
.contrast-150 { filter: contrast(1.5) !important; }
.contrast-200 { filter: contrast(2) !important; }
// 灰度
.grayscale-0 { filter: grayscale(0) !important; }
.grayscale { filter: grayscale(100%) !important; }
// 色相旋转
.hue-rotate-0 { filter: hue-rotate(0deg) !important; }
.hue-rotate-15 { filter: hue-rotate(15deg) !important; }
.hue-rotate-30 { filter: hue-rotate(30deg) !important; }
.hue-rotate-60 { filter: hue-rotate(60deg) !important; }
.hue-rotate-90 { filter: hue-rotate(90deg) !important; }
.hue-rotate-180 { filter: hue-rotate(180deg) !important; }
// 饱和度
.saturate-0 { filter: saturate(0) !important; }
.saturate-50 { filter: saturate(0.5) !important; }
.saturate-100 { filter: saturate(1) !important; }
.saturate-150 { filter: saturate(1.5) !important; }
.saturate-200 { filter: saturate(2) !important; }
// 深褐色
.sepia-0 { filter: sepia(0) !important; }
.sepia { filter: sepia(100%) !important; }
// 反转
.invert-0 { filter: invert(0) !important; }
.invert { filter: invert(100%) !important; }
// 投影
.drop-shadow-sm { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.05)) !important; }
.drop-shadow { filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.1)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.06)) !important; }
.drop-shadow-md { filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.07)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.06)) !important; }
.drop-shadow-lg { filter: drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1)) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.05)) !important; }
.drop-shadow-xl { filter: drop-shadow(0 20px 25px rgba(0, 0, 0, 0.15)) drop-shadow(0 8px 10px rgba(0, 0, 0, 0.04)) !important; }
.drop-shadow-2xl { filter: drop-shadow(0 25px 50px rgba(0, 0, 0, 0.25)) !important; }
.drop-shadow-none { filter: drop-shadow(0 0 #0000) !important; }
// 响应式工具类
@include respond-to('mobile') {
.mobile\:hidden { display: none !important; }
.mobile\:block { display: block !important; }
.mobile\:flex { display: flex !important; }
.mobile\:text-center { text-align: center !important; }
.mobile\:text-left { text-align: left !important; }
.mobile\:text-sm { font-size: $font-size-sm !important; }
.mobile\:p-sm { padding: $spacing-sm !important; }
.mobile\:m-sm { margin: $spacing-sm !important; }
}
@include respond-to('tablet') {
.tablet\:hidden { display: none !important; }
.tablet\:block { display: block !important; }
.tablet\:flex { display: flex !important; }
.tablet\:text-center { text-align: center !important; }
.tablet\:text-left { text-align: left !important; }
}
@include respond-to('desktop') {
.desktop\:hidden { display: none !important; }
.desktop\:block { display: block !important; }
.desktop\:flex { display: flex !important; }
.desktop\:text-center { text-align: center !important; }
.desktop\:text-left { text-align: left !important; }
}
// 打印样式
@media print {
.print\:hidden { display: none !important; }
.print\:block { display: block !important; }
.print\:text-black { color: black !important; }
.print\:bg-white { background-color: white !important; }
}
// 暗色主题
@media (prefers-color-scheme: dark) {
.dark\:text-white { color: white !important; }
.dark\:text-gray-300 { color: #d1d5db !important; }
.dark\:bg-gray-800 { background-color: #1f2937 !important; }
.dark\:bg-gray-900 { background-color: #111827 !important; }
.dark\:border-gray-600 { border-color: #4b5563 !important; }
}
// 高对比度
@media (prefers-contrast: high) {
.high-contrast\:border-black { border-color: black !important; }
.high-contrast\:text-black { color: black !important; }
.high-contrast\:bg-white { background-color: white !important; }
}
// 减少动画
@media (prefers-reduced-motion: reduce) {
.motion-reduce\:animate-none { animation: none !important; }
.motion-reduce\:transition-none { transition: none !important; }
}
// 自定义工具类
.clearfix { @include clearfix; }
.center { @include center; }
.center-x { @include center('horizontal'); }
.center-y { @include center('vertical'); }
.flex-center { @include flex-center; }
.visually-hidden { @include visually-hidden; }
.reset-button { @include reset-button; }
.reset-list { @include reset-list; }
// 滚动条样式
.scrollbar-thin { @include scrollbar(4px); }
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
// 安全区域
.safe-top { @include safe-area('top'); }
.safe-bottom { @include safe-area('bottom'); }
.safe-left { @include safe-area('left'); }
.safe-right { @include safe-area('right'); }
// 1px 边框
.hairline { @include hairline; }
.hairline-top { @include hairline('top'); }
.hairline-bottom { @include hairline('bottom'); }
.hairline-left { @include hairline('left'); }
.hairline-right { @include hairline('right'); }
// 宽高比
.aspect-square { @include aspect-ratio(1); }
.aspect-video { @include aspect-ratio(16/9); }
.aspect-photo { @include aspect-ratio(4/3); }

View File

@@ -0,0 +1,54 @@
// 主题色彩
$primary-color: #2c5aa0;
$primary-light: #4a7bc8;
$primary-dark: #1e3a8a;
$success-color: #52c41a;
$warning-color: #faad14;
$danger-color: #ff4d4f;
$info-color: #1890ff;
// 中性色
$text-color: #333333;
$text-secondary: #666666;
$text-muted: #999999;
$text-light: #cccccc;
$bg-color: #f5f7fa;
$bg-white: #ffffff;
$bg-gray: #f8f9fa;
$border-color: #e8e8e8;
$border-light: #f0f0f0;
// 字体大小
$font-size-xs: 20rpx;
$font-size-sm: 24rpx;
$font-size-base: 28rpx;
$font-size-lg: 32rpx;
$font-size-xl: 36rpx;
$font-size-xxl: 40rpx;
// 间距
$spacing-xs: 8rpx;
$spacing-sm: 12rpx;
$spacing-base: 16rpx;
$spacing-lg: 24rpx;
$spacing-xl: 32rpx;
$spacing-xxl: 48rpx;
// 圆角
$border-radius-sm: 4rpx;
$border-radius-base: 8rpx;
$border-radius-lg: 12rpx;
$border-radius-xl: 16rpx;
// 阴影
$box-shadow-sm: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
$box-shadow-base: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
$box-shadow-lg: 0 4rpx 20rpx rgba(0, 0, 0, 0.15);
// 动画时间
$transition-base: 0.3s;
$transition-fast: 0.2s;
$transition-slow: 0.5s;

View File

@@ -0,0 +1,379 @@
// 银行端小程序认证工具类
import { useUserStore } from '@/store'
/**
* 检查用户是否已登录
* @returns {boolean} 登录状态
*/
export const isLoggedIn = () => {
const token = uni.getStorageSync('token')
const userInfo = uni.getStorageSync('userInfo')
return !!(token && userInfo)
}
/**
* 检查用户是否有特定权限
* @param {string} permission 权限标识
* @returns {boolean} 是否有权限
*/
export const hasPermission = (permission) => {
const userStore = useUserStore()
return userStore.hasPermission(permission)
}
/**
* 检查用户是否有特定角色
* @param {string} role 角色标识
* @returns {boolean} 是否有角色
*/
export const hasRole = (role) => {
const userStore = useUserStore()
return userStore.hasRole(role)
}
/**
* 检查用户是否有任一权限
* @param {Array} permissions 权限数组
* @returns {boolean} 是否有任一权限
*/
export const hasAnyPermission = (permissions) => {
if (!Array.isArray(permissions) || permissions.length === 0) {
return true
}
const userStore = useUserStore()
return permissions.some(permission => userStore.hasPermission(permission))
}
/**
* 检查用户是否有所有权限
* @param {Array} permissions 权限数组
* @returns {boolean} 是否有所有权限
*/
export const hasAllPermissions = (permissions) => {
if (!Array.isArray(permissions) || permissions.length === 0) {
return true
}
const userStore = useUserStore()
return permissions.every(permission => userStore.hasPermission(permission))
}
/**
* 检查用户是否有任一角色
* @param {Array} roles 角色数组
* @returns {boolean} 是否有任一角色
*/
export const hasAnyRole = (roles) => {
if (!Array.isArray(roles) || roles.length === 0) {
return true
}
const userStore = useUserStore()
return roles.some(role => userStore.hasRole(role))
}
/**
* 获取当前用户信息
* @returns {Object|null} 用户信息
*/
export const getCurrentUser = () => {
const userStore = useUserStore()
return userStore.userInfo
}
/**
* 获取当前用户ID
* @returns {string|null} 用户ID
*/
export const getCurrentUserId = () => {
const userStore = useUserStore()
return userStore.userId
}
/**
* 获取当前用户角色
* @returns {Array} 用户角色数组
*/
export const getCurrentUserRoles = () => {
const userStore = useUserStore()
return userStore.roles || []
}
/**
* 获取当前用户权限
* @returns {Array} 用户权限数组
*/
export const getCurrentUserPermissions = () => {
const userStore = useUserStore()
return userStore.permissions || []
}
/**
* 跳转到登录页
*/
export const redirectToLogin = () => {
uni.reLaunch({
url: '/pages/login/login'
})
}
/**
* 权限验证装饰器
* @param {string|Array} requiredPermissions 必需的权限
* @param {Function} callback 回调函数
* @param {Function} onDenied 权限不足时的回调
*/
export const requirePermission = (requiredPermissions, callback, onDenied) => {
// 检查登录状态
if (!isLoggedIn()) {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: () => {
redirectToLogin()
}
})
return
}
// 检查权限
let hasRequiredPermission = false
if (typeof requiredPermissions === 'string') {
hasRequiredPermission = hasPermission(requiredPermissions)
} else if (Array.isArray(requiredPermissions)) {
hasRequiredPermission = hasAnyPermission(requiredPermissions)
} else {
hasRequiredPermission = true
}
if (hasRequiredPermission) {
if (typeof callback === 'function') {
callback()
}
} else {
if (typeof onDenied === 'function') {
onDenied()
} else {
uni.showToast({
title: '权限不足',
icon: 'none',
duration: 2000
})
}
}
}
/**
* 角色验证装饰器
* @param {string|Array} requiredRoles 必需的角色
* @param {Function} callback 回调函数
* @param {Function} onDenied 角色不足时的回调
*/
export const requireRole = (requiredRoles, callback, onDenied) => {
// 检查登录状态
if (!isLoggedIn()) {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: () => {
redirectToLogin()
}
})
return
}
// 检查角色
let hasRequiredRole = false
if (typeof requiredRoles === 'string') {
hasRequiredRole = hasRole(requiredRoles)
} else if (Array.isArray(requiredRoles)) {
hasRequiredRole = hasAnyRole(requiredRoles)
} else {
hasRequiredRole = true
}
if (hasRequiredRole) {
if (typeof callback === 'function') {
callback()
}
} else {
if (typeof onDenied === 'function') {
onDenied()
} else {
uni.showToast({
title: '角色权限不足',
icon: 'none',
duration: 2000
})
}
}
}
/**
* 登录检查装饰器
* @param {Function} callback 回调函数
*/
export const requireLogin = (callback) => {
if (isLoggedIn()) {
if (typeof callback === 'function') {
callback()
}
} else {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: () => {
redirectToLogin()
}
})
}
}
/**
* 页面权限检查中间件
* @param {Object} pageConfig 页面配置
* @returns {boolean} 是否有权限访问
*/
export const checkPagePermission = (pageConfig = {}) => {
const {
requireAuth = true,
permissions = [],
roles = [],
onDenied
} = pageConfig
// 如果不需要认证,直接通过
if (!requireAuth) {
return true
}
// 检查登录状态
if (!isLoggedIn()) {
uni.showModal({
title: '提示',
content: '请先登录',
showCancel: false,
success: () => {
redirectToLogin()
}
})
return false
}
// 检查权限
if (permissions.length > 0 && !hasAnyPermission(permissions)) {
if (typeof onDenied === 'function') {
onDenied('permission')
} else {
uni.showToast({
title: '权限不足',
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.navigateBack()
}, 2000)
}
return false
}
// 检查角色
if (roles.length > 0 && !hasAnyRole(roles)) {
if (typeof onDenied === 'function') {
onDenied('role')
} else {
uni.showToast({
title: '角色权限不足',
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.navigateBack()
}, 2000)
}
return false
}
return true
}
/**
* 权限常量定义
*/
export const PERMISSIONS = {
// 客户管理权限
CUSTOMER_VIEW: 'customer:view',
CUSTOMER_CREATE: 'customer:create',
CUSTOMER_EDIT: 'customer:edit',
CUSTOMER_DELETE: 'customer:delete',
// 资产管理权限
ASSET_VIEW: 'asset:view',
ASSET_CREATE: 'asset:create',
ASSET_EDIT: 'asset:edit',
ASSET_DELETE: 'asset:delete',
ASSET_APPROVE: 'asset:approve',
// 交易管理权限
TRANSACTION_VIEW: 'transaction:view',
TRANSACTION_CREATE: 'transaction:create',
TRANSACTION_APPROVE: 'transaction:approve',
TRANSACTION_REJECT: 'transaction:reject',
// 风险管理权限
RISK_VIEW: 'risk:view',
RISK_ASSESS: 'risk:assess',
RISK_MANAGE: 'risk:manage',
// 报表权限
REPORT_VIEW: 'report:view',
REPORT_EXPORT: 'report:export',
// 系统管理权限
SYSTEM_CONFIG: 'system:config',
USER_MANAGE: 'user:manage',
ROLE_MANAGE: 'role:manage',
PERMISSION_MANAGE: 'permission:manage'
}
/**
* 角色常量定义
*/
export const ROLES = {
SUPER_ADMIN: 'super_admin', // 超级管理员
ADMIN: 'admin', // 管理员
SUPERVISOR: 'supervisor', // 主管
OFFICER: 'officer', // 业务员
AUDITOR: 'auditor', // 审计员
VIEWER: 'viewer' // 查看者
}
/**
* 默认导出
*/
export default {
isLoggedIn,
hasPermission,
hasRole,
hasAnyPermission,
hasAllPermissions,
hasAnyRole,
getCurrentUser,
getCurrentUserId,
getCurrentUserRoles,
getCurrentUserPermissions,
redirectToLogin,
requirePermission,
requireRole,
requireLogin,
checkPagePermission,
PERMISSIONS,
ROLES
}

View File

@@ -0,0 +1,485 @@
// 银行端小程序权限管理工具
import { PERMISSIONS, ROLES } from './auth'
/**
* 权限配置映射
*/
export const PERMISSION_CONFIG = {
// 页面权限配置
pages: {
'/pages/index/index': {
requireAuth: false,
permissions: [],
roles: []
},
'/pages/login/login': {
requireAuth: false,
permissions: [],
roles: []
},
'/pages/dashboard/dashboard': {
requireAuth: true,
permissions: [],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/customers/customers': {
requireAuth: true,
permissions: [PERMISSIONS.CUSTOMER_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/customers/detail': {
requireAuth: true,
permissions: [PERMISSIONS.CUSTOMER_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/assets/assets': {
requireAuth: true,
permissions: [PERMISSIONS.ASSET_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/assets/detail': {
requireAuth: true,
permissions: [PERMISSIONS.ASSET_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/transactions/transactions': {
requireAuth: true,
permissions: [PERMISSIONS.TRANSACTION_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/transactions/detail': {
requireAuth: true,
permissions: [PERMISSIONS.TRANSACTION_VIEW],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'/pages/risk/risk': {
requireAuth: true,
permissions: [PERMISSIONS.RISK_VIEW],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN, ROLES.AUDITOR]
},
'/pages/profile/profile': {
requireAuth: true,
permissions: [],
roles: []
}
},
// 功能权限配置
features: {
// 客户管理功能
'customer-create': {
permissions: [PERMISSIONS.CUSTOMER_CREATE],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'customer-edit': {
permissions: [PERMISSIONS.CUSTOMER_EDIT],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'customer-delete': {
permissions: [PERMISSIONS.CUSTOMER_DELETE],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
// 资产管理功能
'asset-create': {
permissions: [PERMISSIONS.ASSET_CREATE],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'asset-edit': {
permissions: [PERMISSIONS.ASSET_EDIT],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'asset-delete': {
permissions: [PERMISSIONS.ASSET_DELETE],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'asset-approve': {
permissions: [PERMISSIONS.ASSET_APPROVE],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
// 交易管理功能
'transaction-create': {
permissions: [PERMISSIONS.TRANSACTION_CREATE],
roles: [ROLES.OFFICER, ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'transaction-approve': {
permissions: [PERMISSIONS.TRANSACTION_APPROVE],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'transaction-reject': {
permissions: [PERMISSIONS.TRANSACTION_REJECT],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
// 风险管理功能
'risk-assess': {
permissions: [PERMISSIONS.RISK_ASSESS],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN, ROLES.AUDITOR]
},
'risk-manage': {
permissions: [PERMISSIONS.RISK_MANAGE],
roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
// 报表功能
'report-view': {
permissions: [PERMISSIONS.REPORT_VIEW],
roles: [ROLES.SUPERVISOR, ROLES.ADMIN, ROLES.SUPER_ADMIN, ROLES.AUDITOR]
},
'report-export': {
permissions: [PERMISSIONS.REPORT_EXPORT],
roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
// 系统管理功能
'system-config': {
permissions: [PERMISSIONS.SYSTEM_CONFIG],
roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'user-manage': {
permissions: [PERMISSIONS.USER_MANAGE],
roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN]
},
'role-manage': {
permissions: [PERMISSIONS.ROLE_MANAGE],
roles: [ROLES.SUPER_ADMIN]
}
}
}
/**
* 角色权限映射
*/
export const ROLE_PERMISSIONS = {
[ROLES.SUPER_ADMIN]: [
// 拥有所有权限
...Object.values(PERMISSIONS)
],
[ROLES.ADMIN]: [
// 客户管理
PERMISSIONS.CUSTOMER_VIEW,
PERMISSIONS.CUSTOMER_CREATE,
PERMISSIONS.CUSTOMER_EDIT,
PERMISSIONS.CUSTOMER_DELETE,
// 资产管理
PERMISSIONS.ASSET_VIEW,
PERMISSIONS.ASSET_CREATE,
PERMISSIONS.ASSET_EDIT,
PERMISSIONS.ASSET_DELETE,
PERMISSIONS.ASSET_APPROVE,
// 交易管理
PERMISSIONS.TRANSACTION_VIEW,
PERMISSIONS.TRANSACTION_CREATE,
PERMISSIONS.TRANSACTION_APPROVE,
PERMISSIONS.TRANSACTION_REJECT,
// 风险管理
PERMISSIONS.RISK_VIEW,
PERMISSIONS.RISK_ASSESS,
PERMISSIONS.RISK_MANAGE,
// 报表
PERMISSIONS.REPORT_VIEW,
PERMISSIONS.REPORT_EXPORT,
// 系统管理
PERMISSIONS.SYSTEM_CONFIG,
PERMISSIONS.USER_MANAGE
],
[ROLES.SUPERVISOR]: [
// 客户管理
PERMISSIONS.CUSTOMER_VIEW,
PERMISSIONS.CUSTOMER_CREATE,
PERMISSIONS.CUSTOMER_EDIT,
PERMISSIONS.CUSTOMER_DELETE,
// 资产管理
PERMISSIONS.ASSET_VIEW,
PERMISSIONS.ASSET_CREATE,
PERMISSIONS.ASSET_EDIT,
PERMISSIONS.ASSET_DELETE,
PERMISSIONS.ASSET_APPROVE,
// 交易管理
PERMISSIONS.TRANSACTION_VIEW,
PERMISSIONS.TRANSACTION_CREATE,
PERMISSIONS.TRANSACTION_APPROVE,
PERMISSIONS.TRANSACTION_REJECT,
// 风险管理
PERMISSIONS.RISK_VIEW,
PERMISSIONS.RISK_ASSESS,
// 报表
PERMISSIONS.REPORT_VIEW
],
[ROLES.OFFICER]: [
// 客户管理
PERMISSIONS.CUSTOMER_VIEW,
PERMISSIONS.CUSTOMER_CREATE,
PERMISSIONS.CUSTOMER_EDIT,
// 资产管理
PERMISSIONS.ASSET_VIEW,
PERMISSIONS.ASSET_CREATE,
PERMISSIONS.ASSET_EDIT,
// 交易管理
PERMISSIONS.TRANSACTION_VIEW,
PERMISSIONS.TRANSACTION_CREATE,
// 风险管理
PERMISSIONS.RISK_VIEW
],
[ROLES.AUDITOR]: [
// 客户管理
PERMISSIONS.CUSTOMER_VIEW,
// 资产管理
PERMISSIONS.ASSET_VIEW,
// 交易管理
PERMISSIONS.TRANSACTION_VIEW,
// 风险管理
PERMISSIONS.RISK_VIEW,
PERMISSIONS.RISK_ASSESS,
// 报表
PERMISSIONS.REPORT_VIEW
],
[ROLES.VIEWER]: [
// 客户管理
PERMISSIONS.CUSTOMER_VIEW,
// 资产管理
PERMISSIONS.ASSET_VIEW,
// 交易管理
PERMISSIONS.TRANSACTION_VIEW,
// 风险管理
PERMISSIONS.RISK_VIEW
]
}
/**
* 获取页面权限配置
* @param {string} pagePath 页面路径
* @returns {Object} 权限配置
*/
export const getPagePermissionConfig = (pagePath) => {
return PERMISSION_CONFIG.pages[pagePath] || {
requireAuth: true,
permissions: [],
roles: []
}
}
/**
* 获取功能权限配置
* @param {string} featureKey 功能键
* @returns {Object} 权限配置
*/
export const getFeaturePermissionConfig = (featureKey) => {
return PERMISSION_CONFIG.features[featureKey] || {
permissions: [],
roles: []
}
}
/**
* 根据角色获取权限列表
* @param {string} role 角色
* @returns {Array} 权限列表
*/
export const getPermissionsByRole = (role) => {
return ROLE_PERMISSIONS[role] || []
}
/**
* 根据角色列表获取所有权限
* @param {Array} roles 角色列表
* @returns {Array} 权限列表
*/
export const getPermissionsByRoles = (roles) => {
if (!Array.isArray(roles)) {
return []
}
const permissions = new Set()
roles.forEach(role => {
const rolePermissions = getPermissionsByRole(role)
rolePermissions.forEach(permission => {
permissions.add(permission)
})
})
return Array.from(permissions)
}
/**
* 检查角色是否有特定权限
* @param {string} role 角色
* @param {string} permission 权限
* @returns {boolean} 是否有权限
*/
export const roleHasPermission = (role, permission) => {
const permissions = getPermissionsByRole(role)
return permissions.includes(permission)
}
/**
* 权限级别定义
*/
export const PERMISSION_LEVELS = {
READ: 1, // 只读
WRITE: 2, // 读写
DELETE: 3, // 删除
APPROVE: 4, // 审批
ADMIN: 5 // 管理
}
/**
* 权限分组
*/
export const PERMISSION_GROUPS = {
CUSTOMER: {
name: '客户管理',
permissions: [
PERMISSIONS.CUSTOMER_VIEW,
PERMISSIONS.CUSTOMER_CREATE,
PERMISSIONS.CUSTOMER_EDIT,
PERMISSIONS.CUSTOMER_DELETE
]
},
ASSET: {
name: '资产管理',
permissions: [
PERMISSIONS.ASSET_VIEW,
PERMISSIONS.ASSET_CREATE,
PERMISSIONS.ASSET_EDIT,
PERMISSIONS.ASSET_DELETE,
PERMISSIONS.ASSET_APPROVE
]
},
TRANSACTION: {
name: '交易管理',
permissions: [
PERMISSIONS.TRANSACTION_VIEW,
PERMISSIONS.TRANSACTION_CREATE,
PERMISSIONS.TRANSACTION_APPROVE,
PERMISSIONS.TRANSACTION_REJECT
]
},
RISK: {
name: '风险管理',
permissions: [
PERMISSIONS.RISK_VIEW,
PERMISSIONS.RISK_ASSESS,
PERMISSIONS.RISK_MANAGE
]
},
REPORT: {
name: '报表管理',
permissions: [
PERMISSIONS.REPORT_VIEW,
PERMISSIONS.REPORT_EXPORT
]
},
SYSTEM: {
name: '系统管理',
permissions: [
PERMISSIONS.SYSTEM_CONFIG,
PERMISSIONS.USER_MANAGE,
PERMISSIONS.ROLE_MANAGE,
PERMISSIONS.PERMISSION_MANAGE
]
}
}
/**
* 获取权限显示名称
* @param {string} permission 权限标识
* @returns {string} 显示名称
*/
export const getPermissionDisplayName = (permission) => {
const nameMap = {
[PERMISSIONS.CUSTOMER_VIEW]: '查看客户',
[PERMISSIONS.CUSTOMER_CREATE]: '创建客户',
[PERMISSIONS.CUSTOMER_EDIT]: '编辑客户',
[PERMISSIONS.CUSTOMER_DELETE]: '删除客户',
[PERMISSIONS.ASSET_VIEW]: '查看资产',
[PERMISSIONS.ASSET_CREATE]: '创建资产',
[PERMISSIONS.ASSET_EDIT]: '编辑资产',
[PERMISSIONS.ASSET_DELETE]: '删除资产',
[PERMISSIONS.ASSET_APPROVE]: '审批资产',
[PERMISSIONS.TRANSACTION_VIEW]: '查看交易',
[PERMISSIONS.TRANSACTION_CREATE]: '创建交易',
[PERMISSIONS.TRANSACTION_APPROVE]: '审批交易',
[PERMISSIONS.TRANSACTION_REJECT]: '拒绝交易',
[PERMISSIONS.RISK_VIEW]: '查看风险',
[PERMISSIONS.RISK_ASSESS]: '风险评估',
[PERMISSIONS.RISK_MANAGE]: '风险管理',
[PERMISSIONS.REPORT_VIEW]: '查看报表',
[PERMISSIONS.REPORT_EXPORT]: '导出报表',
[PERMISSIONS.SYSTEM_CONFIG]: '系统配置',
[PERMISSIONS.USER_MANAGE]: '用户管理',
[PERMISSIONS.ROLE_MANAGE]: '角色管理',
[PERMISSIONS.PERMISSION_MANAGE]: '权限管理'
}
return nameMap[permission] || permission
}
/**
* 获取角色显示名称
* @param {string} role 角色标识
* @returns {string} 显示名称
*/
export const getRoleDisplayName = (role) => {
const nameMap = {
[ROLES.SUPER_ADMIN]: '超级管理员',
[ROLES.ADMIN]: '管理员',
[ROLES.SUPERVISOR]: '主管',
[ROLES.OFFICER]: '业务员',
[ROLES.AUDITOR]: '审计员',
[ROLES.VIEWER]: '查看者'
}
return nameMap[role] || role
}
export default {
PERMISSION_CONFIG,
ROLE_PERMISSIONS,
PERMISSION_LEVELS,
PERMISSION_GROUPS,
getPagePermissionConfig,
getFeaturePermissionConfig,
getPermissionsByRole,
getPermissionsByRoles,
roleHasPermission,
getPermissionDisplayName,
getRoleDisplayName
}

View File

@@ -0,0 +1,239 @@
// 银行端小程序统一请求封装
// 获取后端服务地址
const getBaseURL = () => {
// 开发环境
if (process.env.NODE_ENV === 'development') {
return 'http://localhost:3002'
}
// 生产环境
return 'https://your-bank-api-domain.com'
}
const BASE_URL = getBaseURL()
/**
* 统一请求方法
* @param {Object} options 请求配置
* @returns {Promise} 请求结果
*/
const request = (options) => {
return new Promise((resolve, reject) => {
// 请求配置
const config = {
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
...options.header
},
timeout: options.timeout || 10000
}
// 添加认证token
const token = uni.getStorageSync('token')
if (token) {
config.header.Authorization = `Bearer ${token}`
}
// 显示加载提示
if (options.loading !== false) {
uni.showLoading({
title: options.loadingText || '加载中...',
mask: true
})
}
// 发起请求
uni.request({
...config,
success: (response) => {
const { data, statusCode } = response
// 隐藏加载提示
if (options.loading !== false) {
uni.hideLoading()
}
// HTTP状态码检查
if (statusCode >= 200 && statusCode < 300) {
// 业务状态码检查
if (data.code === 200 || data.success === true) {
resolve(data)
} else if (data.code === 401) {
// token过期跳转登录
handleTokenExpired()
reject(new Error(data.message || '登录已过期'))
} else {
// 其他业务错误
if (options.showError !== false) {
uni.showToast({
title: data.message || '请求失败',
icon: 'none',
duration: 2000
})
}
reject(new Error(data.message || '请求失败'))
}
} else {
// HTTP错误
const errorMsg = `请求失败 (${statusCode})`
if (options.showError !== false) {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
}
reject(new Error(errorMsg))
}
},
fail: (error) => {
// 隐藏加载提示
if (options.loading !== false) {
uni.hideLoading()
}
console.error('请求失败:', error)
let errorMsg = '网络连接失败'
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
errorMsg = '请求超时'
} else if (error.errMsg.includes('fail')) {
errorMsg = '网络连接失败'
}
}
if (options.showError !== false) {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
}
reject(new Error(errorMsg))
}
})
})
}
/**
* 处理token过期
*/
const handleTokenExpired = () => {
// 清除本地存储
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/login'
})
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
})
}
/**
* GET请求
*/
const get = (url, params = {}, options = {}) => {
const queryString = Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
const fullUrl = queryString ? `${url}?${queryString}` : url
return request({
url: fullUrl,
method: 'GET',
...options
})
}
/**
* POST请求
*/
const post = (url, data = {}, options = {}) => {
return request({
url,
method: 'POST',
data,
...options
})
}
/**
* PUT请求
*/
const put = (url, data = {}, options = {}) => {
return request({
url,
method: 'PUT',
data,
...options
})
}
/**
* DELETE请求
*/
const del = (url, options = {}) => {
return request({
url,
method: 'DELETE',
...options
})
}
/**
* 文件上传
*/
const upload = (url, filePath, options = {}) => {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
uni.uploadFile({
url: BASE_URL + url,
filePath,
name: options.name || 'file',
formData: options.formData || {},
header: {
Authorization: token ? `Bearer ${token}` : '',
...options.header
},
success: (response) => {
try {
const data = JSON.parse(response.data)
if (data.code === 200 || data.success === true) {
resolve(data)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch (error) {
reject(new Error('响应数据解析失败'))
}
},
fail: (error) => {
reject(new Error(error.errMsg || '上传失败'))
}
})
})
}
export {
request,
get,
post,
put,
del,
upload,
BASE_URL
}

View File

@@ -0,0 +1,148 @@
import { config } from '@vue/test-utils'
import { createPinia } from 'pinia'
// 全局测试配置
config.global.plugins = [createPinia()]
// Mock uni-app API
const mockUni = {
// 导航相关
navigateTo: jest.fn(),
navigateBack: jest.fn(),
redirectTo: jest.fn(),
switchTab: jest.fn(),
reLaunch: jest.fn(),
// 界面相关
showToast: jest.fn(),
showModal: jest.fn(),
showLoading: jest.fn(),
hideLoading: jest.fn(),
showActionSheet: jest.fn(),
// 网络请求
request: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn(),
// 数据存储
setStorage: jest.fn(),
getStorage: jest.fn(),
removeStorage: jest.fn(),
clearStorage: jest.fn(),
setStorageSync: jest.fn(),
getStorageSync: jest.fn(),
removeStorageSync: jest.fn(),
clearStorageSync: jest.fn(),
// 设备信息
getSystemInfo: jest.fn(),
getSystemInfoSync: jest.fn(),
// 位置信息
getLocation: jest.fn(),
chooseLocation: jest.fn(),
openLocation: jest.fn(),
// 媒体
chooseImage: jest.fn(),
previewImage: jest.fn(),
chooseVideo: jest.fn(),
// 文件
saveFile: jest.fn(),
getSavedFileList: jest.fn(),
getSavedFileInfo: jest.fn(),
removeSavedFile: jest.fn(),
openDocument: jest.fn(),
// 其他
scanCode: jest.fn(),
setClipboardData: jest.fn(),
getClipboardData: jest.fn(),
makePhoneCall: jest.fn(),
// 页面相关
onLoad: jest.fn(),
onShow: jest.fn(),
onHide: jest.fn(),
onUnload: jest.fn(),
onPullDownRefresh: jest.fn(),
onReachBottom: jest.fn(),
// 分享
onShareAppMessage: jest.fn(),
onShareTimeline: jest.fn(),
// 支付
requestPayment: jest.fn(),
// 登录
login: jest.fn(),
checkSession: jest.fn(),
getUserInfo: jest.fn(),
getUserProfile: jest.fn(),
// 授权
authorize: jest.fn(),
getSetting: jest.fn(),
openSetting: jest.fn()
}
// 设置全局 uni 对象
;(global as any).uni = mockUni
;(global as any).wx = mockUni
;(global as any).getCurrentPages = jest.fn(() => [])
;(global as any).getApp = jest.fn(() => ({}))
// Mock console 方法(可选)
global.console = {
...console,
// 在测试中静默某些日志
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}
// 设置默认的 mock 返回值
mockUni.getSystemInfoSync.mockReturnValue({
platform: 'devtools',
system: 'iOS 14.0',
version: '8.0.5',
SDKVersion: '2.19.4',
brand: 'iPhone',
model: 'iPhone 12',
pixelRatio: 3,
screenWidth: 375,
screenHeight: 812,
windowWidth: 375,
windowHeight: 812,
statusBarHeight: 44,
safeArea: {
left: 0,
right: 375,
top: 44,
bottom: 778,
width: 375,
height: 734
}
})
mockUni.request.mockImplementation(({ success }) => {
if (success) {
success({
statusCode: 200,
data: { code: 0, message: 'success', data: {} }
})
}
})
mockUni.showToast.mockImplementation(() => Promise.resolve())
mockUni.showModal.mockImplementation(() => Promise.resolve({ confirm: true }))
mockUni.showLoading.mockImplementation(() => Promise.resolve())
mockUni.hideLoading.mockImplementation(() => Promise.resolve())
// 导出 mock 对象供测试使用
export { mockUni }

View File

@@ -0,0 +1,241 @@
import { mount, VueWrapper } from '@vue/test-utils'
import { createPinia } from 'pinia'
import type { ComponentMountingOptions } from '@vue/test-utils'
/**
* 创建测试用的 Pinia 实例
*/
export function createTestPinia() {
return createPinia()
}
/**
* 挂载组件的辅助函数
*/
export function mountComponent<T>(
component: T,
options: ComponentMountingOptions<T> = {}
): VueWrapper<any> {
const pinia = createTestPinia()
return mount(component, {
global: {
plugins: [pinia],
...options.global
},
...options
})
}
/**
* 等待 Vue 的下一个 tick
*/
export async function nextTick(): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, 0)
})
}
/**
* 等待指定时间
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 模拟 uni-app 的页面跳转
*/
export function mockNavigation() {
const navigateTo = jest.fn()
const navigateBack = jest.fn()
const redirectTo = jest.fn()
const switchTab = jest.fn()
const reLaunch = jest.fn()
;(global as any).uni = {
...(global as any).uni,
navigateTo,
navigateBack,
redirectTo,
switchTab,
reLaunch
}
return {
navigateTo,
navigateBack,
redirectTo,
switchTab,
reLaunch
}
}
/**
* 模拟 uni-app 的网络请求
*/
export function mockRequest() {
const request = jest.fn()
;(global as any).uni = {
...(global as any).uni,
request
}
return { request }
}
/**
* 模拟 uni-app 的存储
*/
export function mockStorage() {
const storage: Record<string, any> = {}
const setStorageSync = jest.fn((key: string, value: any) => {
storage[key] = value
})
const getStorageSync = jest.fn((key: string) => {
return storage[key]
})
const removeStorageSync = jest.fn((key: string) => {
delete storage[key]
})
const clearStorageSync = jest.fn(() => {
Object.keys(storage).forEach(key => {
delete storage[key]
})
})
;(global as any).uni = {
...(global as any).uni,
setStorageSync,
getStorageSync,
removeStorageSync,
clearStorageSync
}
return {
setStorageSync,
getStorageSync,
removeStorageSync,
clearStorageSync,
storage
}
}
/**
* 模拟 uni-app 的界面反馈
*/
export function mockUI() {
const showToast = jest.fn()
const showModal = jest.fn()
const showLoading = jest.fn()
const hideLoading = jest.fn()
const showActionSheet = jest.fn()
;(global as any).uni = {
...(global as any).uni,
showToast,
showModal,
showLoading,
hideLoading,
showActionSheet
}
return {
showToast,
showModal,
showLoading,
hideLoading,
showActionSheet
}
}
/**
* 创建模拟的响应数据
*/
export function createMockResponse<T>(data: T, code = 0, message = 'success') {
return {
statusCode: 200,
data: {
code,
message,
data
}
}
}
/**
* 创建模拟的错误响应
*/
export function createMockErrorResponse(code = -1, message = 'error') {
return {
statusCode: 500,
data: {
code,
message,
data: null
}
}
}
/**
* 触发组件事件的辅助函数
*/
export async function triggerEvent(
wrapper: VueWrapper<any>,
selector: string,
event: string,
payload?: any
) {
const element = wrapper.find(selector)
await element.trigger(event, payload)
await nextTick()
}
/**
* 等待组件更新完成
*/
export async function waitForUpdate(wrapper: VueWrapper<any>) {
await wrapper.vm.$nextTick()
await nextTick()
}
/**
* 检查元素是否存在
*/
export function expectElementExists(wrapper: VueWrapper<any>, selector: string) {
expect(wrapper.find(selector).exists()).toBe(true)
}
/**
* 检查元素是否不存在
*/
export function expectElementNotExists(wrapper: VueWrapper<any>, selector: string) {
expect(wrapper.find(selector).exists()).toBe(false)
}
/**
* 检查元素文本内容
*/
export function expectElementText(
wrapper: VueWrapper<any>,
selector: string,
text: string
) {
expect(wrapper.find(selector).text()).toBe(text)
}
/**
* 检查元素是否包含指定文本
*/
export function expectElementContainsText(
wrapper: VueWrapper<any>,
selector: string,
text: string
) {
expect(wrapper.find(selector).text()).toContain(text)
}

View File

@@ -0,0 +1,74 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/pages/*": ["src/pages/*"],
"@/utils/*": ["src/utils/*"],
"@/api/*": ["src/api/*"],
"@/stores/*": ["src/stores/*"],
"@/styles/*": ["src/styles/*"],
"@/types/*": ["src/types/*"],
"@/assets/*": ["src/assets/*"]
},
/* Additional options */
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"removeComments": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
/* Type definitions */
"types": [
"@dcloudio/types",
"node",
"jest"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"types/**/*.d.ts",
"vite.config.ts"
],
"exclude": [
"node_modules",
"dist",
"unpackage",
"**/*.js"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": [
"vite.config.ts",
"jest.config.ts",
"scripts/**/*"
]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
server: {
port: 3001,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3002', // 银行后端服务地址
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "@/styles/variables.scss";'
}
}
}
})

View File

@@ -0,0 +1,152 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
// 路径解析
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@/components': resolve(__dirname, 'src/components'),
'@/pages': resolve(__dirname, 'src/pages'),
'@/utils': resolve(__dirname, 'src/utils'),
'@/api': resolve(__dirname, 'src/api'),
'@/stores': resolve(__dirname, 'src/stores'),
'@/styles': resolve(__dirname, 'src/styles'),
'@/types': resolve(__dirname, 'src/types'),
'@/assets': resolve(__dirname, 'src/assets')
}
},
// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "@/styles/variables.scss";
@import "@/styles/mixins.scss";
`
}
}
},
// 构建配置
build: {
// 输出目录
outDir: 'dist',
// 生成源码映射
sourcemap: process.env.NODE_ENV === 'development',
// 构建后是否生成 source map
minify: 'terser',
// terser 配置
terserOptions: {
compress: {
// 生产环境移除 console
drop_console: process.env.NODE_ENV === 'production',
drop_debugger: process.env.NODE_ENV === 'production'
}
},
// 分包策略
rollupOptions: {
output: {
// 分包
manualChunks: {
// 将 node_modules 中的代码单独打包
vendor: ['vue', 'pinia'],
utils: ['axios', 'dayjs', 'lodash-es'],
charts: ['echarts']
}
}
},
// 资源内联阈值
assetsInlineLimit: 4096,
// 启用/禁用 CSS 代码拆分
cssCodeSplit: true,
// 构建时显示文件大小警告的阈值
chunkSizeWarningLimit: 1000
},
// 开发服务器配置
server: {
host: '0.0.0.0',
port: 3000,
open: false,
cors: true,
// 代理配置
proxy: {
'/api': {
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/upload': {
target: process.env.VUE_APP_UPLOAD_URL || 'http://localhost:8080',
changeOrigin: true
}
}
},
// 预览服务器配置
preview: {
host: '0.0.0.0',
port: 4173,
cors: true
},
// 环境变量配置
envPrefix: 'VUE_APP_',
// 依赖优化
optimizeDeps: {
include: [
'vue',
'pinia',
'axios',
'dayjs',
'lodash-es',
'crypto-js'
],
exclude: [
'@dcloudio/uni-app'
]
},
// 定义全局常量
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__UNI_FEATURE_I18N_EN__: false,
__UNI_FEATURE_I18N_ES__: false,
__UNI_FEATURE_I18N_FR__: false,
__UNI_FEATURE_I18N_ZH_HANS__: true,
__UNI_FEATURE_I18N_ZH_HANT__: false
},
// 实验性功能
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === 'js') {
return { js: `/${filename}` }
} else {
return { relative: true }
}
}
},
// 日志级别
logLevel: 'info',
// 清除屏幕
clearScreen: false
})

View File

@@ -0,0 +1,420 @@
# 银行端小程序产品需求文档 (PRD)
## 版本历史
| 版本 | 日期 | 作者 | 变更说明 |
|------|------|------|----------|
| v1.0 | 2025-01-19 | 产品经理 | 初始版本,定义银行端小程序核心功能需求 |
---
## 1. 项目概述
### 1.1 背景
基于宁夏智慧养殖监管平台的银行监管系统,为银行机构开发专用的微信小程序,实现移动端的信贷风险管理、抵押物监控、客户服务等核心业务功能。通过小程序的便携性和实时性,提升银行人员的工作效率和客户服务质量。
### 1.2 业务目标
- **提升工作效率**:银行人员可随时随地处理业务,提高响应速度
- **降低风险成本**:实时监控抵押物状态,及时预警风险事件
- **优化客户体验**:为养殖企业客户提供便捷的金融服务入口
- **数字化转型**:推动银行业务向移动化、智能化方向发展
### 1.3 产品定位
面向银行信贷风险管理人员和养殖企业客户的专业金融服务小程序,集成信贷管理、风险监控、客户服务于一体的移动端解决方案。
---
## 2. 用户角色与用例
### 2.1 核心用户角色
| 角色 | 描述 | 核心需求 |
|------|------|----------|
| 银行信贷经理 | 负责信贷业务审批和管理 | 客户信息查询、信贷审批、风险评估 |
| 银行风控专员 | 负责风险监控和预警处理 | 抵押物监控、风险预警、数据分析 |
| 银行客户经理 | 负责客户关系维护和服务 | 客户沟通、业务推广、服务支持 |
| 养殖企业客户 | 银行信贷服务的使用者 | 贷款申请、还款管理、资产查询 |
### 2.2 用户故事
#### 用户故事1移动端信贷管理
**As a** 银行信贷经理
**I want to** 在小程序中查看和处理信贷申请
**So that** 我可以随时随地处理业务,提高工作效率
#### 用户故事2实时风险监控
**As a** 银行风控专员
**I want to** 实时监控抵押物状态和风险预警
**So that** 我可以及时发现和处理潜在风险
#### 用户故事3便捷客户服务
**As a** 养殖企业客户
**I want to** 通过小程序查询贷款信息和还款计划
**So that** 我可以方便地管理自己的金融业务
---
## 3. 功能需求
### 3.1 核心功能模块
#### 3.1.1 用户认证与权限管理
**功能描述**:基于现有银行后端系统的用户认证和角色权限管理
**主要功能**
- 微信授权登录
- 手机号验证
- 角色权限验证
- 会话管理
- 安全退出
**验收标准**
- 支持微信一键登录
- 集成现有银行用户体系
- 实现基于角色的功能访问控制
- 会话超时自动跳转登录页
#### 3.1.2 客户信息管理
**功能描述**:银行客户信息的查询、管理和维护
**主要功能**
- 客户基本信息查询
- 客户信用评级查看
- 客户资产信息展示
- 客户联系记录管理
- 客户标签分类
**验收标准**
- 支持多条件客户搜索
- 客户信息实时同步
- 敏感信息权限控制
- 客户互动记录完整
#### 3.1.3 信贷业务管理
**功能描述**:信贷申请、审批、放款、还款全流程管理
**主要功能**
- 信贷申请查看和处理
- 信贷审批流程管理
- 放款进度跟踪
- 还款计划管理
- 逾期提醒处理
**验收标准**
- 审批流程状态实时更新
- 支持移动端审批操作
- 还款提醒及时推送
- 逾期处理流程完整
#### 3.1.4 抵押物监控
**功能描述**:养殖资产作为抵押物的实时监控和管理
**主要功能**
- 抵押物基本信息查看
- 抵押物价值评估
- 抵押物状态监控
- 抵押物变更记录
- 抵押物风险预警
**验收标准**
- 抵押物信息实时更新
- 价值评估算法准确
- 异常状态及时预警
- 变更记录完整追溯
#### 3.1.5 风险预警系统
**功能描述**:基于多维度数据的风险识别和预警
**主要功能**
- 风险等级评估
- 预警信息推送
- 风险处理建议
- 风险趋势分析
- 预警历史记录
**验收标准**
- 风险评估模型准确
- 预警推送及时有效
- 处理建议具有指导性
- 趋势分析直观易懂
#### 3.1.6 数据统计分析
**功能描述**:业务数据的统计分析和可视化展示
**主要功能**
- 业务数据概览
- 风险指标统计
- 客户分布分析
- 业绩指标展示
- 趋势图表分析
**验收标准**
- 数据统计准确及时
- 图表展示清晰美观
- 支持多维度分析
- 数据导出功能完善
### 3.2 辅助功能模块
#### 3.2.1 消息通知
- 系统消息推送
- 业务提醒通知
- 风险预警通知
- 客户互动消息
#### 3.2.2 文档管理
- 合同文档查看
- 资料上传下载
- 文档版本管理
- 电子签名支持
#### 3.2.3 设置中心
- 个人信息设置
- 通知偏好设置
- 安全设置管理
- 系统参数配置
---
## 4. 非功能需求
### 4.1 性能要求
- **响应时间**:页面加载时间 < 3秒接口响应时间 < 2秒
- **并发用户**支持1000+并发用户同时使用
- **数据同步**关键数据实时同步延迟 < 5秒
- **离线支持**支持基础功能离线查看
### 4.2 安全要求
- **数据加密**敏感数据传输和存储加密
- **访问控制**基于角色的细粒度权限控制
- **审计日志**完整的操作日志记录
- **合规性**符合银行业数据安全规范
### 4.3 兼容性要求
- **微信版本**支持微信7.0+版本
- **设备兼容**支持iOS 12+、Android 8+
- **屏幕适配**支持主流手机屏幕尺寸
- **网络环境**支持4G/5G/WiFi网络
### 4.4 可扩展性要求
- **模块化设计**支持功能模块独立开发和部署
- **接口标准化**遵循RESTful API设计规范
- **配置化管理**支持业务规则和参数配置化
- **多租户支持**支持多银行机构独立使用
---
## 5. 技术架构
### 5.1 前端技术栈
- **开发框架**Vue.js 3.x + uni-app
- **状态管理**Pinia
- **UI组件**自定义组件 + uni-app内置组件
- **网络请求**基于uni.request()封装
- **构建工具**HBuilderX + Vite
### 5.2 后端集成
- **API接口**完全复用现有bank-backend系统
- **数据模型**UserAccountTransactionRole等
- **认证方式**JWT Token认证
- **数据格式**JSON格式数据交换
### 5.3 部署架构
- **小程序发布**微信小程序平台
- **后端服务**复用现有bank-backend服务
- **数据库**复用现有MySQL数据库
- **文件存储**支持本地存储和云存储
---
## 6. 数据字典
### 6.1 核心数据实体
#### 6.1.1 用户信息 (User)
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| id | Integer | 用户ID | 1001 |
| username | String | 用户名 | zhangsan |
| real_name | String | 真实姓名 | 张三 |
| phone | String | 手机号 | 13800138000 |
| role_id | Integer | 角色ID | 1 |
| status | Enum | 用户状态 | active |
#### 6.1.2 账户信息 (Account)
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| id | Integer | 账户ID | 2001 |
| account_number | String | 账户号码 | 6228480012345678 |
| user_id | Integer | 用户ID | 1001 |
| account_type | Enum | 账户类型 | savings |
| balance | BigInt | 账户余额() | 100000 |
| status | Enum | 账户状态 | active |
#### 6.1.3 交易记录 (Transaction)
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| id | Integer | 交易ID | 3001 |
| transaction_number | String | 交易流水号 | TXN20250119001 |
| account_id | Integer | 账户ID | 2001 |
| transaction_type | Enum | 交易类型 | deposit |
| amount | BigInt | 交易金额() | 50000 |
| status | Enum | 交易状态 | completed |
---
## 7. API规范
### 7.1 设计原则
- **RESTful设计**遵循REST架构风格
- **统一响应格式**标准化的JSON响应结构
- **错误处理**完善的错误码和错误信息
- **版本管理**支持API版本控制
### 7.2 核心接口
#### 7.2.1 用户认证
```
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/profile
```
#### 7.2.2 客户管理
```
GET /api/users
GET /api/users/:id
POST /api/users
PUT /api/users/:id
```
#### 7.2.3 账户管理
```
GET /api/accounts
GET /api/accounts/:id
POST /api/accounts
PUT /api/accounts/:id
```
#### 7.2.4 交易管理
```
GET /api/transactions
GET /api/transactions/:id
POST /api/transactions/transfer
GET /api/transactions/stats
```
### 7.3 响应格式
```json
{
"success": true,
"data": {},
"message": "操作成功",
"timestamp": "2025-01-19T10:30:00Z"
}
```
---
## 8. 部署要求
### 8.1 硬件要求
- **服务器配置**复用现有bank-backend服务器
- **存储空间**小程序包大小 < 2MB
- **网络带宽**支持高并发访问
### 8.2 软件要求
- **Node.js版本**16.20.2与保险小程序保持一致
- **微信开发者工具**最新稳定版
- **后端服务**复用现有bank-backend服务
### 8.3 部署架构
- **开发环境**本地开发 + 测试后端
- **测试环境**微信开发者工具预览
- **生产环境**微信小程序平台发布
---
## 9. 项目计划
### 9.1 开发阶段
#### 第一阶段基础框架搭建1周
- 项目初始化和环境配置
- 基础组件和工具类开发
- 用户认证和权限管理
- 基础页面框架搭建
#### 第二阶段核心功能开发3周
- 客户信息管理模块
- 信贷业务管理模块
- 抵押物监控模块
- 风险预警系统
#### 第三阶段辅助功能开发2周
- 数据统计分析模块
- 消息通知功能
- 文档管理功能
- 设置中心功能
#### 第四阶段测试优化1周
- 功能测试和性能优化
- 兼容性测试
- 安全性测试
- 用户体验优化
### 9.2 里程碑
- **M1**基础框架完成用户认证可用
- **M2**核心业务功能完成基本可用
- **M3**所有功能完成进入测试阶段
- **M4**测试完成准备发布上线
---
## 10. 风险与约束
### 10.1 技术风险
- **微信平台限制**小程序功能和性能限制
- **后端依赖**依赖现有bank-backend系统稳定性
- **数据同步**实时数据同步的技术挑战
- **安全合规**银行业安全规范的严格要求
### 10.2 业务风险
- **用户接受度**银行人员对移动端工具的接受程度
- **业务复杂性**银行业务流程的复杂性和特殊性
- **监管要求**金融监管政策的变化影响
- **竞争压力**同类产品的竞争压力
### 10.3 项目约束
- **开发周期**7周开发周期的时间约束
- **资源限制**开发人员和测试资源的限制
- **预算控制**项目预算和成本控制要求
- **质量标准**银行级应用的高质量标准
---
## 11. 附录
### 11.1 参考文档
- 银行监管系统需求文档
- 现有bank-backend API文档
- 保险小程序开发经验总结
- 微信小程序开发规范
### 11.2 术语表
- **PRD**Product Requirements Document产品需求文档
- **API**Application Programming Interface应用程序接口
- **JWT**JSON Web TokenJSON网络令牌
- **RBAC**Role-Based Access Control基于角色的访问控制
### 11.3 联系方式
- **产品经理**负责需求确认和产品规划
- **技术负责人**负责技术架构和开发指导
- **测试负责人**负责测试计划和质量保证
- **项目经理**负责项目进度和资源协调
---
**文档状态**已完成
**最后更新**2025-01-19
**下次评审**待定