周洋 há 2 semanas atrás
commit
d776b14a12
88 ficheiros alterados com 6662 adições e 0 exclusões
  1. 22 0
      .editorconfig
  2. 1 0
      .env.development
  3. 1 0
      .env.production
  4. 10 0
      .eslintignore
  5. 17 0
      .eslintrc.js
  6. 30 0
      .gitignore
  7. 14 0
      Dockerfile
  8. 36 0
      README.en.md
  9. 37 0
      README.md
  10. 5 0
      babel.config.js
  11. 12 0
      bin/build.bat
  12. 12 0
      bin/package.bat
  13. 12 0
      bin/run-web.bat
  14. 35 0
      build/index.js
  15. 38 0
      nginx.conf
  16. 73 0
      package.json
  17. 13 0
      public/index.html
  18. 19 0
      src/App.vue
  19. 80 0
      src/api/balance.js
  20. 10 0
      src/api/consumeReport.js
  21. 5 0
      src/api/dashboard.js
  22. 9 0
      src/api/fee.js
  23. 18 0
      src/api/login.js
  24. 9 0
      src/api/performance.js
  25. 9 0
      src/api/profit.js
  26. 17 0
      src/api/quota.js
  27. 17 0
      src/api/tenant.js
  28. 29 0
      src/api/withdraw.js
  29. BIN
      src/assets/images/profile.jpg
  30. BIN
      src/assets/logo/ylrz.png
  31. 99 0
      src/assets/styles/btn.scss
  32. 272 0
      src/assets/styles/common.scss
  33. 92 0
      src/assets/styles/element-ui.scss
  34. 28 0
      src/assets/styles/element-variables.scss
  35. 196 0
      src/assets/styles/index.scss
  36. 66 0
      src/assets/styles/mixin.scss
  37. 222 0
      src/assets/styles/sidebar.scss
  38. 48 0
      src/assets/styles/transition.scss
  39. 39 0
      src/assets/styles/variables.scss
  40. 72 0
      src/components/Breadcrumb/index.vue
  41. 75 0
      src/components/Hamburger.vue
  42. 44 0
      src/components/Hamburger/index.vue
  43. 46 0
      src/components/Screenfull/index.vue
  44. 54 0
      src/components/SizeSelect/index.vue
  45. 37 0
      src/layout/AppMain.vue
  46. 106 0
      src/layout/Navbar.vue
  47. 124 0
      src/layout/Sidebar.vue
  48. 58 0
      src/layout/components/AppMain.vue
  49. 150 0
      src/layout/components/Navbar.vue
  50. 25 0
      src/layout/components/Sidebar/FixiOSBug.js
  51. 27 0
      src/layout/components/Sidebar/Item.vue
  52. 43 0
      src/layout/components/Sidebar/Link.vue
  53. 91 0
      src/layout/components/Sidebar/Logo.vue
  54. 100 0
      src/layout/components/Sidebar/SidebarItem.vue
  55. 57 0
      src/layout/components/Sidebar/index.vue
  56. 89 0
      src/layout/components/TagsView/ScrollPane.vue
  57. 327 0
      src/layout/components/TagsView/index.vue
  58. 4 0
      src/layout/components/index.js
  59. 97 0
      src/layout/index.vue
  60. 43 0
      src/layout/mixin/ResizeHandler.js
  61. 64 0
      src/main.js
  62. 41 0
      src/permission.js
  63. 178 0
      src/router/index.js
  64. 49 0
      src/settings.js
  65. 9 0
      src/store/getters.js
  66. 52 0
      src/store/index.js
  67. 56 0
      src/store/modules/app.js
  68. 53 0
      src/store/modules/permission.js
  69. 41 0
      src/store/modules/settings.js
  70. 207 0
      src/store/modules/tagsView.js
  71. 17 0
      src/utils/auth.js
  72. 56 0
      src/utils/request.js
  73. 103 0
      src/utils/validate.js
  74. 55 0
      src/views/404.vue
  75. 255 0
      src/views/balance/flow.vue
  76. 282 0
      src/views/balance/recharge.vue
  77. 236 0
      src/views/dashboard/index.vue
  78. 192 0
      src/views/fee/consumeReport/index.vue
  79. 208 0
      src/views/fee/index.vue
  80. 126 0
      src/views/fee/moduleUsage/index.vue
  81. 155 0
      src/views/login/index.vue
  82. 176 0
      src/views/performance/index.vue
  83. 115 0
      src/views/profit/index.vue
  84. 158 0
      src/views/quota/index.vue
  85. 12 0
      src/views/redirect/index.vue
  86. 174 0
      src/views/tenant/index.vue
  87. 217 0
      src/views/withdraw/index.vue
  88. 54 0
      vue.config.js

+ 22 - 0
.editorconfig

@@ -0,0 +1,22 @@
+# 告诉EditorConfig插件,这是根文件,不用继续往上查找
+root = true
+
+# 匹配全部文件
+[*]
+# 设置字符集
+charset = utf-8
+# 缩进风格,可选space、tab
+indent_style = space
+# 缩进的空格数
+indent_size = 2
+# 结尾换行符,可选lf、cr、crlf
+end_of_line = lf
+# 在文件结尾插入新行
+insert_final_newline = true
+# 删除一行中的前后空格
+trim_trailing_whitespace = true
+
+# 匹配md结尾的文件
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false

+ 1 - 0
.env.development

@@ -0,0 +1 @@
+VUE_APP_BASE_API = '/api'

+ 1 - 0
.env.production

@@ -0,0 +1 @@
+VUE_APP_BASE_API = '/api'

+ 10 - 0
.eslintignore

@@ -0,0 +1,10 @@
+# 忽略build目录下类型为js的文件的语法检查
+build/*.js
+# 忽略src/assets目录下文件的语法检查
+src/assets
+# 忽略public目录下文件的语法检查
+public
+# 忽略当前目录下为js的文件的语法检查
+*.js
+# 忽略当前目录下为vue的文件的语法检查
+*.vue

+ 17 - 0
.eslintrc.js

@@ -0,0 +1,17 @@
+module.exports = {
+  root: true,
+  env: {
+    node: true
+  },
+  extends: [
+    'plugin:vue/essential',
+    'eslint:recommended'
+  ],
+  parserOptions: {
+    parser: 'babel-eslint'
+  },
+  rules: {
+    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
+  }
+}

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.log
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+package-lock.json
+yarn.lock
+
+# AGENTS.md - 项目指导文档,不上传到远程仓库
+AGENTS.md

+ 14 - 0
Dockerfile

@@ -0,0 +1,14 @@
+#基于官方 NGINX 镜像部署(精简镜像)
+FROM swr.cn-east-2.myhuaweicloud.com/library/nginx:alpine
+
+# 复制本地打包好的 dist 目录到 NGINX 静态资源目录
+COPY ./dist /usr/share/nginx/html
+
+# 替换 NGINX 默认配置(用我们自定义的 nginx.conf)
+COPY ./nginx.conf /etc/nginx/nginx.conf
+
+# 暴露容器端口(与 nginx.conf 中 listen 一致)
+#EXPOSE 80
+
+# 启动 NGINX(前台运行,避免容器启动后退出)
+CMD ["nginx", "-g", "daemon off;"]

+ 36 - 0
README.en.md

@@ -0,0 +1,36 @@
+# his_adminui
+
+#### Description
+adminui
+
+#### Software Architecture
+Software architecture description
+
+#### Installation
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Instructions
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Contribution
+
+1.  Fork the repository
+2.  Create Feat_xxx branch
+3.  Commit your code
+4.  Create Pull Request
+
+
+#### Gitee Feature
+
+1.  You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
+2.  Gitee blog [blog.gitee.com](https://blog.gitee.com)
+3.  Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
+4.  The most valuable open source project [GVP](https://gitee.com/gvp)
+5.  The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
+6.  The most popular members  [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

+ 37 - 0
README.md

@@ -0,0 +1,37 @@
+# his_adminui
+
+#### 介绍
+adminui
+
+#### 软件架构
+软件架构说明
+
+
+#### 安装教程
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### 使用说明
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### 参与贡献
+
+1.  Fork 本仓库
+2.  新建 Feat_xxx 分支
+3.  提交代码
+4.  新建 Pull Request
+
+
+#### 特技
+
+1.  使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
+2.  Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
+3.  你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
+4.  [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
+5.  Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
+6.  Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 12 - 0
bin/build.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 打包Web工程,生成dist文件。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm run build:prod
+
+pause

+ 12 - 0
bin/package.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 安装Web工程,生成node_modules文件。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm install --registry=https://registry.npm.taobao.org
+
+pause

+ 12 - 0
bin/run-web.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 使用 Vue CLI 命令运行 Web 工程。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm run dev
+
+pause

+ 35 - 0
build/index.js

@@ -0,0 +1,35 @@
+const { run } = require('runjs')
+const chalk = require('chalk')
+const config = require('../vue.config.js')
+const rawArgv = process.argv.slice(2)
+const args = rawArgv.join(' ')
+
+if (process.env.npm_config_preview || rawArgv.includes('--preview')) {
+  const report = rawArgv.includes('--report')
+
+  run(`vue-cli-service build ${args}`)
+
+  const port = 9526
+  const publicPath = config.publicPath
+
+  var connect = require('connect')
+  var serveStatic = require('serve-static')
+  const app = connect()
+
+  app.use(
+    publicPath,
+    serveStatic('./dist', {
+      index: ['index.html', '/']
+    })
+  )
+
+  app.listen(port, function () {
+    console.log(chalk.green(`> Preview at  http://localhost:${port}${publicPath}`))
+    if (report) {
+      console.log(chalk.green(`> Report at  http://localhost:${port}${publicPath}report.html`))
+    }
+
+  })
+} else {
+  run(`vue-cli-service build ${args}`)
+}

+ 38 - 0
nginx.conf

@@ -0,0 +1,38 @@
+# nginx.conf
+worker_processes  5;
+
+events {
+    worker_connections  1024;
+}
+
+http {
+    include       mime.types;
+    default_type  application/octet-stream;
+    client_max_body_size 200m;
+    sendfile        on;
+    keepalive_timeout  65;
+
+    server {
+        listen       80;  # 容器内端口
+        server_name  localhost;
+
+        # 前端静态资源目录(对应容器内的 /usr/share/nginx/html)
+        root   /usr/share/nginx/html;
+        index  index.html;
+
+        # 关键:处理 VUE Router history 模式(避免刷新 404)
+        location / {
+            try_files $uri $uri/ /index.html;  # 所有路由指向 index.html
+        }
+
+        # 关键:反向代理 API(解决跨域,替换为你的真实后端地址)
+        location /prod-api/ {
+            proxy_pass http://192.168.58.159:7772/;  # 后端 API 地址(末尾加 / 避免路径拼接问题)
+            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;  # 传递 HTTPS 协议
+        }
+
+     }
+}

+ 73 - 0
package.json

@@ -0,0 +1,73 @@
+{
+  "name": "FS-Agent",
+  "version": "1.0.0",
+  "description": "代理端管理系统",
+  "author": "FS",
+  "license": "MIT",
+  "scripts": {
+    "dev": "vue-cli-service serve",
+    "build:prod": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
+    "build:prod-test": "vue-cli-service build --mode prod-test",
+    "preview": "node build/index.js --preview",
+    "lint": "eslint --ext .js,.vue src"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  },
+  "lint-staged": {
+    "src/**/*.{js,vue}": [
+      "eslint --fix",
+      "git add"
+    ]
+  },
+  "keywords": [
+    "vue",
+    "admin",
+    "dashboard",
+    "element-ui",
+    "agent"
+  ],
+  "dependencies": {
+    "axios": "0.21.0",
+    "core-js": "3.8.1",
+    "dayjs": "^1.11.13",
+    "echarts": "^4.9.0",
+    "element-ui": "2.15.5",
+    "js-cookie": "2.2.1",
+    "nprogress": "0.2.0",
+    "screenfull": "5.2.0",
+    "vue": "2.6.12",
+    "vue-meta": "2.4.0",
+    "vue-router": "3.4.9",
+    "vuex": "3.6.0"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "4.4.6",
+    "@vue/cli-plugin-eslint": "4.4.6",
+    "@vue/cli-service": "4.4.6",
+    "babel-eslint": "10.1.0",
+    "chalk": "4.1.0",
+    "connect": "3.6.6",
+    "eslint": "7.15.0",
+    "eslint-plugin-vue": "7.2.0",
+    "lint-staged": "10.5.3",
+    "sass": "^1.32.0",
+    "runjs": "4.4.2",
+    "sass-loader": "8.0.2",
+    "script-ext-html-webpack-plugin": "2.1.5",
+    "svg-sprite-loader": "5.1.1",
+    "vue-template-compiler": "2.6.12",
+    "webpack": "^4.46.0",
+    "webpack-dev-server": "^3.11.3"
+  },
+  "engines": {
+    "node": ">=8.9",
+    "npm": ">= 3.0.0"
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions"
+  ]
+}

+ 13 - 0
public/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <title>代理端管理系统</title>
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+  </head>
+  <body>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 19 - 0
src/App.vue

@@ -0,0 +1,19 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script>
+export default  {
+  name:  'App',
+    metaInfo() {
+        return {
+            title: this.$store.state.settings.dynamicTitle && this.$store.state.settings.title,
+            titleTemplate: title => {
+                return title ? `${title} - ${process.env.VUE_APP_TITLE || '代理端管理系统'}` : process.env.VUE_APP_TITLE || '代理端管理系统'
+            }
+        }
+    }
+}
+</script>

+ 80 - 0
src/api/balance.js

@@ -0,0 +1,80 @@
+import request from '@/utils/request'
+
+// 获取租户余额信息
+export function getBalanceInfo(tenantId) {
+  return request({
+    url: `/proxy/balance/info/${tenantId}`,
+    method: 'get'
+  })
+}
+
+// 充值到总账户
+export function rechargeToTotal(data) {
+  return request({
+    url: '/proxy/balance/recharge',
+    method: 'post',
+    data
+  })
+}
+
+// 购买预存服务
+export function purchaseService(data) {
+  return request({
+    url: '/proxy/balance/purchase',
+    method: 'post',
+    data
+  })
+}
+
+// 服务余额转总余额
+export function transferToTotal(data) {
+  return request({
+    url: '/proxy/balance/transfer',
+    method: 'post',
+    data
+  })
+}
+
+// 获取消费记录列表
+export function getConsumeRecords(params) {
+  return request({
+    url: '/proxy/balance/records',
+    method: 'get',
+    params
+  })
+}
+
+// 获取收费配置
+export function getFeeConfigs() {
+  return request({
+    url: '/proxy/balance/fee/config',
+    method: 'get'
+  })
+}
+
+// 更新收费配置
+export function updateFeeConfig(data) {
+  return request({
+    url: '/proxy/balance/fee/config/update',
+    method: 'post',
+    data
+  })
+}
+
+// 计算账户费
+export function calculateAccountFee(data) {
+  return request({
+    url: '/proxy/balance/account/fee',
+    method: 'post',
+    data
+  })
+}
+
+// 获取余额流水
+export function getBalanceFlow(params) {
+  return request({
+    url: '/proxy/balance/flow',
+    method: 'get',
+    params
+  })
+}

+ 10 - 0
src/api/consumeReport.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 获取归属代理下所有租户的模块消费统计报告
+export function getConsumptionReport(params) {
+  return request({
+    url: '/proxy/module-consumption/report',
+    method: 'get',
+    params
+  })
+}

+ 5 - 0
src/api/dashboard.js

@@ -0,0 +1,5 @@
+import request from '@/utils/request'
+
+export function getDashboardData() {
+  return request({ url: '/proxy/dashboard', method: 'get' })
+}

+ 9 - 0
src/api/fee.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function getFeeConfig() {
+  return request({ url: '/proxy/priceConfig', method: 'get' })
+}
+
+export function saveFeeConfig(data) {
+  return request({ url: '/proxy/servicePrice/batch', method: 'put', data })
+}

+ 18 - 0
src/api/login.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+export function login(data) {
+  return request({
+    url: '/proxy/login',
+    method: 'post',
+    data
+  })
+}
+
+// 获取验证码
+export function getCodeImg() {
+  return request({
+    url: '/captchaImage',
+    method: 'get',
+    timeout: 20000
+  })
+}

+ 9 - 0
src/api/performance.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function getConsumeStatistics(params) {
+  return request({ url: '/proxy/tenant/consume', method: 'get', params })
+}
+
+export function getPerformanceList(params) {
+  return request({ url: '/proxy/performance/list', method: 'get', params })
+}

+ 9 - 0
src/api/profit.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function getProfitStatistics(params) {
+  return request({ url: '/proxy/profit', method: 'get', params })
+}
+
+export function getProfitList(params) {
+  return request({ url: '/proxy/profit/list', method: 'get', params })
+}

+ 17 - 0
src/api/quota.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function getQuotaList(params) {
+  return request({
+    url: '/proxy/quota/list',
+    method: 'get',
+    params
+  })
+}
+
+export function updateQuota(data) {
+  return request({
+    url: '/proxy/quota/update',
+    method: 'post',
+    data
+  })
+}

+ 17 - 0
src/api/tenant.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function getTenantList(params) {
+  return request({ url: '/proxy/tenants', method: 'get', params })
+}
+
+export function addTenant(data) {
+  return request({ url: '/proxy/tenants', method: 'post', data })
+}
+
+export function updateTenant(data) {
+  return request({ url: '/proxy/tenants', method: 'put', data })
+}
+
+export function toggleTenantStatus(tenantId, status) {
+  return request({ url: `/proxy/tenants/${tenantId}/status`, method: 'put', params: { status } })
+}

+ 29 - 0
src/api/withdraw.js

@@ -0,0 +1,29 @@
+import request from '@/utils/request'
+
+export function getWithdrawList(params) {
+  return request({ url: '/proxy/withdraw/list', method: 'get', params })
+}
+
+export function addWithdraw(data) {
+  return request({ url: '/proxy/withdraw', method: 'post', data })
+}
+
+export function auditWithdraw(id, status, failReason) {
+  return request({ url: `/proxy/withdraw/audit/${id}`, method: 'put', params: { status, failReason } })
+}
+
+export function approveWithdraw(id) {
+  return request({ url: `/proxy/withdraw/audit/${id}`, method: 'put', params: { status: 1 } })
+}
+
+export function rejectWithdraw(id, failReason) {
+  return request({ url: `/proxy/withdraw/audit/${id}`, method: 'put', params: { status: 2, failReason } })
+}
+
+export function payWithdraw(id) {
+  return request({ url: `/proxy/withdraw/pay/${id}`, method: 'put' })
+}
+
+export function countPending() {
+  return request({ url: '/proxy/withdraw/countPending', method: 'get' })
+}

BIN
src/assets/images/profile.jpg


BIN
src/assets/logo/ylrz.png


+ 99 - 0
src/assets/styles/btn.scss

@@ -0,0 +1,99 @@
+@import './variables.scss';
+
+@mixin colorBtn($color) {
+  background: $color;
+
+  &:hover {
+    color: $color;
+
+    &:before,
+    &:after {
+      background: $color;
+    }
+  }
+}
+
+.blue-btn {
+  @include colorBtn($blue)
+}
+
+.light-blue-btn {
+  @include colorBtn($light-blue)
+}
+
+.red-btn {
+  @include colorBtn($red)
+}
+
+.pink-btn {
+  @include colorBtn($pink)
+}
+
+.green-btn {
+  @include colorBtn($green)
+}
+
+.tiffany-btn {
+  @include colorBtn($tiffany)
+}
+
+.yellow-btn {
+  @include colorBtn($yellow)
+}
+
+.pan-btn {
+  font-size: 14px;
+  color: #fff;
+  padding: 14px 36px;
+  border-radius: 8px;
+  border: none;
+  outline: none;
+  transition: 600ms ease all;
+  position: relative;
+  display: inline-block;
+
+  &:hover {
+    background: #fff;
+
+    &:before,
+    &:after {
+      width: 100%;
+      transition: 600ms ease all;
+    }
+  }
+
+  &:before,
+  &:after {
+    content: '';
+    position: absolute;
+    top: 0;
+    right: 0;
+    height: 2px;
+    width: 0;
+    transition: 400ms ease all;
+  }
+
+  &::after {
+    right: inherit;
+    top: inherit;
+    left: 0;
+    bottom: 0;
+  }
+}
+
+.custom-button {
+  display: inline-block;
+  line-height: 1;
+  white-space: nowrap;
+  cursor: pointer;
+  background: #fff;
+  color: #fff;
+  -webkit-appearance: none;
+  text-align: center;
+  box-sizing: border-box;
+  outline: 0;
+  margin: 0;
+  padding: 10px 15px;
+  font-size: 14px;
+  border-radius: 4px;
+}

+ 272 - 0
src/assets/styles/common.scss

@@ -0,0 +1,272 @@
+/**
+* 通用css样式布局处理
+*/
+
+/** 基础通用 **/
+.pt5 {
+  padding-top: 5px;
+}
+.pr5 {
+  padding-right: 5px;
+}
+.pb5 {
+  padding-bottom: 5px;
+}
+.mt5 {
+  margin-top: 5px;
+}
+.mr5 {
+  margin-right: 5px;
+}
+.mb5 {
+  margin-bottom: 5px;
+}
+.mb8 {
+  margin-bottom: 8px;
+}
+.ml5 {
+  margin-left: 5px;
+}
+.mt10 {
+  margin-top: 10px;
+}
+.mr10 {
+  margin-right: 10px;
+}
+.mb10 {
+  margin-bottom: 10px;
+}
+.ml0 {
+  margin-left: 10px;
+}
+.mt20 {
+  margin-top: 20px;
+}
+.mr20 {
+  margin-right: 20px;
+}
+.mb20 {
+  margin-bottom: 20px;
+}
+.m20 {
+  margin-left: 20px;
+}
+
+.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
+  font-family: inherit;
+  font-weight: 500;
+  line-height: 1.1;
+  color: inherit;
+}
+
+.el-dialog:not(.is-fullscreen) {
+  margin-top: 6vh !important;
+}
+
+.el-dialog__wrapper.scrollbar .el-dialog .el-dialog__body {
+  overflow: auto;
+  overflow-x: hidden;
+  max-height: 70vh;
+  padding: 10px 20px 0;
+}
+
+.el-table {
+  .el-table__header-wrapper, .el-table__fixed-header-wrapper {
+    th {
+      word-break: break-word;
+      background-color: #f8f8f9;
+      color: #515a6e;
+      height: 40px;
+      font-size: 13px;
+    }
+  }
+  .el-table__body-wrapper {
+    .el-button [class*="el-icon-"] + span {
+      margin-left: 1px;
+    }
+  }
+}
+
+/** 表单布局 **/
+.form-header {
+  font-size:15px;
+  color:#6379bb;
+  border-bottom:1px solid #ddd;
+  margin:8px 10px 25px 10px;
+  padding-bottom:5px
+}
+
+/** 表格布局 **/
+.pagination-container {
+  position: relative;
+  height: 25px;
+  margin-bottom: 10px;
+  margin-top: 15px;
+  padding: 10px 20px !important;
+}
+
+/* tree border */
+.tree-border {
+  margin-top: 5px;
+  border: 1px solid #e5e6e7;
+  background: #FFFFFF none;
+  border-radius:4px;
+}
+
+.pagination-container .el-pagination {
+  right: 0;
+  position: absolute;
+}
+
+@media ( max-width : 768px) {
+  .pagination-container .el-pagination > .el-pagination__jump {
+    display: none !important;
+  }
+  .pagination-container .el-pagination > .el-pagination__sizes {
+    display: none !important;
+  }
+}
+
+.el-table .fixed-width .el-button--mini {
+  padding-left: 0;
+  padding-right: 0;
+  width: inherit;
+}
+
+/** 表格更多操作下拉样式 */
+.el-table .el-dropdown-link {
+  cursor: pointer;
+  color: #409EFF;
+  margin-left: 5px;
+}
+
+.el-table .el-dropdown, .el-icon-arrow-down {
+  font-size: 12px;
+}
+
+.el-tree-node__content > .el-checkbox {
+  margin-right: 8px;
+}
+
+.list-group-striped > .list-group-item {
+  border-left: 0;
+  border-right: 0;
+  border-radius: 0;
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.list-group {
+  padding-left: 0px;
+  list-style: none;
+}
+
+.list-group-item {
+  border-bottom: 1px solid #e7eaec;
+  border-top: 1px solid #e7eaec;
+  margin-bottom: -1px;
+  padding: 11px 0px;
+  font-size: 13px;
+}
+
+.pull-right {
+  float: right !important;
+}
+
+.el-card__header {
+  padding: 14px 15px 7px;
+  min-height: 40px;
+}
+
+.el-card__body {
+  padding: 15px 20px 20px 20px;
+}
+
+.card-box {
+  padding-right: 15px;
+  padding-left: 15px;
+  margin-bottom: 10px;
+}
+
+/* button color */
+.el-button--cyan.is-active,
+.el-button--cyan:active {
+  background: #20B2AA;
+  border-color: #20B2AA;
+  color: #FFFFFF;
+}
+
+.el-button--cyan:focus,
+.el-button--cyan:hover {
+  background: #48D1CC;
+  border-color: #48D1CC;
+  color: #FFFFFF;
+}
+
+.el-button--cyan {
+  background-color: #20B2AA;
+  border-color: #20B2AA;
+  color: #FFFFFF;
+}
+
+/* text color */
+.text-navy {
+  color: #1ab394;
+}
+
+.text-primary {
+  color: inherit;
+}
+
+.text-success {
+  color: #1c84c6;
+}
+
+.text-info {
+  color: #23c6c8;
+}
+
+.text-warning {
+  color: #f8ac59;
+}
+
+.text-danger {
+  color: #ed5565;
+}
+
+.text-muted {
+  color: #888888;
+}
+
+/* image */
+.img-circle {
+  border-radius: 50%;
+}
+
+.img-lg {
+  width: 120px;
+  height: 120px;
+}
+
+.avatar-upload-preview {
+  position: absolute;
+  top: 50%;
+  transform: translate(50%, -50%);
+  width: 200px;
+  height: 200px;
+  border-radius: 50%;
+  box-shadow: 0 0 4px #ccc;
+  overflow: hidden;
+}
+
+/* 拖拽列样式 */
+.sortable-ghost{
+  opacity: .8;
+  color: #fff!important;
+  background: #42b983!important;
+}
+
+.top-right-btn {
+  position: relative;
+  float: right;
+}

+ 92 - 0
src/assets/styles/element-ui.scss

@@ -0,0 +1,92 @@
+// cover some element-ui styles
+
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+  font-weight: 400 !important;
+}
+
+.el-upload {
+  input[type="file"] {
+    display: none !important;
+  }
+}
+
+.el-upload__input {
+  display: none;
+}
+
+.cell {
+  .el-tag {
+    margin-right: 0px;
+  }
+}
+
+.small-padding {
+  .cell {
+    padding-left: 5px;
+    padding-right: 5px;
+  }
+}
+
+.fixed-width {
+  .el-button--mini {
+    padding: 7px 10px;
+    width: 60px;
+  }
+}
+
+.status-col {
+  .cell {
+    padding: 0 10px;
+    text-align: center;
+
+    .el-tag {
+      margin-right: 0px;
+    }
+  }
+}
+
+// to fixed https://github.com/ElemeFE/element/issues/2461
+.el-dialog {
+  transform: none;
+  left: 0;
+  position: relative;
+  margin: 0 auto;
+}
+
+// refine element ui upload
+.upload-container {
+  .el-upload {
+    width: 100%;
+
+    .el-upload-dragger {
+      width: 100%;
+      height: 200px;
+    }
+  }
+}
+
+// dropdown
+.el-dropdown-menu {
+  a {
+    display: block
+  }
+}
+
+// fix date-picker ui bug in filter-item
+.el-range-editor.el-input__inner {
+  display: inline-flex !important;
+}
+
+// to fix el-date-picker css style
+.el-range-separator {
+  box-sizing: content-box;
+}
+
+.el-menu--collapse
+> div
+> .el-submenu
+> .el-submenu__title
+.el-submenu__icon-arrow {
+  display: none;
+}

+ 28 - 0
src/assets/styles/element-variables.scss

@@ -0,0 +1,28 @@
+/**
+* I think element-ui's default theme color is too light for long-term use.
+* So I modified the default color and you can modify it to your liking.
+**/
+
+/* theme color */
+$--color-primary: #1890ff;
+$--color-success: #13ce66;
+$--color-warning: #ffba00;
+$--color-danger: #ff4949;
+
+$--button-font-weight: 400;
+
+$--border-color-light: #dfe4ed;
+$--border-color-lighter: #e6ebf5;
+
+$--table-border:1px solid#dfe6ec;
+
+/* icon font path, required */
+$--font-path: '~element-ui/lib/theme-chalk/fonts';
+
+@import "~element-ui/packages/theme-chalk/src/index";
+
+// the :export directive is the magic sauce for webpack
+// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+:export {
+  theme: $--color-primary;
+}

+ 196 - 0
src/assets/styles/index.scss

@@ -0,0 +1,196 @@
+@import './variables.scss';
+@import './mixin.scss';
+@import './transition.scss';
+@import './element-ui.scss';
+@import './sidebar.scss';
+@import './btn.scss';
+
+body {
+  height: 100%;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+  font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
+}
+
+label {
+  font-weight: 700;
+}
+
+html {
+  height: 100%;
+  box-sizing: border-box;
+}
+
+#app {
+  height: 100%;
+}
+
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+
+.no-padding {
+  padding: 0px !important;
+}
+
+.padding-content {
+  padding: 4px 0;
+}
+
+a:focus,
+a:active {
+  outline: none;
+}
+
+a,
+a:focus,
+a:hover {
+  cursor: pointer;
+  color: inherit;
+  text-decoration: none;
+}
+
+div:focus {
+  outline: none;
+}
+
+.fr {
+  float: right;
+}
+
+.fl {
+  float: left;
+}
+
+.pr-5 {
+  padding-right: 5px;
+}
+
+.pl-5 {
+  padding-left: 5px;
+}
+
+.block {
+  display: block;
+}
+
+.pointer {
+  cursor: pointer;
+}
+
+.inlineBlock {
+  display: block;
+}
+
+.clearfix {
+  &:after {
+    visibility: hidden;
+    display: block;
+    font-size: 0;
+    content: " ";
+    clear: both;
+    height: 0;
+  }
+}
+
+aside {
+  background: #eef1f6;
+  padding: 8px 24px;
+  margin-bottom: 20px;
+  border-radius: 2px;
+  display: block;
+  line-height: 32px;
+  font-size: 16px;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
+  color: #2c3e50;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+
+  a {
+    color: #337ab7;
+    cursor: pointer;
+
+    &:hover {
+      color: rgb(32, 160, 255);
+    }
+  }
+}
+
+//main-container全局样式
+.app-container {
+  background-color: #fff;
+  margin: 10px;
+  padding: 20px;
+}
+
+.components-container {
+  margin: 30px 50px;
+  position: relative;
+}
+
+.pagination-container {
+  margin-top: 30px;
+}
+
+.text-center {
+  text-align: center
+}
+
+.sub-navbar {
+  height: 50px;
+  line-height: 50px;
+  position: relative;
+  width: 100%;
+  text-align: right;
+  padding-right: 20px;
+  transition: 600ms ease position;
+  background: linear-gradient(90deg, rgba(32, 182, 249, 1) 0%, rgba(32, 182, 249, 1) 0%, rgba(33, 120, 241, 1) 100%, rgba(33, 120, 241, 1) 100%);
+
+  .subtitle {
+    font-size: 20px;
+    color: #fff;
+  }
+
+  &.draft {
+    background: #d0d0d0;
+  }
+
+  &.deleted {
+    background: #d0d0d0;
+  }
+}
+
+.link-type,
+.link-type:focus {
+  color: #337ab7;
+  cursor: pointer;
+
+  &:hover {
+    color: rgb(32, 160, 255);
+  }
+}
+
+.filter-container {
+  padding-bottom: 10px;
+
+  .filter-item {
+    display: inline-block;
+    vertical-align: middle;
+    margin-bottom: 10px;
+  }
+}
+
+//refine vue-multiselect plugin
+.multiselect {
+  line-height: 16px;
+}
+
+.multiselect--active {
+  z-index: 1000 !important;
+}
+.el-dialog__footer{
+  border-top: 1px solid #DCDFE6;
+}

+ 66 - 0
src/assets/styles/mixin.scss

@@ -0,0 +1,66 @@
+@mixin clearfix {
+  &:after {
+    content: "";
+    display: table;
+    clear: both;
+  }
+}
+
+@mixin scrollBar {
+  &::-webkit-scrollbar-track-piece {
+    background: #d3dce6;
+  }
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #99a9bf;
+    border-radius: 20px;
+  }
+}
+
+@mixin relative {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+
+@mixin pct($pct) {
+  width: #{$pct};
+  position: relative;
+  margin: 0 auto;
+}
+
+@mixin triangle($width, $height, $color, $direction) {
+  $width: $width/2;
+  $color-border-style: $height solid $color;
+  $transparent-border-style: $width solid transparent;
+  height: 0;
+  width: 0;
+
+  @if $direction==up {
+    border-bottom: $color-border-style;
+    border-left: $transparent-border-style;
+    border-right: $transparent-border-style;
+  }
+
+  @else if $direction==right {
+    border-left: $color-border-style;
+    border-top: $transparent-border-style;
+    border-bottom: $transparent-border-style;
+  }
+
+  @else if $direction==down {
+    border-top: $color-border-style;
+    border-left: $transparent-border-style;
+    border-right: $transparent-border-style;
+  }
+
+  @else if $direction==left {
+    border-right: $color-border-style;
+    border-top: $transparent-border-style;
+    border-bottom: $transparent-border-style;
+  }
+}

+ 222 - 0
src/assets/styles/sidebar.scss

@@ -0,0 +1,222 @@
+#app {
+
+  .main-container {
+    min-height: 100%;
+    transition: margin-left .28s;
+    margin-left: $base-sidebar-width;
+    position: relative;
+  }
+
+  .sidebar-container {
+    -webkit-transition: width .28s;
+    transition: width 0.28s;
+    width: $base-sidebar-width !important;
+    background-color: $base-menu-background;
+    height: 100%;
+    position: fixed;
+    font-size: 0px;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 1001;
+    overflow: hidden;
+    -webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35);
+    box-shadow: 2px 0 6px rgba(0,21,41,.35);
+
+    // reset element-ui css
+    .horizontal-collapse-transition {
+      transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
+    }
+
+    .scrollbar-wrapper {
+      overflow-x: hidden !important;
+    }
+
+    .el-scrollbar__bar.is-vertical {
+      right: 0px;
+    }
+
+    .el-scrollbar {
+      height: 100%;
+    }
+
+    &.has-logo {
+      .el-scrollbar {
+        height: calc(100% - 50px);
+      }
+    }
+
+    .is-horizontal {
+      display: none;
+    }
+
+    a {
+      display: inline-block;
+      width: 100%;
+      overflow: hidden;
+    }
+
+    .svg-icon {
+      margin-right: 16px;
+    }
+
+    .el-menu {
+      border: none;
+      height: 100%;
+      width: 100% !important;
+    }
+
+    .el-menu-item, .el-submenu__title {
+      overflow: hidden !important;
+      text-overflow: ellipsis !important;
+      white-space: nowrap !important;
+    }
+
+    // menu hover
+    .submenu-title-noDropdown,
+    .el-submenu__title {
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.06) !important;
+      }
+    }
+
+    & .theme-dark .is-active > .el-submenu__title {
+      color: $base-menu-color-active !important;
+    }
+
+    & .nest-menu .el-submenu>.el-submenu__title,
+    & .el-submenu .el-menu-item {
+      min-width: $base-sidebar-width !important;
+
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.06) !important;
+      }
+    }
+
+    & .theme-dark .nest-menu .el-submenu>.el-submenu__title,
+    & .theme-dark .el-submenu .el-menu-item {
+      background-color: $base-sub-menu-background !important;
+
+      &:hover {
+        background-color: $base-sub-menu-hover !important;
+      }
+    }
+  }
+
+  .hideSidebar {
+    .sidebar-container {
+      width: 54px !important;
+    }
+
+    .main-container {
+      margin-left: 54px;
+    }
+
+    .submenu-title-noDropdown {
+      padding: 0 !important;
+      position: relative;
+
+      .el-tooltip {
+        padding: 0 !important;
+
+        .svg-icon {
+          margin-left: 20px;
+        }
+      }
+    }
+
+    .el-submenu {
+      overflow: hidden;
+
+      &>.el-submenu__title {
+        padding: 0 !important;
+
+        .svg-icon {
+          margin-left: 20px;
+        }
+
+      }
+    }
+
+    .el-menu--collapse {
+      .el-submenu {
+        &>.el-submenu__title {
+          &>span {
+            height: 0;
+            width: 0;
+            overflow: hidden;
+            visibility: hidden;
+            display: inline-block;
+          }
+        }
+      }
+    }
+  }
+
+  .el-menu--collapse .el-menu .el-submenu {
+    min-width: $base-sidebar-width !important;
+  }
+
+  // mobile responsive
+  .mobile {
+    .main-container {
+      margin-left: 0px;
+    }
+
+    .sidebar-container {
+      transition: transform .28s;
+      width: $base-sidebar-width !important;
+    }
+
+    &.hideSidebar {
+      .sidebar-container {
+        pointer-events: none;
+        transition-duration: 0.3s;
+        transform: translate3d(-$base-sidebar-width, 0, 0);
+      }
+    }
+  }
+
+  .withoutAnimation {
+
+    .main-container,
+    .sidebar-container {
+      transition: none;
+    }
+  }
+}
+
+// when menu collapsed
+.el-menu--vertical {
+  &>.el-menu {
+    .svg-icon {
+      margin-right: 16px;
+    }
+  }
+
+  .nest-menu .el-submenu>.el-submenu__title,
+  .el-menu-item {
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.06) !important;
+    }
+  }
+
+  // the scroll bar appears when the subMenu is too long
+  >.el-menu--popup {
+    max-height: 100vh;
+    overflow-y: auto;
+
+    &::-webkit-scrollbar-track-piece {
+      background: #d3dce6;
+    }
+
+    &::-webkit-scrollbar {
+      width: 6px;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: #99a9bf;
+      border-radius: 20px;
+    }
+  }
+}

+ 48 - 0
src/assets/styles/transition.scss

@@ -0,0 +1,48 @@
+// global transition css
+
+/* fade */
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.28s;
+}
+
+.fade-enter,
+.fade-leave-active {
+  opacity: 0;
+}
+
+/* fade-transform */
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+  transition: all .5s;
+}
+
+.fade-transform-enter {
+  opacity: 0;
+  transform: translateX(-30px);
+}
+
+.fade-transform-leave-to {
+  opacity: 0;
+  transform: translateX(30px);
+}
+
+/* breadcrumb transition */
+.breadcrumb-enter-active,
+.breadcrumb-leave-active {
+  transition: all .5s;
+}
+
+.breadcrumb-enter,
+.breadcrumb-leave-active {
+  opacity: 0;
+  transform: translateX(20px);
+}
+
+.breadcrumb-move {
+  transition: all .5s;
+}
+
+.breadcrumb-leave-active {
+  position: absolute;
+}

+ 39 - 0
src/assets/styles/variables.scss

@@ -0,0 +1,39 @@
+// base color
+$blue:#324157;
+$light-blue: #3a71a8;
+$red:#C03639;
+$pink: #E65D6E;
+$green: #30B08F;
+$tiffany: #4AB7BD;
+$yellow:#FEC171;
+$panGreen: #30B08F;
+
+// 默认菜单主题风格
+$base-menu-color:#bfcbd9;
+$base-menu-color-active:#f4f4f5;
+$base-menu-background: #304156;
+$base-logo-title-color: #ffffff;
+
+$base-menu-light-color:rgba(0,0,0,.70);
+$base-menu-light-background:#ffffff;
+$base-logo-light-title-color: #001529;
+
+$base-sub-menu-background:#1f2d3d;
+$base-sub-menu-hover:#001528;
+
+$base-sidebar-width: 200px;
+
+// the :export directive is the magic sauce for webpack
+// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+:export {
+  menuColor: $base-menu-color;
+  menuLightColor: $base-menu-light-color;
+  menuColorActive: $base-menu-color-active;
+  menuBackground: $base-menu-background;
+  menuLightBackground: $base-menu-light-background;
+  subMenuBackground: $base-sub-menu-background;
+  subMenuHover: $base-sub-menu-hover;
+  sideBarWidth: $base-sidebar-width;
+  logoTitleColor: $base-logo-title-color;
+  logoLightTitleColor: $base-logo-light-title-color
+}

+ 72 - 0
src/components/Breadcrumb/index.vue

@@ -0,0 +1,72 @@
+<template>
+  <el-breadcrumb class="app-breadcrumb" separator="/">
+    <transition-group name="breadcrumb">
+      <el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
+        <span v-if="item.redirect==='noRedirect'||index==levelList.length-1" class="no-redirect">{{ item.meta.title }}</span>
+        <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
+      </el-breadcrumb-item>
+    </transition-group>
+  </el-breadcrumb>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      levelList: null
+    }
+  },
+  watch: {
+    $route(route) {
+      if (route.path.startsWith('/redirect/')) {
+        return
+      }
+      this.getBreadcrumb()
+    }
+  },
+  created() {
+    this.getBreadcrumb()
+  },
+  methods: {
+    getBreadcrumb() {
+      let matched = this.$route.matched.filter(item => item.meta && item.meta.title)
+      const first = matched[0]
+
+      if (!this.isDashboard(first)) {
+        matched = [{ path: '/dashboard', meta: { title: '首页' }}].concat(matched)
+      }
+
+      this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
+    },
+    isDashboard(route) {
+      const name = route && route.name
+      if (!name) {
+        return false
+      }
+      return name.trim() === 'Dashboard'
+    },
+    handleLink(item) {
+      const { redirect, path } = item
+      if (redirect) {
+        this.$router.push(redirect)
+        return
+      }
+      this.$router.push(path)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  font-size: 14px;
+  line-height: 50px;
+  margin-left: 8px;
+
+  .no-redirect {
+    color: #97a8be;
+    cursor: text;
+  }
+}
+</style>

+ 75 - 0
src/components/Hamburger.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="hamburger" :class="{ isActive: active }" @click="$emit('toggle')">
+    <span class="hamburger-inner" />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Hamburger',
+  props: {
+    active: {
+      type: Boolean,
+      default: false
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.hamburger {
+  display: inline-block;
+  position: relative;
+  width: 20px;
+  height: 20px;
+  margin-top: 20px;
+
+  &:hover {
+    opacity: 1;
+  }
+
+  &.isActive .hamburger-inner {
+    background: transparent;
+
+    &::before {
+      transform: rotate(45deg) translate(-2px, -2px);
+    }
+
+    &::after {
+      transform: rotate(-45deg) translate(3px, -3px);
+    }
+  }
+}
+
+.hamburger-inner {
+  display: block;
+  width: 100%;
+  height: 2px;
+  background: #999;
+  position: absolute;
+  left: 0;
+  top: 50%;
+  margin-top: -1px;
+  transition: all 0.3s;
+
+  &::before,
+  &::after {
+    content: '';
+    display: block;
+    width: 100%;
+    height: 2px;
+    background: #999;
+    position: absolute;
+    left: 0;
+    transition: all 0.3s;
+  }
+
+  &::before {
+    top: -6px;
+  }
+
+  &::after {
+    bottom: -6px;
+  }
+}
+</style>

+ 44 - 0
src/components/Hamburger/index.vue

@@ -0,0 +1,44 @@
+<template>
+  <div style="padding: 0 15px;" @click="toggleClick">
+    <svg
+      :class="{'is-active':isActive}"
+      class="hamburger"
+      viewBox="0 0 1024 1024"
+      xmlns="http://www.w3.org/2000/svg"
+      width="64"
+      height="64"
+    >
+      <path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
+    </svg>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Hamburger',
+  props: {
+    isActive: {
+      type: Boolean,
+      default: false
+    }
+  },
+  methods: {
+    toggleClick() {
+      this.$emit('toggleClick')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hamburger {
+  display: inline-block;
+  vertical-align: middle;
+  width: 20px;
+  height: 20px;
+}
+
+.hamburger.is-active {
+  transform: rotate(180deg);
+}
+</style>

+ 46 - 0
src/components/Screenfull/index.vue

@@ -0,0 +1,46 @@
+<template>
+  <div>
+    <i :class="isFullscreen?'el-icon-aim':'el-icon-full-screen'" style="cursor:pointer;font-size:18px;color:#5a5e66;" @click="click" />
+  </div>
+</template>
+
+<script>
+import screenfull from 'screenfull'
+
+export default {
+  name: 'Screenfull',
+  data() {
+    return {
+      isFullscreen: false
+    }
+  },
+  mounted() {
+    this.init()
+  },
+  beforeDestroy() {
+    this.destroy()
+  },
+  methods: {
+    click() {
+      if (!screenfull.isEnabled) {
+        this.$message({ message: '你的浏览器不支持全屏', type: 'warning' })
+        return false
+      }
+      screenfull.toggle()
+    },
+    change() {
+      this.isFullscreen = screenfull.isFullscreen
+    },
+    init() {
+      if (screenfull.isEnabled) {
+        screenfull.on('change', this.change)
+      }
+    },
+    destroy() {
+      if (screenfull.isEnabled) {
+        screenfull.off('change', this.change)
+      }
+    }
+  }
+}
+</script>

+ 54 - 0
src/components/SizeSelect/index.vue

@@ -0,0 +1,54 @@
+<template>
+  <el-dropdown trigger="click" @command="handleSetSize">
+    <div>
+      <i class="el-icon-setting" style="font-size:18px;color:#5a5e66;cursor:pointer;" />
+    </div>
+    <el-dropdown-menu slot="dropdown">
+      <el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size===item.value" :command="item.value">
+        {{ item.label }}
+      </el-dropdown-item>
+    </el-dropdown-menu>
+  </el-dropdown>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      sizeOptions: [
+        { label: 'Default', value: 'default' },
+        { label: 'Medium', value: 'medium' },
+        { label: 'Small', value: 'small' },
+        { label: 'Mini', value: 'mini' }
+      ]
+    }
+  },
+  computed: {
+    size() {
+      return this.$store.getters.size
+    }
+  },
+  methods: {
+    handleSetSize(size) {
+      this.$ELEMENT.size = size
+      this.$store.dispatch('app/setSize', size)
+      this.refreshView()
+      this.$message({
+        message: '布局大小切换成功',
+        type: 'success'
+      })
+    },
+    refreshView() {
+      this.$store.dispatch('tagsView/delAllCachedViews', this.$route)
+
+      const { fullPath } = this.$route
+
+      this.$nextTick(() => {
+        this.$router.replace({
+          path: '/redirect' + fullPath
+        })
+      })
+    }
+  }
+}
+</script>

+ 37 - 0
src/layout/AppMain.vue

@@ -0,0 +1,37 @@
+<template>
+  <main class="app-main">
+    <transition name="fade" mode="out-in">
+      <router-view :key="key" />
+    </transition>
+  </main>
+</template>
+
+<script>
+export default {
+  name: 'AppMain',
+  computed: {
+    key() {
+      return this.$route.fullPath
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-main {
+  min-height: calc(100vh - 60px);
+  padding: 20px;
+  margin-top: 60px;
+  background: #f5f5f5;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.fade-enter,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 106 - 0
src/layout/Navbar.vue

@@ -0,0 +1,106 @@
+<template>
+  <header class="navbar">
+    <div class="navbar-header">
+      <component :is="hamburger" class="hamburger-container" @click="toggleSideBar" />
+    </div>
+    <div class="navbar-right">
+      <el-dropdown class="avatar-container" trigger="click">
+        <div class="avatar-wrapper">
+          <span class="user-name">{{ proxyName }}</span>
+        </div>
+        <el-dropdown-menu slot="dropdown" class="user-dropdown">
+          <el-dropdown-item @click.native="logout">
+            <i class="el-icon-switch-button" /> 退出登录
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </header>
+</template>
+
+<script>
+import { mapGetters, mapActions } from 'vuex'
+import Hamburger from '@/components/Hamburger'
+
+export default {
+  name: 'Navbar',
+  components: {
+    Hamburger
+  },
+  computed: {
+    hamburger() {
+      return Hamburger
+    },
+    proxyName() {
+      return this.$store.state.proxyInfo.proxyName || '代理用户'
+    }
+  },
+  methods: {
+    ...mapActions([
+      'toggleSideBar'
+    ]),
+    logout() {
+      this.$store.dispatch('setToken', '')
+      this.$store.dispatch('setProxyInfo', {})
+      this.$router.push('/login')
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.navbar {
+  position: fixed;
+  right: 0;
+  left: 210px;
+  z-index: 99;
+  height: 60px;
+  background: #fff;
+  border-bottom: 1px solid #e6e6e6;
+  transition: left 0.28s;
+
+  &.hideSidebar {
+    left: 54px;
+  }
+}
+
+.navbar-header {
+  float: left;
+}
+
+.hamburger-container {
+  height: 60px;
+  line-height: 60px;
+  padding: 0 10px;
+  cursor: pointer;
+}
+
+.navbar-right {
+  float: right;
+  height: 100%;
+  line-height: 60px;
+}
+
+.avatar-container {
+  margin-right: 20px;
+
+  .avatar-wrapper {
+    display: flex;
+    align-items: center;
+    padding: 0 10px;
+    cursor: pointer;
+
+    .user-name {
+      color: #666;
+      font-size: 14px;
+      margin-right: 8px;
+    }
+  }
+}
+
+.user-dropdown {
+  .el-dropdown-item {
+    width: 100px;
+  }
+}
+</style>

+ 124 - 0
src/layout/Sidebar.vue

@@ -0,0 +1,124 @@
+<template>
+  <div :class="{'has-logo':showLogo}" class="sidebar-wrapper">
+    <div class="logo">
+      <div class="logo-title">代理端管理系统</div>
+    </div>
+    <el-scrollbar wrap-class="scrollbar-wrapper">
+      <el-menu
+        :default-active="activeMenu"
+        :router="true"
+        mode="vertical"
+        :show-timeout="200"
+        class="sidebar-el-menu"
+      >
+        <template v-for="item in permission_routes">
+          <el-submenu v-if="item.children&&item.children.length>0" :index="item.path" :key="item.path">
+            <template slot="title">
+              <i :class="item.meta&&item.meta.icon||'el-icon-menu'" />
+              <span>{{item.meta&&item.meta.title||''}}</span>
+            </template>
+            <template v-for="child in item.children">
+              <el-menu-item :index="resolvePath(item.path, child.path)" :key="child.path">
+                <span>{{child.meta&&child.meta.title||''}}</span>
+              </el-menu-item>
+            </template>
+          </el-submenu>
+          <el-menu-item v-else :index="item.path" :key="item.path">
+            <i :class="item.meta&&item.meta.icon||'el-icon-menu'" />
+            <span>{{item.meta&&item.meta.title||''}}</span>
+          </el-menu-item>
+        </template>
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import { constantRoutes } from '@/router'
+
+export default {
+  name: 'Sidebar',
+  computed: {
+    ...mapGetters([
+      'sidebar'
+    ]),
+    permission_routes() {
+      return constantRoutes.filter(item => !item.hidden)
+    },
+    activeMenu() {
+      const route = this.$route
+      const { meta, path } = route
+      if (meta.activeMenu) {
+        return meta.activeMenu
+      }
+      return path
+    },
+    showLogo() {
+      return this.sidebar.opened
+    }
+  },
+  methods: {
+    resolvePath(parentPath, childPath) {
+      if (childPath.startsWith('/')) {
+        return childPath
+      }
+      return parentPath + '/' + childPath
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.sidebar-wrapper {
+  position: fixed;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 210px;
+  background: #001529;
+  z-index: 100;
+  transition: width 0.28s;
+
+  &.collapsed {
+    width: 54px;
+  }
+}
+
+.logo {
+  height: 60px;
+  line-height: 60px;
+  text-align: center;
+  background: #002140;
+
+  .logo-title {
+    color: #fff;
+    font-size: 16px;
+    font-weight: bold;
+  }
+}
+
+.sidebar-el-menu {
+  border-right: none;
+  height: calc(100% - 60px);
+
+  .el-menu-item,
+  .el-submenu__title {
+    color: rgba(255, 255, 255, 0.7);
+    height: 46px;
+    line-height: 46px;
+    margin: 0 4px;
+    border-radius: 4px;
+
+    &:hover {
+      background: rgba(255, 255, 255, 0.1);
+      color: #fff;
+    }
+
+    &.is-active {
+      background: #1890ff;
+      color: #fff;
+    }
+  }
+}
+</style>

+ 58 - 0
src/layout/components/AppMain.vue

@@ -0,0 +1,58 @@
+<template>
+  <section class="app-main">
+    <transition name="fade-transform" mode="out-in">
+      <keep-alive :include="cachedViews">
+        <router-view :key="key" />
+      </keep-alive>
+    </transition>
+  </section>
+</template>
+
+<script>
+export default {
+  name: 'AppMain',
+  computed: {
+    cachedViews() {
+      return this.$store.state.tagsView.cachedViews
+    },
+    key() {
+      return this.$route.path
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-main {
+  background-color: #f0f2f5;
+  /* 50= navbar  50  */
+  min-height: calc(100vh - 50px);
+  width: 100%;
+  position: relative;
+  overflow: hidden;
+}
+
+.fixed-header+.app-main {
+  padding-top: 50px;
+}
+
+.hasTagsView {
+  .app-main {
+    /* 84 = navbar + tags-view = 50 + 34 */
+    min-height: calc(100vh - 84px);
+  }
+
+  .fixed-header+.app-main {
+    padding-top: 84px;
+  }
+}
+</style>
+
+<style lang="scss">
+// fix css style bug in open el-dialog
+.el-popup-parent--hidden {
+  .fixed-header {
+    padding-right: 17px;
+  }
+}
+</style>

+ 150 - 0
src/layout/components/Navbar.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="navbar">
+    <hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
+
+    <breadcrumb id="breadcrumb-container" class="breadcrumb-container" />
+
+    <div class="right-menu">
+      <template v-if="device!=='mobile'">
+        <screenfull id="screenfull" class="right-menu-item hover-effect" />
+
+        <el-tooltip content="布局大小" effect="dark" placement="bottom">
+          <size-select id="size-select" class="right-menu-item hover-effect" />
+        </el-tooltip>
+
+      </template>
+
+      <el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
+        <div class="avatar-wrapper">
+          <span class="user-name">{{ proxyName }}</span>
+          <i class="el-icon-caret-bottom" />
+        </div>
+        <el-dropdown-menu slot="dropdown">
+          <el-dropdown-item divided @click.native="logout">
+            <span>退出登录</span>
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import Breadcrumb from '@/components/Breadcrumb'
+import Hamburger from '@/components/Hamburger'
+import Screenfull from '@/components/Screenfull'
+import SizeSelect from '@/components/SizeSelect'
+
+export default {
+  components: {
+    Breadcrumb,
+    Hamburger,
+    Screenfull,
+    SizeSelect
+  },
+  computed: {
+    ...mapGetters([
+      'sidebar',
+      'device'
+    ]),
+    proxyName() {
+      return this.$store.state.proxyInfo.proxyName || '代理用户'
+    }
+  },
+  methods: {
+    toggleSideBar() {
+      this.$store.dispatch('app/toggleSideBar')
+    },
+    async logout() {
+      this.$confirm('确定注销并退出系统吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.$store.dispatch('logout').then(() => {
+          location.href = '/index';
+        })
+      }).catch(() => {});
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.navbar {
+  height: 50px;
+  overflow: hidden;
+  position: relative;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0,21,41,.08);
+
+  .hamburger-container {
+    line-height: 46px;
+    height: 100%;
+    float: left;
+    cursor: pointer;
+    transition: background .3s;
+    -webkit-tap-highlight-color:transparent;
+
+    &:hover {
+      background: rgba(0, 0, 0, .025)
+    }
+  }
+
+  .breadcrumb-container {
+    float: left;
+  }
+
+  .right-menu {
+    float: right;
+    height: 100%;
+    line-height: 50px;
+
+    &:focus {
+      outline: none;
+    }
+
+    .right-menu-item {
+      display: inline-block;
+      padding: 0 8px;
+      height: 100%;
+      font-size: 18px;
+      color: #5a5e66;
+      vertical-align: text-bottom;
+
+      &.hover-effect {
+        cursor: pointer;
+        transition: background .3s;
+
+        &:hover {
+          background: rgba(0, 0, 0, .025)
+        }
+      }
+    }
+
+    .avatar-container {
+      margin-right: 30px;
+
+      .avatar-wrapper {
+        margin-top: 5px;
+        position: relative;
+
+        .user-name {
+          color: #333;
+          font-size: 14px;
+          margin-right: 4px;
+        }
+
+        .el-icon-caret-bottom {
+          cursor: pointer;
+          position: absolute;
+          right: -20px;
+          top: 25px;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 25 - 0
src/layout/components/Sidebar/FixiOSBug.js

@@ -0,0 +1,25 @@
+export default {
+  computed: {
+    device() {
+      return this.$store.state.app.device
+    }
+  },
+  mounted() {
+    // In order to fix the click on menu on the ios device will trigger the mouseleave bug
+    this.fixBugIniOS()
+  },
+  methods: {
+    fixBugIniOS() {
+      const $subMenu = this.$refs.subMenu
+      if ($subMenu) {
+        const handleMouseleave = $subMenu.handleMouseleave
+        $subMenu.handleMouseleave = (e) => {
+          if (this.device === 'mobile') {
+            return
+          }
+          handleMouseleave(e)
+        }
+      }
+    }
+  }
+}

+ 27 - 0
src/layout/components/Sidebar/Item.vue

@@ -0,0 +1,27 @@
+<script>
+export default {
+  name: 'MenuItem',
+  functional: true,
+  props: {
+    icon: {
+      type: String,
+      default: ''
+    },
+    title: {
+      type: String,
+      default: ''
+    }
+  },
+  render(h, context) {
+    const { icon, title } = context.props
+    const vnodes = []
+
+    vnodes.push(<i class={icon || 'el-icon-s-grid'}/>)
+
+    if (title) {
+      vnodes.push(<span slot='title'>{(title)}</span>)
+    }
+    return vnodes
+  }
+}
+</script>

+ 43 - 0
src/layout/components/Sidebar/Link.vue

@@ -0,0 +1,43 @@
+<template>
+  <component :is="type" v-bind="linkProps(to)">
+    <slot />
+  </component>
+</template>
+
+<script>
+import { isExternal } from '@/utils/validate'
+
+export default {
+  props: {
+    to: {
+      type: [String, Object],
+      required: true
+    }
+  },
+  computed: {
+    isExternal() {
+      return isExternal(this.to)
+    },
+    type() {
+      if (this.isExternal) {
+        return 'a'
+      }
+      return 'router-link'
+    }
+  },
+  methods: {
+    linkProps(to) {
+      if (this.isExternal) {
+        return {
+          href: to,
+          target: '_blank',
+          rel: 'noopener'
+        }
+      }
+      return {
+        to: to
+      }
+    }
+  }
+}
+</script>

+ 91 - 0
src/layout/components/Sidebar/Logo.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
+    <transition name="sidebarLogoFade">
+      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
+        <img v-if="logImg" :src="logImg" class="sidebar-logo" />
+        <h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
+      </router-link>
+      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
+        <img v-if="logImg" :src="logImg" class="sidebar-logo" />
+        <h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<script>
+import variables from '@/assets/styles/variables.scss'
+
+export default {
+  name: 'SidebarLogo',
+  props: {
+    collapse: {
+      type: Boolean,
+      required: true
+    }
+  },
+  computed: {
+    variables() {
+      return variables;
+    },
+    sideTheme() {
+      return this.$store.state.settings.sideTheme
+    }
+  },
+  data() {
+    return {
+      title: process.env.VUE_APP_TITLE || "代理端管理系统",
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.sidebarLogoFade-enter-active {
+  transition: opacity 1.5s;
+}
+
+.sidebarLogoFade-enter,
+.sidebarLogoFade-leave-to {
+  opacity: 0;
+}
+
+.sidebar-logo-container {
+  position: relative;
+  width: 100%;
+  height: 50px;
+  line-height: 50px;
+  background: #2b2f3a;
+  text-align: center;
+  overflow: hidden;
+
+  & .sidebar-logo-link {
+    height: 100%;
+    width: 100%;
+
+    & .sidebar-logo {
+      width: 32px;
+      height: 32px;
+      vertical-align: middle;
+      margin-right: 12px;
+    }
+
+    & .sidebar-title {
+      display: inline-block;
+      margin: 0;
+      color: #fff;
+      font-weight: bold;
+      line-height: 50px;
+      font-size: 16px;
+      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
+      vertical-align: middle;
+    }
+  }
+
+  &.collapse {
+    .sidebar-logo {
+      margin-right: 0px;
+    }
+  }
+}
+</style>

+ 100 - 0
src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,100 @@
+<template>
+  <div v-if="!item.hidden">
+    <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
+        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
+          <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
+        </el-menu-item>
+      </app-link>
+    </template>
+
+    <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
+      <template slot="title">
+        <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
+      </template>
+      <sidebar-item
+        v-for="child in item.children"
+        :key="child.path"
+        :is-nest="true"
+        :item="child"
+        :base-path="resolvePath(child.path)"
+        class="nest-menu"
+      />
+    </el-submenu>
+  </div>
+</template>
+
+<script>
+import path from 'path'
+import { isExternal } from '@/utils/validate'
+import Item from './Item'
+import AppLink from './Link'
+import FixiOSBug from './FixiOSBug'
+
+export default {
+  name: 'SidebarItem',
+  components: { Item, AppLink },
+  mixins: [FixiOSBug],
+  props: {
+    // route object
+    item: {
+      type: Object,
+      required: true
+    },
+    isNest: {
+      type: Boolean,
+      default: false
+    },
+    basePath: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    this.onlyOneChild = null
+    return {}
+  },
+  methods: {
+    hasOneShowingChild(children = [], parent) {
+      if (!children) {
+        children = [];
+      }
+      const showingChildren = children.filter(item => {
+        if (item.hidden) {
+          return false
+        } else {
+          // Temp set(will be used if only has one showing child)
+          this.onlyOneChild = item
+          return true
+        }
+      })
+
+      // When there is only one child router, the child router is displayed by default
+      if (showingChildren.length === 1) {
+        return true
+      }
+
+      // Show parent if there are no child router to display
+      if (showingChildren.length === 0) {
+        this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
+        return true
+      }
+
+      return false
+    },
+    resolvePath(routePath, routeQuery) {
+      if (isExternal(routePath)) {
+        return routePath
+      }
+      if (isExternal(this.basePath)) {
+        return this.basePath
+      }
+      if (routeQuery) {
+        let query = JSON.parse(routeQuery);
+        return { path: path.resolve(this.basePath, routePath), query: query }
+      }
+      return path.resolve(this.basePath, routePath)
+    }
+  }
+}
+</script>

+ 57 - 0
src/layout/components/Sidebar/index.vue

@@ -0,0 +1,57 @@
+<template>
+  <div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
+    <logo v-if="showLogo" :collapse="isCollapse" />
+    <el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="isCollapse"
+        :background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
+        :text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
+        :unique-opened="true"
+        :active-text-color="settings.theme"
+        :collapse-transition="false"
+        mode="vertical"
+        class="custom-menu"
+      >
+        <sidebar-item
+          v-for="(route, index) in sidebarRouters"
+          :key="route.path  + index"
+          :item="route"
+          :base-path="route.path"
+        />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<script>
+import { mapGetters, mapState } from "vuex";
+import Logo from "./Logo";
+import SidebarItem from "./SidebarItem";
+import variables from "@/assets/styles/variables.scss";
+
+export default {
+  components: { SidebarItem, Logo },
+  computed: {
+    ...mapState(["settings"]),
+    ...mapGetters(["sidebarRouters", "sidebar"]),
+    activeMenu() {
+      const route = this.$route;
+      const { meta, path } = route;
+      if (meta.activeMenu) {
+        return meta.activeMenu;
+      }
+      return path;
+    },
+    showLogo() {
+      return this.$store.state.settings.sidebarLogo;
+    },
+    variables() {
+      return variables;
+    },
+    isCollapse() {
+      return !this.sidebar.opened;
+    }
+  }
+};
+</script>

+ 89 - 0
src/layout/components/TagsView/ScrollPane.vue

@@ -0,0 +1,89 @@
+<template>
+  <el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
+    <slot />
+  </el-scrollbar>
+</template>
+
+<script>
+const tagAndTagSpacing = 4
+
+export default {
+  name: 'ScrollPane',
+  data() {
+    return {
+      left: 0
+    }
+  },
+  computed: {
+    scrollWrapper() {
+      return this.$refs.scrollContainer.$refs.wrap
+    }
+  },
+  mounted() {
+    this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
+  },
+  beforeDestroy() {
+    this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
+  },
+  methods: {
+    handleScroll(e) {
+      const eventDelta = e.wheelDelta || -e.deltaY * 40
+      const $scrollWrapper = this.scrollWrapper
+      $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
+    },
+    emitScroll() {
+      this.$emit('scroll')
+    },
+    moveToTarget(currentTag) {
+      const $container = this.$refs.scrollContainer.$el
+      const $containerWidth = $container.offsetWidth
+      const $scrollWrapper = this.scrollWrapper
+      const tagList = this.$parent.$refs.tag
+
+      let firstTag = null
+      let lastTag = null
+
+      if (tagList.length > 0) {
+        firstTag = tagList[0]
+        lastTag = tagList[tagList.length - 1]
+      }
+
+      if (firstTag === currentTag) {
+        $scrollWrapper.scrollLeft = 0
+      } else if (lastTag === currentTag) {
+        $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
+      } else {
+        const currentIndex = tagList.findIndex(item => item === currentTag)
+        const prevTag = tagList[currentIndex - 1]
+        const nextTag = tagList[currentIndex + 1]
+
+        const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
+        const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
+
+        if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
+          $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
+        } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
+          $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.scroll-container {
+  white-space: nowrap;
+  position: relative;
+  overflow: hidden;
+  width: 100%;
+  ::v-deep {
+    .el-scrollbar__bar {
+      bottom: 0px;
+    }
+    .el-scrollbar__wrap {
+      height: 49px;
+    }
+  }
+}
+</style>

+ 327 - 0
src/layout/components/TagsView/index.vue

@@ -0,0 +1,327 @@
+<template>
+  <div id="tags-view-container" class="tags-view-container">
+    <scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
+      <router-link
+        v-for="tag in visitedViews"
+        ref="tag"
+        :key="tag.path"
+        :class="isActive(tag)?'active':''"
+        :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
+        tag="span"
+        class="tags-view-item"
+        :style="activeStyle(tag)"
+        @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
+        @contextmenu.prevent.native="openMenu(tag,$event)"
+      >
+        {{ tag.title }}
+        <span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
+      </router-link>
+    </scroll-pane>
+    <ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
+      <li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
+      <li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
+      <li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
+      <li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
+      <li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import ScrollPane from './ScrollPane'
+import path from 'path'
+
+export default {
+  components: { ScrollPane },
+  data() {
+    return {
+      visible: false,
+      top: 0,
+      left: 0,
+      selectedTag: {},
+      affixTags: []
+    }
+  },
+  computed: {
+    visitedViews() {
+      return this.$store.state.tagsView.visitedViews
+    },
+    routes() {
+      return this.$store.state.permission.routes
+    },
+    theme() {
+      return this.$store.state.settings.theme;
+    }
+  },
+  watch: {
+    $route() {
+      this.addTags()
+      this.moveToCurrentTag()
+    },
+    visible(value) {
+      if (value) {
+        document.body.addEventListener('click', this.closeMenu)
+      } else {
+        document.body.removeEventListener('click', this.closeMenu)
+      }
+    }
+  },
+  mounted() {
+    this.initTags()
+    this.addTags()
+  },
+  methods: {
+    isActive(route) {
+      return route.path === this.$route.path
+    },
+    activeStyle(tag) {
+      if (!this.isActive(tag)) return {};
+      return {
+        "background-color": this.theme,
+        "border-color": this.theme
+      };
+    },
+    isAffix(tag) {
+      return tag.meta && tag.meta.affix
+    },
+    isFirstView() {
+      try {
+        return this.selectedTag.fullPath === this.visitedViews[1].fullPath || this.selectedTag.fullPath === '/index'
+      } catch (err) {
+        return false
+      }
+    },
+    isLastView() {
+      try {
+        return this.selectedTag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
+      } catch (err) {
+        return false
+      }
+    },
+    filterAffixTags(routes, basePath = '/') {
+      let tags = []
+      routes.forEach(route => {
+        if (route.meta && route.meta.affix) {
+          const tagPath = path.resolve(basePath, route.path)
+          tags.push({
+            fullPath: tagPath,
+            path: tagPath,
+            name: route.name,
+            meta: { ...route.meta }
+          })
+        }
+        if (route.children) {
+          const tempTags = this.filterAffixTags(route.children, route.path)
+          if (tempTags.length >= 1) {
+            tags = [...tags, ...tempTags]
+          }
+        }
+      })
+      return tags
+    },
+    initTags() {
+      const affixTags = this.affixTags = this.filterAffixTags(this.routes)
+      for (const tag of affixTags) {
+        if (tag.name) {
+          this.$store.dispatch('tagsView/addVisitedView', tag)
+        }
+      }
+    },
+    addTags() {
+      const { name } = this.$route
+      if (name) {
+        this.$store.dispatch('tagsView/addView', this.$route)
+      }
+      return false
+    },
+    moveToCurrentTag() {
+      const tags = this.$refs.tag
+      this.$nextTick(() => {
+        for (const tag of tags) {
+          if (tag.to.path === this.$route.path) {
+            this.$refs.scrollPane.moveToTarget(tag)
+            if (tag.to.fullPath !== this.$route.fullPath) {
+              this.$store.dispatch('tagsView/updateVisitedView', this.$route)
+            }
+            break
+          }
+        }
+      })
+    },
+    refreshSelectedTag(view) {
+      this.$store.dispatch('tagsView/delCachedView', view).then(() => {
+        const { fullPath } = view
+        this.$nextTick(() => {
+          this.$router.replace({
+            path: '/redirect' + fullPath
+          })
+        })
+      })
+    },
+    closeSelectedTag(view) {
+      this.$store.dispatch('tagsView/delView', view).then(({ visitedViews }) => {
+        if (this.isActive(view)) {
+          this.toLastView(visitedViews, view)
+        }
+      })
+    },
+    closeRightTags() {
+      this.$store.dispatch('tagsView/delRightTags', this.selectedTag).then(visitedViews => {
+        if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
+          this.toLastView(visitedViews)
+        }
+      })
+    },
+    closeLeftTags() {
+      this.$store.dispatch('tagsView/delLeftTags', this.selectedTag).then(visitedViews => {
+        if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
+          this.toLastView(visitedViews)
+        }
+      })
+    },
+    closeOthersTags() {
+      this.$router.push(this.selectedTag).catch(()=>{});
+      this.$store.dispatch('tagsView/delOthersViews', this.selectedTag).then(() => {
+        this.moveToCurrentTag()
+      })
+    },
+    closeAllTags(view) {
+      this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
+        if (this.affixTags.some(tag => tag.path === this.$route.path)) {
+          return
+        }
+        this.toLastView(visitedViews, view)
+      })
+    },
+    toLastView(visitedViews, view) {
+      const latestView = visitedViews.slice(-1)[0]
+      if (latestView) {
+        this.$router.push(latestView.fullPath)
+      } else {
+        if (view.name === 'Dashboard') {
+          this.$router.replace({ path: '/redirect' + view.fullPath })
+        } else {
+          this.$router.push('/')
+        }
+      }
+    },
+    openMenu(tag, e) {
+      const menuMinWidth = 105
+      const offsetLeft = this.$el.getBoundingClientRect().left
+      const offsetWidth = this.$el.offsetWidth
+      const maxLeft = offsetWidth - menuMinWidth
+      const left = e.clientX - offsetLeft + 15
+
+      if (left > maxLeft) {
+        this.left = maxLeft
+      } else {
+        this.left = left
+      }
+
+      this.top = e.clientY
+      this.visible = true
+      this.selectedTag = tag
+    },
+    closeMenu() {
+      this.visible = false
+    },
+    handleScroll() {
+      this.closeMenu()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.tags-view-container {
+  height: 34px;
+  width: 100%;
+  background: #fff;
+  border-bottom: 1px solid #d8dce5;
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
+  .tags-view-wrapper {
+    .tags-view-item {
+      display: inline-block;
+      position: relative;
+      cursor: pointer;
+      height: 26px;
+      line-height: 26px;
+      border: 1px solid #d8dce5;
+      color: #495060;
+      background: #fff;
+      padding: 0 8px;
+      font-size: 12px;
+      margin-left: 5px;
+      margin-top: 4px;
+      &:first-of-type {
+        margin-left: 15px;
+      }
+      &:last-of-type {
+        margin-right: 15px;
+      }
+      &.active {
+        background-color: #42b983;
+        color: #fff;
+        border-color: #42b983;
+        &::before {
+          content: '';
+          background: #fff;
+          display: inline-block;
+          width: 8px;
+          height: 8px;
+          border-radius: 50%;
+          position: relative;
+          margin-right: 2px;
+        }
+      }
+    }
+  }
+  .contextmenu {
+    margin: 0;
+    background: #fff;
+    z-index: 3000;
+    position: absolute;
+    list-style-type: none;
+    padding: 5px 0;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 400;
+    color: #333;
+    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
+    li {
+      margin: 0;
+      padding: 7px 16px;
+      cursor: pointer;
+      &:hover {
+        background: #eee;
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+.tags-view-wrapper {
+  .tags-view-item {
+    .el-icon-close {
+      width: 16px;
+      height: 16px;
+      vertical-align: 2px;
+      border-radius: 50%;
+      text-align: center;
+      transition: all .3s cubic-bezier(.645, .045, .355, 1);
+      transform-origin: 100% 50%;
+      &:before {
+        transform: scale(.6);
+        display: inline-block;
+        vertical-align: -3px;
+      }
+      &:hover {
+        background-color: #b4bccc;
+        color: #fff;
+      }
+    }
+  }
+}
+</style>

+ 4 - 0
src/layout/components/index.js

@@ -0,0 +1,4 @@
+export { default as AppMain } from './AppMain'
+export { default as Navbar } from './Navbar'
+export { default as Sidebar } from './Sidebar/index.vue'
+export { default as TagsView } from './TagsView/index.vue'

+ 97 - 0
src/layout/index.vue

@@ -0,0 +1,97 @@
+<template>
+  <div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
+    <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
+    <sidebar class="sidebar-container"/>
+    <div :class="{hasTagsView:needTagsView}" class="main-container">
+      <div :class="{'fixed-header':fixedHeader}">
+        <navbar />
+        <tags-view v-if="needTagsView" />
+      </div>
+      <app-main />
+    </div>
+  </div>
+</template>
+
+<script>
+import { AppMain, Navbar, Sidebar, TagsView } from './components'
+import ResizeMixin from './mixin/ResizeHandler'
+import { mapState } from 'vuex'
+
+export default {
+  name: 'Layout',
+  components: {
+    AppMain,
+    Navbar,
+    Sidebar,
+    TagsView
+  },
+  mixins: [ResizeMixin],
+  computed: {
+    ...mapState({
+      theme: state => state.settings.theme,
+      sideTheme: state => state.settings.sideTheme,
+      sidebar: state => state.app.sidebar,
+      device: state => state.app.device,
+      needTagsView: state => state.settings.tagsView,
+      fixedHeader: state => state.settings.fixedHeader
+    }),
+    classObj() {
+      return {
+        hideSidebar: !this.sidebar.opened,
+        openSidebar: this.sidebar.opened,
+        withoutAnimation: this.sidebar.withoutAnimation,
+        mobile: this.device === 'mobile'
+      }
+    }
+  },
+  methods: {
+    handleClickOutside() {
+      this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  @import "~@/assets/styles/mixin.scss";
+  @import "~@/assets/styles/variables.scss";
+
+  .app-wrapper {
+    @include clearfix;
+    position: relative;
+    height: 100%;
+    width: 100%;
+
+    &.mobile.openSidebar {
+      position: fixed;
+      top: 0;
+    }
+  }
+
+  .drawer-bg {
+    background: #000;
+    opacity: 0.3;
+    width: 100%;
+    top: 0;
+    height: 100%;
+    position: absolute;
+    z-index: 999;
+  }
+
+  .fixed-header {
+    position: fixed;
+    top: 0;
+    right: 0;
+    z-index: 9;
+    width: calc(100% - #{$base-sidebar-width});
+    transition: width 0.28s;
+  }
+
+  .hideSidebar .fixed-header {
+    width: calc(100% - 54px)
+  }
+
+  .mobile .fixed-header {
+    width: 100%;
+  }
+</style>

+ 43 - 0
src/layout/mixin/ResizeHandler.js

@@ -0,0 +1,43 @@
+import store from '@/store'
+
+const { body } = document
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+export default {
+  watch: {
+    $route(route) {
+      if (this.device === 'mobile' && this.sidebar.opened) {
+        store.dispatch('app/closeSideBar', { withoutAnimation: false })
+      }
+    }
+  },
+  beforeMount() {
+    window.addEventListener('resize', this.$_resizeHandler)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.$_resizeHandler)
+  },
+  mounted() {
+    const isMobile = this.$_isMobile()
+    if (isMobile) {
+      store.dispatch('app/toggleDevice', 'mobile')
+      store.dispatch('app/closeSideBar', { withoutAnimation: true })
+    }
+  },
+  methods: {
+    $_isMobile() {
+      const rect = body.getBoundingClientRect()
+      return rect.width - 1 < WIDTH
+    },
+    $_resizeHandler() {
+      if (!document.hidden) {
+        const isMobile = this.$_isMobile()
+        store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
+
+        if (isMobile) {
+          store.dispatch('app/closeSideBar', { withoutAnimation: true })
+        }
+      }
+    }
+  }
+}

+ 64 - 0
src/main.js

@@ -0,0 +1,64 @@
+/*
+ * @Author: FS
+ * @Date: 2024-01-01 09:00:00
+ * @Description: 代理端管理系统入口文件
+ */
+import Vue from 'vue'
+
+import Cookies from 'js-cookie'
+
+import Element from 'element-ui'
+import './assets/styles/element-variables.scss'
+import '@/assets/styles/index.scss' // global css
+import '@/assets/styles/common.scss'
+import App from './App'
+import store from './store'
+import router from './router'
+import './permission' // permission control
+import VueMeta from 'vue-meta'
+import * as echarts from "echarts";
+import logImgDefault from '@/assets/images/profile.jpg'
+
+// 全局方法挂载
+Vue.prototype.logImg = process.env.VUE_APP_LOG_URL || logImgDefault
+Vue.prototype.echarts = echarts
+
+Vue.prototype.msgSuccess = function (msg) {
+  this.$message({ showClose: true, message: msg, type: "success" });
+}
+
+Vue.prototype.msgError = function (msg) {
+  this.$message({ showClose: true, message: msg, type: "error" });
+}
+
+Vue.prototype.msgInfo = function (msg) {
+  this.$message.info(msg);
+}
+
+Vue.use(VueMeta)
+
+/**
+ * If you don't want to use mock-server
+ * you want to use MockJs for mock api
+ * you can execute: mockXHR()
+ *
+ * Currently MockJs will be used in the production environment,
+ * please remove it before going online! ! !
+ */
+
+Vue.use(Element, {
+  size: Cookies.get('size') || 'medium' // set element-ui default size
+})
+
+Vue.config.productionTip = false
+
+async function bootstrap() {
+  new Vue({
+    el: '#app',
+    router,
+    store,
+    render: h => h(App)
+  })
+}
+
+bootstrap()

+ 41 - 0
src/permission.js

@@ -0,0 +1,41 @@
+import router from './router'
+import store from './store'
+import { Message } from 'element-ui'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { getToken } from '@/utils/auth'
+
+NProgress.configure({ showSpinner: false })
+
+const whiteList = ['/login', '/404']
+
+router.beforeEach((to, from, next) => {
+  NProgress.start()
+  if (getToken()) {
+    to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
+    if (to.path === '/login') {
+      next({ path: '/' })
+      NProgress.done()
+    } else {
+      // 代理端:加载路由菜单
+      if (store.getters.sidebarRouters.length === 0) {
+        store.dispatch('permission/generateRoutes').then(() => {
+          next()
+        })
+      } else {
+        next()
+      }
+    }
+  } else {
+    if (whiteList.indexOf(to.path) !== -1) {
+      next()
+    } else {
+      next(`/login?redirect=${to.fullPath}`)
+      NProgress.done()
+    }
+  }
+})
+
+router.afterEach(() => {
+  NProgress.done()
+})

+ 178 - 0
src/router/index.js

@@ -0,0 +1,178 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)
+
+export const constantRoutes = [
+  {
+    path: '/login',
+    component: () => import('@/views/login/index'),
+    hidden: true
+  },
+  {
+    path: '/404',
+    component: () => import('@/views/404'),
+    hidden: true
+  },
+  {
+    path: '/',
+    component: () => import('@/layout/index'),
+    redirect: '/dashboard',
+    children: [
+      {
+        path: 'dashboard',
+        name: 'Dashboard',
+        component: () => import('@/views/dashboard/index'),
+        meta: { title: '工作台', icon: 'el-icon-s-home' }
+      }
+    ]
+  },
+  {
+    path: '/tenant',
+    component: () => import('@/layout/index'),
+    redirect: '/tenant/list',
+    name: 'Tenant',
+    meta: { title: '租户管理', icon: 'el-icon-user' },
+    children: [
+      {
+        path: 'list',
+        name: 'TenantList',
+        component: () => import('@/views/tenant/index'),
+        meta: { title: '租户列表', icon: 'el-icon-user' }
+      }
+    ]
+  },
+  {
+    path: '/quota',
+    component: () => import('@/layout/index'),
+    redirect: '/quota/list',
+    name: 'Quota',
+    meta: { title: '配额管理', icon: 'el-icon-setting' },
+    children: [
+      {
+        path: 'list',
+        name: 'QuotaList',
+        component: () => import('@/views/quota/index'),
+        meta: { title: '配额列表', icon: 'el-icon-setting' }
+      }
+    ]
+  },
+  {
+    path: '/balance',
+    component: () => import('@/layout/index'),
+    redirect: '/balance/flow',
+    name: 'Balance',
+    meta: { title: '余额流水', icon: 'el-icon-wallet' },
+    children: [
+      {
+        path: 'flow',
+        name: 'BalanceFlow',
+        component: () => import('@/views/balance/flow'),
+        meta: { title: '流水明细', icon: 'el-icon-wallet' }
+      },
+      {
+        path: 'recharge',
+        name: 'BalanceRecharge',
+        component: () => import('@/views/balance/recharge'),
+        meta: { title: '充值记录', icon: 'el-icon-plus' }
+      }
+    ]
+  },
+  {
+    path: '/profit',
+    component: () => import('@/layout/index'),
+    redirect: '/profit/detail',
+    name: 'Profit',
+    meta: { title: '分账明细', icon: 'el-icon-split-cell' },
+    children: [
+      {
+        path: 'detail',
+        name: 'ProfitDetail',
+        component: () => import('@/views/profit/index'),
+        meta: { title: '分账明细', icon: 'el-icon-split-cell' }
+      }
+    ]
+  },
+  {
+    path: '/withdraw',
+    component: () => import('@/layout/index'),
+    redirect: '/withdraw/list',
+    name: 'Withdraw',
+    meta: { title: '提现管理', icon: 'el-icon-money' },
+    children: [
+      {
+        path: 'list',
+        name: 'WithdrawList',
+        component: () => import('@/views/withdraw/index'),
+        meta: { title: '提现列表', icon: 'el-icon-money' }
+      }
+    ]
+  },
+  {
+    path: '/performance',
+    component: () => import('@/layout/index'),
+    redirect: '/performance/rank',
+    name: 'Performance',
+    meta: { title: '业绩查看', icon: 'el-icon-trending-up' },
+    children: [
+      {
+        path: 'rank',
+        name: 'PerformanceRank',
+        component: () => import('@/views/performance/index'),
+        meta: { title: '业绩排行', icon: 'el-icon-trending-up' }
+      }
+    ]
+  },
+  {
+    path: '/fee',
+    component: () => import('@/layout/index'),
+    redirect: '/fee/config',
+    name: 'Fee',
+    meta: { title: '收费管理', icon: 'el-icon-ticket' },
+    children: [
+      {
+        path: 'config',
+        name: 'FeeConfig',
+        component: () => import('@/views/fee/index'),
+        meta: { title: '收费配置', icon: 'el-icon-ticket' }
+      },
+      {
+        path: 'moduleUsage',
+        name: 'FeeModuleUsage',
+        component: () => import('@/views/fee/moduleUsage/index'),
+        meta: { title: '模块使用统计', icon: 'el-icon-data-analysis' }
+      },
+      {
+        path: 'consumeReport',
+        name: 'FeeConsumeReport',
+        component: () => import('@/views/fee/consumeReport/index'),
+        meta: { title: '模块消费统计', icon: 'el-icon-s-order' }
+      }
+    ]
+  },
+  // 404 catch-all 必须放在最后,防止未匹配路由导致异常重定向
+  { path: '*', redirect: '/404', hidden: true }
+]
+
+// 重定向路由,用于tags-view刷新
+export const redirectRoute = {
+  path: '/redirect/:path(.*)',
+  hidden: true,
+  component: () => import('@/views/redirect/index')
+}
+
+const createRouter = () => new Router({
+  scrollBehavior: () => ({ y: 0 }),
+  routes: [...constantRoutes, redirectRoute]
+})
+
+const router = createRouter()
+
+// 权限控制已移至 permission.js
+
+export function resetRouter() {
+  const newRouter = createRouter()
+  router.matcher = newRouter.matcher
+}
+
+export default router

+ 49 - 0
src/settings.js

@@ -0,0 +1,49 @@
+module.exports = {
+  /**
+   * 侧边栏主题 深色主题theme-dark,浅色主题theme-light
+   */
+  sideTheme: 'theme-dark',
+
+  /**
+   * 是否系统布局配置
+   */
+  showSettings: false,
+
+  /**
+   * 是否显示顶部导航
+   */
+  topNav: false,
+
+  /**
+   * 是否显示 tagsView
+   */
+  tagsView: true,
+
+  /**
+   * 是否固定头部
+   */
+  fixedHeader: false,
+
+  /**
+   * 是否显示logo
+   */
+  sidebarLogo: true,
+
+  /**
+   * 是否显示动态标题
+   */
+  dynamicTitle: false,
+
+  /**
+   * 代理端系统标题
+   */
+  title: '代理端管理系统',
+
+  /**
+   * @type {string | array} 'production' | ['production', 'development']
+   * @description Need show err logs component.
+   * The default is only used in the production env
+   * If you want to also use it in dev, you can pass ['production', 'development']
+   */
+  errorLog: 'production'
+}

+ 9 - 0
src/store/getters.js

@@ -0,0 +1,9 @@
+const getters = {
+  sidebar: state => state.app.sidebar,
+  size: state => state.app.size,
+  device: state => state.app.device,
+  visitedViews: state => state.tagsView.visitedViews,
+  cachedViews: state => state.tagsView.cachedViews,
+  sidebarRouters: state => state.permission.sidebarRouters,
+}
+export default getters

+ 52 - 0
src/store/index.js

@@ -0,0 +1,52 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import app from './modules/app'
+import settings from './modules/settings'
+import tagsView from './modules/tagsView'
+import permission from './modules/permission'
+import getters from './getters'
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+  modules: {
+    app,
+    settings,
+    tagsView,
+    permission
+  },
+  getters,
+  state: {
+    token: localStorage.getItem('proxy_token') || '',
+    proxyInfo: JSON.parse(localStorage.getItem('proxy_info') || '{}')
+  },
+  mutations: {
+    SET_TOKEN: (state, token) => {
+      state.token = token
+      localStorage.setItem('proxy_token', token)
+    },
+    SET_PROXY_INFO: (state, proxyInfo) => {
+      state.proxyInfo = proxyInfo
+      localStorage.setItem('proxy_info', JSON.stringify(proxyInfo))
+    },
+    CLEAR_AUTH: (state) => {
+      state.token = ''
+      state.proxyInfo = {}
+      localStorage.removeItem('proxy_token')
+      localStorage.removeItem('proxy_info')
+    }
+  },
+  actions: {
+    setToken({ commit }, token) {
+      commit('SET_TOKEN', token)
+    },
+    setProxyInfo({ commit }, proxyInfo) {
+      commit('SET_PROXY_INFO', proxyInfo)
+    },
+    logout({ commit }) {
+      commit('CLEAR_AUTH')
+    }
+  }
+})
+
+export default store

+ 56 - 0
src/store/modules/app.js

@@ -0,0 +1,56 @@
+import Cookies from 'js-cookie'
+
+const state = {
+  sidebar: {
+    opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
+    withoutAnimation: false
+  },
+  device: 'desktop',
+  size: Cookies.get('size') || 'medium'
+}
+
+const mutations = {
+  TOGGLE_SIDEBAR: state => {
+    state.sidebar.opened = !state.sidebar.opened
+    state.sidebar.withoutAnimation = false
+    if (state.sidebar.opened) {
+      Cookies.set('sidebarStatus', 1)
+    } else {
+      Cookies.set('sidebarStatus', 0)
+    }
+  },
+  CLOSE_SIDEBAR: (state, withoutAnimation) => {
+    Cookies.set('sidebarStatus', 0)
+    state.sidebar.opened = false
+    state.sidebar.withoutAnimation = withoutAnimation
+  },
+  TOGGLE_DEVICE: (state, device) => {
+    state.device = device
+  },
+  SET_SIZE: (state, size) => {
+    state.size = size
+    Cookies.set('size', size)
+  }
+}
+
+const actions = {
+  toggleSideBar({ commit }) {
+    commit('TOGGLE_SIDEBAR')
+  },
+  closeSideBar({ commit }, { withoutAnimation }) {
+    commit('CLOSE_SIDEBAR', withoutAnimation)
+  },
+  toggleDevice({ commit }, device) {
+    commit('TOGGLE_DEVICE', device)
+  },
+  setSize({ commit }, size) {
+    commit('SET_SIZE', size)
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 53 - 0
src/store/modules/permission.js

@@ -0,0 +1,53 @@
+import { constantRoutes } from '@/router'
+
+/**
+ * 深拷贝路由配置,避免修改原始 constantRoutes 对象
+ */
+function deepCloneRoutes(routes) {
+  return routes.map(route => {
+    const newRoute = { ...route }
+    if (route.children) {
+      newRoute.children = route.children.map(child => ({ ...child }))
+    }
+    return newRoute
+  })
+}
+
+const state = {
+  routes: [],
+  sidebarRouters: []
+}
+
+const mutations = {
+  SET_ROUTES: (state, routes) => {
+    state.routes = constantRoutes.concat(routes)
+    state.sidebarRouters = routes
+  }
+}
+
+const actions = {
+  generateRoutes({ commit }) {
+    return new Promise(resolve => {
+      // 代理端使用constantRoutes作为菜单,过滤hidden的路由
+      // 必须使用深拷贝,避免修改原始constantRoutes导致路由匹配异常
+      const clonedRoutes = deepCloneRoutes(constantRoutes)
+      const accessedRoutes = clonedRoutes.filter(route => {
+        if (route.children) {
+          route.children = route.children.filter(child => {
+            return !child.hidden
+          })
+        }
+        return !route.hidden
+      })
+      commit('SET_ROUTES', accessedRoutes)
+      resolve(accessedRoutes)
+    })
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 41 - 0
src/store/modules/settings.js

@@ -0,0 +1,41 @@
+import defaultSettings from '@/settings'
+
+const { sideTheme, showSettings, topNav, tagsView, fixedHeader, sidebarLogo, dynamicTitle } = defaultSettings
+
+const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
+const state = {
+  title: '',
+  theme: storageSetting.theme || '#006CFF',
+  sideTheme: storageSetting.sideTheme || sideTheme,
+  showSettings: showSettings,
+  topNav: storageSetting.topNav === undefined ? topNav : storageSetting.topNav,
+  tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
+  fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
+  sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,
+  dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle
+}
+const mutations = {
+  CHANGE_SETTING: (state, { key, value }) => {
+    if (state.hasOwnProperty(key)) {
+      state[key] = value
+    }
+  }
+}
+
+const actions = {
+  // 修改布局设置
+  changeSetting({ commit }, data) {
+    commit('CHANGE_SETTING', data)
+  },
+  // 设置网页标题
+  setTitle({ commit }, title) {
+    state.title = title
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 207 - 0
src/store/modules/tagsView.js

@@ -0,0 +1,207 @@
+const state = {
+  visitedViews: [],
+  cachedViews: []
+}
+
+const mutations = {
+  ADD_VISITED_VIEW: (state, view) => {
+    if (state.visitedViews.some(v => v.path === view.path)) return
+    state.visitedViews.push(
+      Object.assign({}, view, {
+        title: view.meta.title || 'no-name'
+      })
+    )
+  },
+  ADD_CACHED_VIEW: (state, view) => {
+    if (state.cachedViews.includes(view.name)) return
+    if (!view.meta.noCache) {
+      state.cachedViews.push(view.name)
+    }
+  },
+
+  DEL_VISITED_VIEW: (state, view) => {
+    for (const [i, v] of state.visitedViews.entries()) {
+      if (v.path === view.path) {
+        state.visitedViews.splice(i, 1)
+        break
+      }
+    }
+  },
+  DEL_CACHED_VIEW: (state, view) => {
+    const index = state.cachedViews.indexOf(view.name)
+    index > -1 && state.cachedViews.splice(index, 1)
+  },
+
+  DEL_OTHERS_VISITED_VIEWS: (state, view) => {
+    state.visitedViews = state.visitedViews.filter(v => {
+      return v.meta.affix || v.path === view.path
+    })
+  },
+  DEL_OTHERS_CACHED_VIEWS: (state, view) => {
+    const index = state.cachedViews.indexOf(view.name)
+    if (index > -1) {
+      state.cachedViews = state.cachedViews.slice(index, index + 1)
+    } else {
+      state.cachedViews = []
+    }
+  },
+
+  DEL_ALL_VISITED_VIEWS: state => {
+    // keep affix tags
+    const affixTags = state.visitedViews.filter(tag => tag.meta.affix)
+    state.visitedViews = affixTags
+  },
+  DEL_ALL_CACHED_VIEWS: state => {
+    state.cachedViews = []
+  },
+
+  UPDATE_VISITED_VIEW: (state, view) => {
+    for (let v of state.visitedViews) {
+      if (v.path === view.path) {
+        v = Object.assign(v, view)
+        break
+      }
+    }
+  },
+
+  DEL_RIGHT_VIEWS: (state, view) => {
+    const index = state.visitedViews.findIndex(v => v.path === view.path)
+    if (index === -1) {
+      return
+    }
+    state.visitedViews = state.visitedViews.filter((item, idx) => {
+      if (idx <= index || (item.meta && item.meta.affix)) {
+        return true
+      }
+      const i = state.cachedViews.indexOf(item.name)
+      if (i > -1) {
+        state.cachedViews.splice(i, 1)
+      }
+      return false
+    })
+  },
+
+  DEL_LEFT_VIEWS: (state, view) => {
+    const index = state.visitedViews.findIndex(v => v.path === view.path)
+    if (index === -1) {
+      return
+    }
+    state.visitedViews = state.visitedViews.filter((item, idx) => {
+      if (idx >= index || (item.meta && item.meta.affix)) {
+        return true
+      }
+      const i = state.cachedViews.indexOf(item.name)
+      if (i > -1) {
+        state.cachedViews.splice(i, 1)
+      }
+      return false
+    })
+  }
+}
+
+const actions = {
+  addView({ dispatch }, view) {
+    dispatch('addVisitedView', view)
+    dispatch('addCachedView', view)
+  },
+  addVisitedView({ commit }, view) {
+    commit('ADD_VISITED_VIEW', view)
+  },
+  addCachedView({ commit }, view) {
+    commit('ADD_CACHED_VIEW', view)
+  },
+
+  delView({ dispatch, state }, view) {
+    return new Promise(resolve => {
+      dispatch('delVisitedView', view)
+      dispatch('delCachedView', view)
+      resolve({
+        visitedViews: [...state.visitedViews],
+        cachedViews: [...state.cachedViews]
+      })
+    })
+  },
+  delVisitedView({ commit, state }, view) {
+    return new Promise(resolve => {
+      commit('DEL_VISITED_VIEW', view)
+      resolve([...state.visitedViews])
+    })
+  },
+  delCachedView({ commit, state }, view) {
+    return new Promise(resolve => {
+      commit('DEL_CACHED_VIEW', view)
+      resolve([...state.cachedViews])
+    })
+  },
+
+  delOthersViews({ dispatch, state }, view) {
+    return new Promise(resolve => {
+      dispatch('delOthersVisitedViews', view)
+      dispatch('delOthersCachedViews', view)
+      resolve({
+        visitedViews: [...state.visitedViews],
+        cachedViews: [...state.cachedViews]
+      })
+    })
+  },
+  delOthersVisitedViews({ commit, state }, view) {
+    return new Promise(resolve => {
+      commit('DEL_OTHERS_VISITED_VIEWS', view)
+      resolve([...state.visitedViews])
+    })
+  },
+  delOthersCachedViews({ commit, state }, view) {
+    return new Promise(resolve => {
+      commit('DEL_OTHERS_CACHED_VIEWS', view)
+      resolve([...state.cachedViews])
+    })
+  },
+
+  delAllViews({ dispatch, state }, view) {
+    return new Promise(resolve => {
+      dispatch('delAllVisitedViews', view)
+      dispatch('delAllCachedViews', view)
+      resolve({
+        visitedViews: [...state.visitedViews],
+        cachedViews: [...state.cachedViews]
+      })
+    })
+  },
+  delAllVisitedViews({ commit, state }) {
+    return new Promise(resolve => {
+      commit('DEL_ALL_VISITED_VIEWS')
+      resolve([...state.visitedViews])
+    })
+  },
+  delAllCachedViews({ commit, state }) {
+    return new Promise(resolve => {
+      commit('DEL_ALL_CACHED_VIEWS')
+      resolve([...state.cachedViews])
+    })
+  },
+
+  updateVisitedView({ commit }, view) {
+    commit('UPDATE_VISITED_VIEW', view)
+  },
+
+  delRightTags({ commit }, view) {
+    return new Promise(resolve => {
+      commit('DEL_RIGHT_VIEWS', view)
+      resolve([...state.visitedViews])
+    })
+  },
+
+  delLeftTags({ commit }, view) {
+    return new Promise(resolve => {
+      commit('DEL_LEFT_VIEWS', view)
+      resolve([...state.visitedViews])
+    })
+  },
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 17 - 0
src/utils/auth.js

@@ -0,0 +1,17 @@
+import Cookies from 'js-cookie'
+
+const TokenKey = 'proxy_token'
+
+export function getToken() {
+  return Cookies.get(TokenKey) || localStorage.getItem(TokenKey)
+}
+
+export function setToken(token) {
+  localStorage.setItem(TokenKey, token)
+  return Cookies.set(TokenKey, token)
+}
+
+export function removeToken() {
+  localStorage.removeItem(TokenKey)
+  return Cookies.remove(TokenKey)
+}

+ 56 - 0
src/utils/request.js

@@ -0,0 +1,56 @@
+import axios from 'axios'
+import { Message, MessageBox } from 'element-ui'
+import store from '@/store'
+
+const service = axios.create({
+  baseURL: process.env.VUE_APP_BASE_API || '/api',
+  timeout: 30000
+})
+
+service.interceptors.request.use(
+  config => {
+    // 始终发送 X-Frontend-Type 标识
+    config.headers['X-Frontend-Type'] = 'agent'
+    if (store.state.token) {
+      config.headers['Authorization'] = 'Bearer ' + store.state.token
+    }
+    return config
+  },
+  error => {
+    console.error('请求错误:', error)
+    return Promise.reject(error)
+  }
+)
+
+service.interceptors.response.use(
+  response => {
+    const res = response.data
+    if (res.code !== 200) {
+      Message({
+        message: res.message || '请求失败',
+        type: 'error',
+        duration: 3 * 1000
+      })
+      if (res.code === 401) {
+        // 清除过期token
+        store.commit('CLEAR_AUTH')
+        // 跳转登录页,保留当前端口
+        window.location.href = window.location.origin + '/login'
+      }
+      return Promise.reject(new Error(res.message || 'Error'))
+    } else {
+      return res
+    }
+  },
+  error => {
+    console.error('响应错误:', error)
+    Message({
+      message: error.message,
+      type: 'error',
+      duration: 3 * 1000
+    })
+    return Promise.reject(error)
+  }
+)
+
+export default service

+ 103 - 0
src/utils/validate.js

@@ -0,0 +1,103 @@
+/**
+ * @param {string} path
+ * @returns {Boolean}
+ */
+export function isExternal(path) {
+  return /^(https?:|mailto:|tel:)/.test(path)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validUsername(str) {
+  const valid_map = ['admin', 'editor']
+  return valid_map.indexOf(str.trim()) >= 0
+}
+
+/**
+ * @param {string} url
+ * @returns {Boolean}
+ */
+export function validURL(url) {
+  const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
+  return reg.test(url)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validLowerCase(str) {
+  const reg = /^[a-z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validUpperCase(str) {
+  const reg = /^[A-Z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validAlphabets(str) {
+  const reg = /^[A-Za-z]+$/
+  return reg.test(str)
+}
+
+/**
+ * @param {string} email
+ * @returns {Boolean}
+ */
+export function validEmail(email) {
+  const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+  return reg.test(email)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function isString(str) {
+  if (typeof str === 'string' || str instanceof String) {
+    return true
+  }
+  return false
+}
+
+/**
+ * @param {Array} arg
+ * @returns {Boolean}
+ */
+export function isArray(arg) {
+  if (typeof Array.isArray === 'undefined') {
+    return Object.prototype.toString.call(arg) === '[object Array]'
+  }
+  return Array.isArray(arg)
+}
+
+export function isNum(val) {
+  const reg = /^\d+(\.\d{1})?$/
+  return reg.test(val)
+}
+
+export function isNumber(val) {
+  const reg = /^\d+(\.\d{1,2})?$/
+  return reg.test(val)
+}
+
+export function isInteger(val) {
+  const reg = /^[1-9]\d*$/
+  return reg.test(val)
+}
+
+export function validPhone(val) {
+  const reg = /^1[3-9]\d{9}$/;
+  return reg.test(val)
+}

+ 55 - 0
src/views/404.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="page-404">
+    <div class="content">
+      <div class="icon">
+        <i class="el-icon-error" />
+      </div>
+      <h1>404</h1>
+      <p>页面不存在</p>
+      <el-button type="primary" @click="goBack">返回首页</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Page404',
+  methods: {
+    goBack() {
+      this.$router.push('/')
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.page-404 {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f5f5;
+}
+
+.content {
+  text-align: center;
+
+  .icon {
+    font-size: 80px;
+    color: #ccc;
+    margin-bottom: 20px;
+  }
+
+  h1 {
+    font-size: 72px;
+    color: #333;
+    margin-bottom: 10px;
+  }
+
+  p {
+    color: #999;
+    font-size: 16px;
+    margin-bottom: 30px;
+  }
+}
+</style>

+ 255 - 0
src/views/balance/flow.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="balance-container">
+    <div class="search-bar">
+      <el-input v-model="searchForm.keyword" placeholder="租户名称/流水号" class="search-input" />
+      <el-select v-model="searchForm.serviceType" placeholder="服务类型">
+        <el-option label="全部" value="" />
+        <el-option label="充值" value="0" />
+        <el-option label="AI模型训练" value="1" />
+        <el-option label="课程流量费" value="2" />
+        <el-option label="直播流量费" value="3" />
+        <el-option label="AI TOKEN" value="4" />
+        <el-option label="短信发送" value="5" />
+        <el-option label="手拨外呼" value="6" />
+        <el-option label="AI智能外呼" value="7" />
+        <el-option label="微助手" value="8" />
+        <el-option label="账户费" value="9" />
+      </el-select>
+      <el-select v-model="searchForm.status" placeholder="状态">
+        <el-option label="全部" value="" />
+        <el-option label="待处理" value="0" />
+        <el-option label="已完成" value="1" />
+        <el-option label="失败" value="2" />
+      </el-select>
+      <el-date-picker v-model="searchForm.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+    </div>
+    
+    <div class="summary-card">
+      <div class="summary-item">
+        <span class="summary-label">总充值金额</span>
+        <span class="summary-value text-green">¥{{ summary.totalRecharge.toFixed(2) }}</span>
+      </div>
+      <div class="summary-item">
+        <span class="summary-label">总消费金额</span>
+        <span class="summary-value text-red">¥{{ summary.totalConsume.toFixed(2) }}</span>
+      </div>
+      <div class="summary-item">
+        <span class="summary-label">净余额</span>
+        <span class="summary-value">¥{{ summary.netBalance.toFixed(2) }}</span>
+      </div>
+    </div>
+    
+    <el-table :data="recordList" border v-loading="loading">
+      <el-table-column prop="tenantName" label="租户名称" />
+      <el-table-column prop="consumeTypeName" label="服务类型" />
+      <el-table-column prop="amount" label="金额">
+        <template slot-scope="scope">
+          <span :class="scope.row.consumeType === 0 ? 'text-green' : 'text-red'">
+            {{ scope.row.consumeType === 0 ? '+' : '-' }}¥{{ scope.row.amount.toFixed(2) }}
+          </span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="unitPrice" label="单价">
+        <template slot-scope="scope">
+          ¥{{ scope.row.unitPrice ? scope.row.unitPrice.toFixed(4) : '--' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="quantity" label="数量" />
+      <el-table-column prop="beforeBalance" label="消费前余额">
+        <template slot-scope="scope">
+          ¥{{ scope.row.beforeBalance ? scope.row.beforeBalance.toFixed(2) : '--' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="afterBalance" label="消费后余额">
+        <template slot-scope="scope">
+          ¥{{ scope.row.afterBalance ? scope.row.afterBalance.toFixed(2) : '--' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="orderNo" label="流水号" />
+      <el-table-column prop="status" label="状态">
+        <template slot-scope="scope">
+          <el-tag :type="getStatusType(scope.row.status)">
+            {{ getStatusText(scope.row.status) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="remark" label="备注" />
+      <el-table-column prop="consumeTime" label="消费时间" />
+    </el-table>
+    <el-pagination
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+      :current-page="pageInfo.pageNum"
+      :page-size="pageInfo.pageSize"
+      :total="pageInfo.total"
+      layout="total, sizes, prev, pager, next, jumper"
+    />
+  </div>
+</template>
+
+<script>
+import { getConsumeRecords } from '@/api/balance'
+
+export default {
+  name: 'BalanceFlow',
+  data() {
+    return {
+      searchForm: {
+        keyword: '',
+        serviceType: '',
+        status: '',
+        dateRange: []
+      },
+      recordList: [],
+      loading: false,
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      },
+      summary: {
+        totalRecharge: 0,
+        totalConsume: 0,
+        netBalance: 0
+      }
+    }
+  },
+  mounted() {
+    this.loadRecordList()
+  },
+  methods: {
+    formatDate(date) {
+      const d = new Date(date)
+      const pad = n => n.toString().padStart(2, '0')
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+    },
+    async loadRecordList() {
+      this.loading = true
+      try {
+        const params = {
+          tenantId: this.searchForm.keyword,
+          serviceType: this.searchForm.serviceType,
+          status: this.searchForm.status,
+          startTime: this.searchForm.dateRange[0] ? this.formatDate(this.searchForm.dateRange[0]) : '',
+          endTime: this.searchForm.dateRange[1] ? this.formatDate(this.searchForm.dateRange[1]) : '',
+          pageNum: this.pageInfo.pageNum,
+          pageSize: this.pageInfo.pageSize
+        }
+        const response = await getConsumeRecords(params)
+        // 兼容多种后端返回格式:{rows,total} 或 {data:{list,total}} 或 {data:[...]}
+        const list = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+        const total = response.total ?? response.data?.total ?? 0
+        this.recordList = list
+        this.pageInfo.total = total
+        this.calculateSummary()
+      } finally {
+        this.loading = false
+      }
+    },
+    calculateSummary() {
+      this.summary.totalRecharge = this.recordList
+        .filter(r => r.consumeType === 0)
+        .reduce((sum, r) => sum + (r.amount || 0), 0)
+      this.summary.totalConsume = this.recordList
+        .filter(r => r.consumeType !== 0)
+        .reduce((sum, r) => sum + (r.amount || 0), 0)
+      this.summary.netBalance = this.summary.totalRecharge - this.summary.totalConsume
+    },
+    getStatusType(status) {
+      switch (status) {
+        case 0: return 'warning'
+        case 1: return 'success'
+        case 2: return 'danger'
+        default: return 'info'
+      }
+    },
+    getStatusText(status) {
+      switch (status) {
+        case 0: return '待处理'
+        case 1: return '已完成'
+        case 2: return '失败'
+        default: return '未知'
+      }
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadRecordList()
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadRecordList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadRecordList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.balance-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+
+  .el-select {
+    width: 150px;
+    margin-right: 10px;
+  }
+
+  .el-date-picker {
+    margin-right: 10px;
+  }
+}
+
+.summary-card {
+  display: flex;
+  background: #fff;
+  border-radius: 8px;
+  padding: 20px;
+  margin-bottom: 20px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+
+  .summary-item {
+    flex: 1;
+    text-align: center;
+
+    &:not(:last-child) {
+      border-right: 1px solid #eee;
+    }
+
+    .summary-label {
+      display: block;
+      font-size: 14px;
+      color: #999;
+      margin-bottom: 8px;
+    }
+
+    .summary-value {
+      font-size: 24px;
+      font-weight: bold;
+      color: #333;
+    }
+  }
+}
+
+.text-green {
+  color: #67c23a;
+}
+
+.text-red {
+  color: #f56c6c;
+}
+</style>

+ 282 - 0
src/views/balance/recharge.vue

@@ -0,0 +1,282 @@
+<template>
+  <div class="recharge-container">
+    <el-tabs v-model="activeTab" type="card">
+      <el-tab-pane label="充值到总账户" name="recharge">
+        <div class="form-card">
+          <el-form :model="rechargeForm" label-width="120px" ref="rechargeForm">
+            <el-form-item label="租户ID" prop="tenantId">
+              <el-input v-model="rechargeForm.tenantId" type="number" placeholder="请输入租户ID" />
+            </el-form-item>
+            <el-form-item label="租户名称" prop="tenantName">
+              <el-input v-model="rechargeForm.tenantName" placeholder="请输入租户名称" />
+            </el-form-item>
+            <el-form-item label="充值金额" prop="amount">
+              <el-input v-model="rechargeForm.amount" type="number" placeholder="请输入充值金额" />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="handleRecharge">确认充值</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
+      
+      <el-tab-pane label="购买预存服务" name="purchase">
+        <div class="form-card">
+          <el-form :model="purchaseForm" label-width="120px" ref="purchaseForm">
+            <el-form-item label="租户ID" prop="tenantId">
+              <el-input v-model="purchaseForm.tenantId" type="number" placeholder="请输入租户ID" />
+            </el-form-item>
+            <el-form-item label="服务类型" prop="serviceType">
+              <el-select v-model="purchaseForm.serviceType" placeholder="请选择服务类型">
+                <el-option label="AI模型训练" :value="1" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="购买数量" prop="quantity">
+              <el-input v-model="purchaseForm.quantity" type="number" placeholder="请输入购买数量" />
+            </el-form-item>
+            <el-form-item label="预计费用">
+              <span class="fee-display">¥{{ estimatedFee.toFixed(2) }}</span>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="handlePurchase">确认购买</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
+      
+      <el-tab-pane label="服务余额转回" name="transfer">
+        <div class="form-card">
+          <el-form :model="transferForm" label-width="120px" ref="transferForm">
+            <el-form-item label="租户ID" prop="tenantId">
+              <el-input v-model="transferForm.tenantId" type="number" placeholder="请输入租户ID" />
+            </el-form-item>
+            <el-form-item label="服务类型" prop="serviceType">
+              <el-select v-model="transferForm.serviceType" placeholder="请选择服务类型">
+                <el-option label="AI模型训练" :value="1" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="转回数量" prop="quantity">
+              <el-input v-model="transferForm.quantity" type="number" placeholder="请输入转回数量" />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="handleTransfer">确认转回</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
+      
+      <el-tab-pane label="充值记录" name="records">
+        <div class="search-bar">
+          <el-input v-model="searchForm.keyword" placeholder="租户名称/订单号" class="search-input" />
+          <el-date-picker v-model="searchForm.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
+        </div>
+        <el-table :data="rechargeList" border>
+          <el-table-column prop="tenantName" label="租户名称" />
+          <el-table-column prop="amount" label="充值金额">
+            <template slot-scope="scope">
+              <span class="text-green">+¥{{ scope.row.amount }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="orderNo" label="订单号" />
+          <el-table-column prop="status" label="状态">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.status === 1 ? 'success' : 'warning'">
+                {{ scope.row.status === 1 ? '成功' : '处理中' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="createTime" label="充值时间" />
+        </el-table>
+        <el-pagination
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+          :current-page="pageInfo.pageNum"
+          :page-size="pageInfo.pageSize"
+          :total="pageInfo.total"
+          layout="total, sizes, prev, pager, next, jumper"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import { rechargeToTotal, purchaseService, transferToTotal, getConsumeRecords, getFeeConfigs } from '@/api/balance'
+
+export default {
+  name: 'BalanceRecharge',
+  data() {
+    return {
+      activeTab: 'recharge',
+      rechargeForm: {
+        tenantId: '',
+        tenantName: '',
+        amount: ''
+      },
+      purchaseForm: {
+        tenantId: '',
+        serviceType: '',
+        quantity: ''
+      },
+      transferForm: {
+        tenantId: '',
+        serviceType: '',
+        quantity: ''
+      },
+      searchForm: {
+        keyword: '',
+        dateRange: []
+      },
+      rechargeList: [],
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      },
+      feeConfigs: [],
+      estimatedFee: 0
+    }
+  },
+  mounted() {
+    this.loadFeeConfigs()
+    this.loadRechargeList()
+  },
+  watch: {
+    'purchaseForm.serviceType': function(val) {
+      this.calculateEstimatedFee()
+    },
+    'purchaseForm.quantity': function(val) {
+      this.calculateEstimatedFee()
+    }
+  },
+  methods: {
+    async loadFeeConfigs() {
+      const response = await getFeeConfigs()
+      this.feeConfigs = response.data || []
+    },
+    calculateEstimatedFee() {
+      const config = this.feeConfigs.find(c => c.serviceType === this.purchaseForm.serviceType)
+      if (config && this.purchaseForm.quantity) {
+        this.estimatedFee = config.feeStandard * this.purchaseForm.quantity
+      } else {
+        this.estimatedFee = 0
+      }
+    },
+    async handleRecharge() {
+      try {
+        await rechargeToTotal({
+          tenantId: this.rechargeForm.tenantId,
+          amount: this.rechargeForm.amount
+        })
+        this.$message.success('充值成功')
+        this.rechargeForm = { tenantId: '', tenantName: '', amount: '' }
+        this.activeTab = 'records'
+        this.loadRechargeList()
+      } catch (error) {
+        this.$message.error('充值失败: ' + (error.response?.data?.msg || error.message))
+      }
+    },
+    async handlePurchase() {
+      try {
+        await purchaseService({
+          tenantId: this.purchaseForm.tenantId,
+          serviceType: this.purchaseForm.serviceType,
+          quantity: this.purchaseForm.quantity
+        })
+        this.$message.success('购买成功')
+        this.purchaseForm = { tenantId: '', serviceType: '', quantity: '' }
+        this.estimatedFee = 0
+        this.activeTab = 'records'
+        this.loadRechargeList()
+      } catch (error) {
+        this.$message.error('购买失败: ' + (error.response?.data?.msg || error.message))
+      }
+    },
+    async handleTransfer() {
+      try {
+        await transferToTotal({
+          tenantId: this.transferForm.tenantId,
+          serviceType: this.transferForm.serviceType,
+          quantity: this.transferForm.quantity
+        })
+        this.$message.success('转回成功')
+        this.transferForm = { tenantId: '', serviceType: '', quantity: '' }
+        this.activeTab = 'records'
+        this.loadRechargeList()
+      } catch (error) {
+        this.$message.error('转回失败: ' + (error.response?.data?.msg || error.message))
+      }
+    },
+    formatDate(date) {
+      const d = new Date(date)
+      const pad = n => n.toString().padStart(2, '0')
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+    },
+    async loadRechargeList() {
+      const params = {
+        serviceType: 0,
+        startTime: this.searchForm.dateRange[0] ? this.formatDate(this.searchForm.dateRange[0]) : '',
+        endTime: this.searchForm.dateRange[1] ? this.formatDate(this.searchForm.dateRange[1]) : '',
+        pageNum: this.pageInfo.pageNum,
+        pageSize: this.pageInfo.pageSize
+      }
+      const response = await getConsumeRecords(params)
+      this.rechargeList = response.data.list || []
+      this.pageInfo.total = response.data.total || 0
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadRechargeList()
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadRechargeList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadRechargeList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.recharge-container {
+  padding: 20px;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 30px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+  max-width: 500px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+
+  .el-date-picker {
+    margin-right: 10px;
+  }
+}
+
+.fee-display {
+  font-size: 18px;
+  font-weight: bold;
+  color: #f56c6c;
+}
+
+.text-green {
+  color: #67c23a;
+}
+</style>

+ 236 - 0
src/views/dashboard/index.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="dashboard-container">
+    <el-row :gutter="20">
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-icon tenant-icon">
+            <i class="el-icon-user" />
+          </div>
+          <div class="stat-info">
+            <div class="stat-value">{{ dashboardData.tenantCount }}</div>
+            <div class="stat-label">下属租户数</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-icon profit-icon">
+            <i class="el-icon-money" />
+          </div>
+          <div class="stat-info">
+            <div class="stat-value">¥{{ dashboardData.monthProfit }}</div>
+            <div class="stat-label">本月分账</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-icon withdraw-icon">
+            <i class="el-icon-wallet" />
+          </div>
+          <div class="stat-info">
+            <div class="stat-value">{{ dashboardData.pendingWithdraw }}</div>
+            <div class="stat-label">待审核提现</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card class="stat-card">
+          <div class="stat-icon active-icon">
+            <i class="el-icon-trending-up" />
+          </div>
+          <div class="stat-info">
+            <div class="stat-value">{{ dashboardData.activeTenant }}</div>
+            <div class="stat-label">活跃租户</div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row :gutter="20" style="margin-top: 20px;">
+      <el-col :span="12">
+        <el-card title="租户业绩排行" class="chart-card">
+          <div class="ranking-list">
+            <div v-for="(item, index) in topTenants" :key="item.tenantId" class="ranking-item">
+              <span class="rank" :class="'rank-' + (index + 1)">{{ index + 1 }}</span>
+              <span class="name">{{ item.tenantName }}</span>
+              <span class="amount">¥{{ item.amount }}</span>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card title="本月消费趋势" class="chart-card">
+          <div id="consumeChart" style="height: 250px;" />
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getDashboardData } from '@/api/dashboard'
+import echarts from 'echarts'
+
+export default {
+  name: 'Dashboard',
+  data() {
+    return {
+      dashboardData: {
+        tenantCount: 0,
+        monthProfit: 0,
+        pendingWithdraw: 0,
+        activeTenant: 0
+      },
+      topTenants: []
+    }
+  },
+  mounted() {
+    this.loadDashboardData()
+  },
+  methods: {
+    async loadDashboardData() {
+      try {
+        const response = await getDashboardData()
+        const d = response.data
+        this.dashboardData = d
+        this.topTenants = d.topTenants || []
+        this.renderChart(d.trendData || [])
+      } catch (error) {
+        console.error('加载数据失败', error)
+      }
+    },
+    renderChart(trendData) {
+      const el = document.getElementById('consumeChart')
+      if (!el) return
+      const chart = echarts.init(el)
+      const dates = trendData.map(t => t.date || t.statDate || '')
+      const values = trendData.map(t => t.amount || t.consumeAmount || 0)
+      chart.setOption({
+        tooltip: { trigger: 'axis' },
+        xAxis: { type: 'category', data: dates },
+        yAxis: { type: 'value' },
+        series: [{
+          name: '消费金额',
+          data: values,
+          type: 'line',
+          smooth: true,
+          areaStyle: { color: 'rgba(52,144,220,0.2)' },
+          lineStyle: { color: '#3490dc' }
+        }]
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dashboard-container {
+  padding: 20px;
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  padding: 20px;
+  border-radius: 12px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+}
+
+.stat-icon {
+  width: 60px;
+  height: 60px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20px;
+  font-size: 24px;
+  color: #fff;
+
+  &.tenant-icon {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  }
+
+  &.profit-icon {
+    background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+  }
+
+  &.withdraw-icon {
+    background: linear-gradient(135deg, #fc4a1a 0%, #f7b733 100%);
+  }
+
+  &.active-icon {
+    background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+  }
+}
+
+.stat-info {
+  .stat-value {
+    font-size: 24px;
+    font-weight: bold;
+    color: #333;
+  }
+
+  .stat-label {
+    font-size: 14px;
+    color: #999;
+    margin-top: 5px;
+  }
+}
+
+.chart-card {
+  height: 320px;
+}
+
+.ranking-list {
+  .ranking-item {
+    display: flex;
+    align-items: center;
+    padding: 10px 0;
+    border-bottom: 1px solid #f0f0f0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .rank {
+      width: 24px;
+      height: 24px;
+      border-radius: 50%;
+      background: #eee;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 12px;
+      margin-right: 10px;
+
+      &.rank-1 {
+        background: #ffd700;
+        color: #fff;
+      }
+
+      &.rank-2 {
+        background: #c0c0c0;
+        color: #fff;
+      }
+
+      &.rank-3 {
+        background: #cd7f32;
+        color: #fff;
+      }
+    }
+
+    .name {
+      flex: 1;
+      font-size: 14px;
+      color: #333;
+    }
+
+    .amount {
+      font-size: 14px;
+      font-weight: bold;
+      color: #3490dc;
+    }
+  }
+}
+</style>

+ 192 - 0
src/views/fee/consumeReport/index.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="app-container">
+    <!-- ===== KPI 概览卡片 ===== -->
+    <el-row :gutter="16" class="mb16">
+      <el-col :span="6" v-for="card in overviewCards" :key="card.label">
+        <el-card shadow="hover" class="overview-card">
+          <div class="card-inner">
+            <div class="card-icon" :style="{ background: card.bg }"><i :class="card.icon"></i></div>
+            <div class="card-info">
+              <span class="card-value">{{ card.value }}</span>
+              <span class="card-label">{{ card.label }}</span>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- ===== 搜索栏 ===== -->
+    <el-card shadow="never" class="mb16 filter-card">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
+        <el-form-item label="消费时间">
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
+            start-placeholder="开始日期" end-placeholder="结束日期"
+            value-format="yyyy-MM-dd" style="width:240px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
+          <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- ===== 模块消费交叉汇总表 ===== -->
+    <el-table border v-loading="loading" :data="tenantList" size="small" style="width:100%" show-summary :summary-method="getSummary">
+      <el-table-column type="index" label="序号" width="55" align="center" fixed="left" />
+      <el-table-column label="租户名称" align="center" prop="tenantName" min-width="120" fixed="left" />
+      <el-table-column v-for="mod in moduleColumns" :key="mod.code" :label="mod.name" align="center" :min-width="mod.minWidth || 90">
+        <template slot-scope="scope">
+          <span style="color:#1890ff">¥{{ getModuleAmount(scope.row, mod.code) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="消费总额" align="center" prop="totalAmount" width="120" fixed="right">
+        <template slot-scope="scope">
+          <span style="color:#f5222d;font-weight:bold">¥{{ scope.row.totalAmount || '0.00' }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- ===== 模块消费汇总表 ===== -->
+    <el-card shadow="never" class="mt16">
+      <div slot="header"><span>各模块消费汇总</span></div>
+      <el-table border :data="moduleBreakdown" size="small" style="width:100%">
+        <el-table-column type="index" label="序号" width="55" align="center" />
+        <el-table-column label="模块" align="center" prop="moduleName" min-width="120" />
+        <el-table-column label="消费笔数" align="center" prop="count" width="100" />
+        <el-table-column label="消费金额" align="center" prop="totalAmount" width="140">
+          <template slot-scope="scope">
+            <span style="color:#1890ff;font-weight:bold">¥{{ scope.row.totalAmount || '0.00' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="占比" align="center" width="100">
+          <template slot-scope="scope">
+            <el-progress :percentage="getPercent(scope.row.totalAmount)" :stroke-width="16" :show-text="true" />
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getConsumptionReport } from '@/api/consumeReport'
+
+export default {
+  name: 'AgentConsumeReport',
+  data() {
+    return {
+      loading: false,
+      dateRange: [],
+      moduleColumns: [],
+      moduleBreakdown: [],
+      tenantList: [],
+      moduleTotalMap: {},
+      queryParams: {
+        beginTime: null,
+        endTime: null
+      },
+      overviewCards: [
+        { label: '消费总额', value: '¥0.00', icon: 'el-icon-wallet', bg: '#e6f7ff' },
+        { label: '消费笔数', value: 0, icon: 'el-icon-document', bg: '#fff7e6' },
+        { label: '涉及租户数', value: 0, icon: 'el-icon-office-building', bg: '#f6ffed' },
+        { label: '涉及模块数', value: 0, icon: 'el-icon-data-line', bg: '#f9f0ff' }
+      ]
+    }
+  },
+  created() {
+    const now = new Date()
+    const y = now.getFullYear()
+    const m = String(now.getMonth() + 1).padStart(2, '0')
+    this.dateRange = [y + '-' + m + '-01', y + '-' + m + '-' + String(new Date(y, now.getMonth() + 1, 0).getDate()).padStart(2, '0')]
+    this.getReport()
+  },
+  methods: {
+    getReport() {
+      this.loading = true
+      if (this.dateRange && this.dateRange.length === 2) {
+        this.queryParams.beginTime = this.dateRange[0]
+        this.queryParams.endTime = this.dateRange[1]
+      } else {
+        this.queryParams.beginTime = null
+        this.queryParams.endTime = null
+      }
+      getConsumptionReport(this.queryParams).then(res => {
+        const data = res.data || {}
+        this.buildModuleColumns(data.moduleBreakdown || [])
+        this.moduleBreakdown = data.moduleBreakdown || []
+        this.tenantList = data.tenantBreakdown || []
+        this.updateOverview(data.summary || {})
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    buildModuleColumns(modules) {
+      this.moduleColumns = modules.map(m => ({
+        code: m.moduleCode,
+        name: m.moduleName,
+        minWidth: m.moduleName.length > 4 ? 90 : 80
+      }))
+      this.moduleTotalMap = {}
+      modules.forEach(m => {
+        this.moduleTotalMap[m.moduleCode] = parseFloat(m.totalAmount) || 0
+      })
+    },
+    getModuleAmount(row, moduleCode) {
+      const modules = row.modules || {}
+      const val = modules[moduleCode]
+      return val != null ? parseFloat(val).toFixed(2) : '0.00'
+    },
+    getPercent(amount) {
+      const val = parseFloat(amount) || 0
+      const total = parseFloat((this.overviewCards[0].value || '¥0').replace('¥', '')) || 1
+      return Math.round(val / total * 100)
+    },
+    updateOverview(summary) {
+      this.overviewCards[0].value = '¥' + (parseFloat(summary.totalAmount) || 0).toFixed(2)
+      this.overviewCards[1].value = summary.totalCount || 0
+      this.overviewCards[2].value = summary.tenantCount || 0
+      this.overviewCards[3].value = summary.moduleCount || 0
+    },
+    getSummary({ columns, data }) {
+      const sums = []
+      columns.forEach((col, idx) => {
+        if (idx === 0) { sums[idx] = '合计'; return }
+        if (idx === 1) { sums[idx] = ''; return }
+        const prop = col.property
+        if (prop === 'totalAmount') {
+          const val = data.reduce((s, r) => s + (parseFloat(r[prop]) || 0), 0)
+          sums[idx] = '¥' + val.toFixed(2)
+        } else {
+          sums[idx] = ''
+        }
+      })
+      this.moduleColumns.forEach((mod, mi) => {
+        const colIdx = mi + 2
+        const val = data.reduce((s, r) => s + parseFloat(this.getModuleAmount(r, mod.code)), 0)
+        sums[colIdx] = '¥' + val.toFixed(2)
+      })
+      return sums
+    },
+    handleQuery() { this.getReport() },
+    resetQuery() {
+      this.dateRange = []
+      this.resetForm('queryForm')
+      this.handleQuery()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.overview-card { cursor: default; }
+.card-inner { display: flex; align-items: center; }
+.card-icon {
+  width: 46px; height: 46px; border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  margin-right: 14px; font-size: 22px; color: #fff;
+}
+.card-value { font-size: 22px; font-weight: bold; color: #333; display: block; }
+.card-label { font-size: 12px; color: #999; }
+.filter-card { padding-bottom: 0; }
+.mb16 { margin-bottom: 16px; }
+.mt16 { margin-top: 16px; }
+</style>

+ 208 - 0
src/views/fee/index.vue

@@ -0,0 +1,208 @@
+<template>
+  <div class="fee-container">
+    <el-card title="服务模块收费配置">
+      <el-button type="primary" @click="showEditModal = true" style="margin-bottom: 20px;">编辑配置</el-button>
+      
+      <el-table :data="feeConfigs" border>
+        <el-table-column prop="serviceTypeName" label="服务类型" width="150" />
+        <el-table-column prop="feeStandard" label="收费标准" width="120">
+          <template slot-scope="scope">
+            ¥{{ scope.row.feeStandard.toFixed(4) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="feeUnit" label="收费单位" width="120" />
+        <el-table-column prop="description" label="描述" />
+        <el-table-column prop="enabled" label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.enabled === 1 ? 'success' : 'danger'">
+              {{ scope.row.enabled === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="billingType" label="计费方式" width="120">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.billingType === 'prepaid' ? 'warning' : 'info'">
+              {{ scope.row.billingType === 'prepaid' ? '预存模式' : '按量扣除' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+    
+    <el-card title="费用说明" style="margin-top: 20px;">
+      <div class="fee-description">
+        <h4>计费说明:</h4>
+        <div class="billing-info">
+          <div class="billing-section">
+            <h5>预存模式(需先购买)</h5>
+            <ul>
+              <li><strong>AI模型训练</strong>:训练专属AI销售、AI客服模型,2000元/角色</li>
+            </ul>
+          </div>
+          <div class="billing-section">
+            <h5>按量扣除(直接从总余额扣)</h5>
+            <ul>
+              <li><strong>课程流量费</strong>:租户用户观看课程消耗的流量,0.08元/GB</li>
+              <li><strong>直播流量费</strong>:租户直播消耗的流量,0.08元/GB</li>
+              <li><strong>AI Token费</strong>:SOP自动发送和AI聊天消耗的Token,1元/10万</li>
+              <li><strong>短信发送费</strong>:营销短信、挂机短信、订单提醒等,0.05元/条</li>
+              <li><strong>手拨外呼费</strong>:人工手拨外呼,0.15元/分钟</li>
+              <li><strong>AI智能外呼费</strong>:AI机器外呼,0.2元/分钟</li>
+              <li><strong>微助手费</strong>:自动加微功能,1元/个</li>
+            </ul>
+          </div>
+          <div class="billing-section">
+            <h5>定期扣费</h5>
+            <ul>
+              <li><strong>账户费</strong>:每月自动扣除,50元/月/账户</li>
+            </ul>
+          </div>
+        </div>
+        <h4 style="margin-top: 20px;">充值规则:</h4>
+        <ul>
+          <li>租户采用预存款方式充值到总账户</li>
+          <li>账户费每月自动从总余额扣除</li>
+          <li>预存服务(AI模型训练)需先购买后使用</li>
+          <li>按量服务直接从总余额按实际使用量扣除</li>
+          <li>预存服务未使用部分可转回总余额</li>
+        </ul>
+      </div>
+    </el-card>
+    
+    <!-- 编辑弹窗 -->
+    <el-dialog title="编辑收费配置" :visible.sync="showEditModal" width="500px">
+      <el-form :model="editForm" label-width="120px">
+        <el-form-item label="服务类型">
+          <el-select v-model="editForm.serviceType" placeholder="请选择服务类型">
+            <el-option v-for="config in feeConfigs" :key="config.serviceType" :label="config.serviceTypeName" :value="config.serviceType" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="收费标准">
+          <el-input v-model="editForm.feeStandard" type="number" placeholder="请输入收费标准" />
+        </el-form-item>
+        <el-form-item label="收费单位">
+          <el-input v-model="editForm.feeUnit" placeholder="请输入收费单位" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="editForm.description" placeholder="请输入描述" />
+        </el-form-item>
+        <el-form-item label="是否启用">
+          <el-switch v-model="editForm.enabled" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="showEditModal = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getFeeConfigs, updateFeeConfig } from '@/api/balance'
+
+export default {
+  name: 'FeeConfig',
+  data() {
+    return {
+      feeConfigs: [],
+      showEditModal: false,
+      editForm: {
+        serviceType: '',
+        feeStandard: '',
+        feeUnit: '',
+        description: '',
+        enabled: true
+      }
+    }
+  },
+  mounted() {
+    this.loadFeeConfigs()
+  },
+  methods: {
+    async loadFeeConfigs() {
+      const response = await getFeeConfigs()
+      // 兼容多种后端返回格式:{rows} 或 {data:{list}} 或 {data:[...]}
+      const configs = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+      this.feeConfigs = configs.map(config => ({
+        ...config,
+        billingType: this.getBillingType(config.serviceType)
+      }))
+    },
+    getBillingType(serviceType) {
+      // 1-AI模型训练:预存模式
+      // 2-8:按量扣除
+      // 9-账户费:定期扣费(在说明中展示)
+      if (serviceType === 1) return 'prepaid'
+      if (serviceType >= 2 && serviceType <= 8) return 'payAsYouGo'
+      return 'regular'
+    },
+    async handleSave() {
+      try {
+        await updateFeeConfig(this.editForm)
+        this.$message.success('配置更新成功')
+        this.showEditModal = false
+        this.loadFeeConfigs()
+      } catch (error) {
+        this.$message.error('更新失败: ' + (error.response?.data?.msg || error.message))
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.fee-container {
+  padding: 20px;
+}
+
+.fee-description {
+  color: #666;
+  font-size: 14px;
+
+  h4 {
+    color: #333;
+    margin-bottom: 10px;
+  }
+
+  h5 {
+    color: #666;
+    margin-bottom: 8px;
+    font-size: 14px;
+  }
+
+  ul {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+
+    li {
+      padding: 5px 0;
+      border-bottom: 1px dashed #eee;
+
+      &:last-child {
+        border-bottom: none;
+      }
+    }
+  }
+}
+
+.billing-info {
+  display: flex;
+  gap: 20px;
+  flex-wrap: wrap;
+
+  .billing-section {
+    flex: 1;
+    min-width: 300px;
+    padding: 15px;
+    background: #fafafa;
+    border-radius: 6px;
+
+    ul li {
+      border-bottom: none;
+      padding: 4px 0;
+    }
+  }
+}
+</style>

+ 126 - 0
src/views/fee/moduleUsage/index.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="app-container">
+    <el-row :gutter="16" class="mb16">
+      <el-col :span="6" v-for="card in overviewCards" :key="card.label">
+        <el-card shadow="hover" class="overview-card">
+          <div class="card-inner">
+            <div class="card-icon" :style="{ background: card.bg }"><i :class="card.icon"></i></div>
+            <div class="card-info">
+              <span class="card-value">{{ card.value }}</span>
+              <span class="card-label">{{ card.label }}</span>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never" class="mb16 filter-card">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
+        <el-form-item label="租户名称" prop="tenantName">
+          <el-input v-model="queryParams.tenantName" placeholder="请输入租户名称" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
+          <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-table border v-loading="loading" :data="list" size="small" style="width:100%" show-summary :summary-method="getSummary">
+      <el-table-column label="租户名称" align="center" prop="tenantName" min-width="120" fixed="left" />
+      <el-table-column label="外呼次数" align="center" prop="outboundCallCount" min-width="75" />
+      <el-table-column label="语音分钟" align="center" prop="voiceCallMinutes" min-width="75" />
+      <el-table-column label="短信量" align="center" prop="smsSentCount" min-width="65" />
+      <el-table-column label="流量(GB)" align="center" prop="flowUsageGB" min-width="75" />
+      <el-table-column label="个微用户" align="center" prop="wxUserCount" min-width="75" />
+      <el-table-column label="CID加个微" align="center" prop="cidAddWxCount" min-width="80" />
+      <el-table-column label="个微绑定" align="center" prop="wxAccountCount" min-width="75" />
+      <el-table-column label="企微用户" align="center" prop="qwUserCount" min-width="75" />
+      <el-table-column label="CID加企微" align="center" prop="cidAddQwCount" min-width="80" />
+      <el-table-column label="企微绑定" align="center" prop="qwAccountCount" min-width="75" />
+      <el-table-column label="SOP" align="center" prop="sopCount" min-width="55" />
+      <el-table-column label="课程" align="center" prop="courseCount" min-width="55" />
+      <el-table-column label="直播" align="center" prop="liveCount" min-width="55" />
+      <el-table-column label="商品" align="center" prop="productCount" min-width="55" />
+      <el-table-column label="工作流" align="center" prop="workflowCount" min-width="65" />
+      <el-table-column label="销售账户" align="center" prop="salesAccountCount" min-width="75" />
+      <el-table-column label="AI Token" align="center" prop="aiTokenUsed" min-width="90" />
+      <el-table-column label="员工" align="center" prop="employeeCount" min-width="55" />
+      <el-table-column label="活跃模块" align="center" prop="activeModuleCount" min-width="75">
+        <template slot-scope="scope">
+          <el-tag type="success" size="mini">{{ scope.row.activeModuleCount || 0 }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="消费总额" align="center" prop="totalConsumeAmount" width="110" fixed="right">
+        <template slot-scope="scope">
+          <span style="color:#1890ff;font-weight:bold">¥{{ scope.row.totalConsumeAmount || '0.00' }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'AgentModuleUsage',
+  data() {
+    return {
+      loading: false, list: [], total: 0,
+      queryParams: { pageNum: 1, pageSize: 10, tenantName: null },
+      overviewCards: [
+        { label: '租户总数', value: 0, icon: 'el-icon-office-building', bg: '#e6f7ff' },
+        { label: '活跃模块总数', value: 0, icon: 'el-icon-data-line', bg: '#f6ffed' },
+        { label: '消费总额', value: '¥0.00', icon: 'el-icon-wallet', bg: '#fff7e6' },
+        { label: 'AI调用总量', value: 0, icon: 'el-icon-cpu', bg: '#f9f0ff' }
+      ]
+    }
+  },
+  created() { this.getList() },
+  methods: {
+    getList() {
+      this.loading = true
+      request({ url: '/proxy/module-usage/list', method: 'get', params: this.queryParams }).then(res => {
+        this.list = res.rows || []
+        this.total = res.total || 0
+        this.updateOverview(this.list)
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    updateOverview(list) {
+      this.overviewCards[0].value = this.total
+      this.overviewCards[1].value = list.reduce((s, r) => s + (r.activeModuleCount || 0), 0)
+      this.overviewCards[2].value = '¥' + list.reduce((s, r) => s + (parseFloat(r.totalConsumeAmount) || 0), 0).toFixed(2)
+      this.overviewCards[3].value = list.reduce((s, r) => s + (r.aiCallTotal || 0) + (r.aiChatTotal || 0), 0)
+    },
+    getSummary({ columns, data }) {
+      const sums = []; const numFields = ['outboundCallCount','voiceCallMinutes','smsSentCount','flowUsageGB',
+        'wxUserCount','cidAddWxCount','wxAccountCount','qwUserCount','cidAddQwCount','qwAccountCount',
+        'sopCount','courseCount','liveCount','productCount','workflowCount','salesAccountCount','aiTokenUsed','employeeCount','activeModuleCount']
+      columns.forEach((col, idx) => {
+        if (idx === 0) { sums[idx] = '合计'; return }
+        const prop = col.property
+        if (numFields.includes(prop)) { sums[idx] = data.reduce((s, r) => s + (r[prop] || 0), 0) }
+        else if (prop === 'totalConsumeAmount') { sums[idx] = '¥' + data.reduce((s, r) => s + (parseFloat(r[prop]) || 0), 0).toFixed(2) }
+        else { sums[idx] = '' }
+      })
+      return sums
+    },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.resetForm('queryForm'); this.handleQuery() }
+  }
+}
+</script>
+
+<style scoped>
+.overview-card { cursor: default; }
+.card-inner { display: flex; align-items: center; }
+.card-icon { width: 46px; height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 14px; font-size: 22px; color: #fff; }
+.card-value { font-size: 22px; font-weight: bold; color: #333; display: block; }
+.card-label { font-size: 12px; color: #999; }
+.filter-card { padding-bottom: 0; }
+.mb16 { margin-bottom: 16px; }
+</style>

+ 155 - 0
src/views/login/index.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="login-container">
+    <div class="login-box">
+      <div class="login-header">
+        <h2>代理端管理系统</h2>
+        <p>请登录您的账户</p>
+      </div>
+      <el-form ref="loginForm" :model="loginForm" :rules="loginRules" label-width="80px" class="login-form">
+        <el-form-item label="用户名" prop="username">
+          <el-input v-model="loginForm.username" placeholder="请输入用户名" />
+        </el-form-item>
+        <el-form-item label="密码" prop="password">
+          <el-input type="password" v-model="loginForm.password" placeholder="请输入密码" show-password />
+        </el-form-item>
+        <el-form-item label="验证码" prop="code" v-if="captchaOnOff">
+          <div style="display:flex;align-items:center;">
+            <el-input v-model="loginForm.code" placeholder="请输入验证码" style="flex:1" @keyup.enter.native="handleLogin" />
+            <img v-if="codeUrl" :src="codeUrl" @click="getCode" class="code-img" title="点击刷新" />
+          </div>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleLogin" class="login-btn" :loading="loading">登录</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script>
+import { login, getCodeImg } from '@/api/login'
+
+export default {
+  name: 'Login',
+  data() {
+    return {
+      loading: false,
+      codeUrl: '',
+      captchaOnOff: true,
+      loginForm: {
+        username: '',
+        password: '',
+        code: '',
+        uuid: ''
+      },
+      loginRules: {
+        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+        password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+        code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.getCode()
+  },
+  methods: {
+    getCode() {
+      getCodeImg().then(res => {
+        this.captchaOnOff = res.captchaOnOff === undefined ? true : res.captchaOnOff
+        if (this.captchaOnOff) {
+          this.codeUrl = "data:image/gif;base64," + res.img
+          this.loginForm.uuid = res.uuid
+        }
+      }).catch(() => {
+        this.codeUrl = ''
+      })
+    },
+    async handleLogin() {
+      this.$refs.loginForm.validate(async valid => {
+        if (!valid) return
+        this.loading = true
+        try {
+          const data = {
+            username: this.loginForm.username,
+            password: this.loginForm.password
+          }
+          if (this.captchaOnOff) {
+            data.code = this.loginForm.code
+            data.uuid = this.loginForm.uuid
+          }
+          const response = await login(data)
+          if (response.code === 200) {
+            this.$store.dispatch('setToken', response.token)
+            if (response.proxyInfo) {
+              this.$store.dispatch('setProxyInfo', response.proxyInfo)
+            }
+            this.$router.push('/dashboard')
+          } else {
+            this.$message.error(response.msg || '登录失败')
+            if (this.captchaOnOff) {
+              this.getCode()
+            }
+          }
+        } catch (error) {
+          this.$message.error('登录失败')
+          if (this.captchaOnOff) {
+            this.getCode()
+          }
+        } finally {
+          this.loading = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login-container {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.login-box {
+  width: 400px;
+  background: #fff;
+  border-radius: 12px;
+  padding: 40px;
+  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+}
+
+.login-header {
+  text-align: center;
+  margin-bottom: 30px;
+
+  h2 {
+    color: #333;
+    font-size: 24px;
+    margin-bottom: 10px;
+  }
+
+  p {
+    color: #999;
+    font-size: 14px;
+  }
+}
+
+.login-form {
+  .login-btn {
+    width: 100%;
+    height: 40px;
+    font-size: 16px;
+  }
+}
+
+.code-img {
+  height: 36px;
+  margin-left: 10px;
+  cursor: pointer;
+  border-radius: 4px;
+  border: 1px solid #dcdfe6;
+}
+</style>

+ 176 - 0
src/views/performance/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="performance-container">
+    <div class="search-bar">
+      <el-select v-model="searchForm.period" placeholder="统计周期">
+        <el-option label="本月" :value="'month'" />
+        <el-option label="本季度" :value="'quarter'" />
+        <el-option label="本年" :value="'year'" />
+      </el-select>
+      <el-button type="primary" @click="handleSearch">查询</el-button>
+    </div>
+    <el-card title="租户业绩排行" class="ranking-card">
+      <el-table :data="performanceList" border v-loading="loading">
+        <el-table-column label="排名">
+          <template slot-scope="scope">
+            <span class="rank" :class="'rank-' + (scope.$index + 1)">{{ scope.$index + 1 }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="tenantName" label="租户名称" />
+        <el-table-column prop="totalAmount" label="消费总额" />
+        <el-table-column prop="orderCount" label="订单数" />
+        <el-table-column prop="newUserCount" label="新增用户" />
+        <el-table-column prop="profitAmount" label="分账金额" />
+      </el-table>
+    </el-card>
+    <el-row :gutter="20" style="margin-top: 20px;">
+      <el-col :span="12">
+        <el-card title="业绩趋势">
+          <div id="trendChart" style="height: 250px;" />
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card title="消费类型分布">
+          <div id="pieChart" style="height: 250px;" />
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getPerformanceList } from '@/api/performance'
+import echarts from 'echarts'
+
+export default {
+  name: 'Performance',
+  data() {
+    return {
+      searchForm: {
+        period: 'month'
+      },
+      performanceList: [],
+      loading: false
+    }
+  },
+  mounted() {
+    this.loadPerformanceList()
+  },
+  methods: {
+    async loadPerformanceList() {
+      this.loading = true
+      try {
+        const response = await getPerformanceList(this.searchForm)
+        // 兼容多种后端返回格式:{rows,total} 或 {data:{list,total}} 或 {data:[...]}
+        this.performanceList = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+        this.$nextTick(() => {
+          this.initCharts()
+        })
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.loadPerformanceList()
+    },
+    initCharts() {
+      const trendEl = document.getElementById('trendChart')
+      const pieEl = document.getElementById('pieChart')
+      if (!trendEl || !pieEl) return
+
+      // 业绩趋势图:从API数据中提取
+      const trendChart = echarts.init(trendEl)
+      const trendData = this.performanceList.slice(0, 6)
+      trendChart.setOption({
+        tooltip: { trigger: 'axis' },
+        xAxis: { type: 'category', data: trendData.map(t => t.tenantName || '') },
+        yAxis: { type: 'value' },
+        series: [{
+          name: '消费总额',
+          data: trendData.map(t => t.totalAmount || 0),
+          type: 'bar',
+          itemStyle: { color: '#3490dc' }
+        }]
+      })
+
+      // 消费类型分布图
+      const pieChart = echarts.init(pieEl)
+      const consumeData = []
+      if (this.performanceList.length > 0) {
+        const totals = this.performanceList.reduce((acc, t) => {
+          acc.totalAmount = (acc.totalAmount || 0) + (t.totalAmount || 0)
+          acc.orderCount = (acc.orderCount || 0) + (t.orderCount || 0)
+          acc.newUserCount = (acc.newUserCount || 0) + (t.newUserCount || 0)
+          acc.profitAmount = (acc.profitAmount || 0) + (t.profitAmount || 0)
+          return acc
+        }, {})
+        consumeData.push(
+          { value: totals.totalAmount || 0, name: '消费总额' },
+          { value: totals.orderCount || 0, name: '订单数' },
+          { value: totals.newUserCount || 0, name: '新增用户' },
+          { value: totals.profitAmount || 0, name: '分账金额' }
+        )
+      } else {
+        consumeData.push(
+          { value: 35, name: '消费总额' },
+          { value: 25, name: '订单数' },
+          { value: 20, name: '新增用户' },
+          { value: 15, name: '分账金额' }
+        )
+      }
+      pieChart.setOption({
+        tooltip: { trigger: 'item' },
+        series: [{
+          type: 'pie',
+          radius: '50%',
+          data: consumeData
+        }]
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.performance-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+
+  .el-select {
+    width: 150px;
+    margin-right: 10px;
+  }
+}
+
+.ranking-card {
+  .rank {
+    display: inline-block;
+    width: 24px;
+    height: 24px;
+    border-radius: 50%;
+    background: #eee;
+    text-align: center;
+    line-height: 24px;
+    font-size: 12px;
+
+    &.rank-1 {
+      background: #ffd700;
+      color: #fff;
+    }
+
+    &.rank-2 {
+      background: #c0c0c0;
+      color: #fff;
+    }
+
+    &.rank-3 {
+      background: #cd7f32;
+      color: #fff;
+    }
+  }
+}
+</style>

+ 115 - 0
src/views/profit/index.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="profit-container">
+    <div class="search-bar">
+      <el-input v-model="searchForm.keyword" placeholder="租户名称" class="search-input" />
+      <el-select v-model="searchForm.month" placeholder="选择月份">
+        <el-option label="本月" :value="''" />
+        <el-option label="上月" :value="'last'" />
+      </el-select>
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+    </div>
+    <el-table :data="profitList" border v-loading="loading">
+      <el-table-column prop="tenantName" label="租户名称" />
+      <el-table-column prop="month" label="月份" />
+      <el-table-column prop="totalAmount" label="消费总额" />
+      <el-table-column prop="shareRatio" label="分账比例(%)" />
+      <el-table-column prop="shareAmount" label="分账金额" />
+      <el-table-column prop="status" label="状态">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.status === 1 ? 'success' : 'warning'">
+            {{ scope.row.status === 1 ? '已结算' : '待结算' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="settleTime" label="结算时间" />
+    </el-table>
+    <el-pagination
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+      :current-page="pageInfo.pageNum"
+      :page-size="pageInfo.pageSize"
+      :total="pageInfo.total"
+      layout="total, sizes, prev, pager, next, jumper"
+    />
+  </div>
+</template>
+
+<script>
+import { getProfitList } from '@/api/profit'
+
+export default {
+  name: 'Profit',
+  data() {
+    return {
+      searchForm: {
+        keyword: '',
+        month: ''
+      },
+      profitList: [],
+      loading: false,
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      }
+    }
+  },
+  mounted() {
+    this.loadProfitList()
+  },
+  methods: {
+    async loadProfitList() {
+      this.loading = true
+      try {
+        const params = {
+          ...this.searchForm,
+          pageNum: this.pageInfo.pageNum,
+          pageSize: this.pageInfo.pageSize
+        }
+        const response = await getProfitList(params)
+        // 兼容多种后端返回格式:{rows,total} 或 {data:{list,total}} 或 {data:[...]}
+        const list = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+        const total = response.total ?? response.data?.total ?? 0
+        this.profitList = list
+        this.pageInfo.total = total
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadProfitList()
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadProfitList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadProfitList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.profit-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+
+  .el-select {
+    width: 150px;
+    margin-right: 10px;
+  }
+}
+</style>

+ 158 - 0
src/views/quota/index.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="quota-container">
+    <div class="search-bar">
+      <el-input v-model="searchForm.keyword" placeholder="租户名称" class="search-input" />
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+    </div>
+    <el-table :data="quotaList" border v-loading="loading">
+      <el-table-column prop="tenantName" label="租户名称" />
+      <el-table-column prop="accountQuota" label="账户配额" />
+      <el-table-column prop="aiModelQuota" label="AI模型配额" />
+      <el-table-column prop="tokenQuota" label="Token配额" />
+      <el-table-column prop="smsQuota" label="短信配额" />
+      <el-table-column prop="callQuota" label="外呼配额(分钟)" />
+      <el-table-column prop="flowQuota" label="流量配额(GB)" />
+      <el-table-column label="操作">
+        <template slot-scope="scope">
+          <el-button size="mini" @click="handleAdjust(scope.row)">调整配额</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-pagination
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+      :current-page="pageInfo.pageNum"
+      :page-size="pageInfo.pageSize"
+      :total="pageInfo.total"
+      layout="total, sizes, prev, pager, next, jumper"
+    />
+    <el-dialog title="调整配额" :visible.sync="dialogVisible" width="600px">
+      <el-form ref="quotaForm" :model="quotaForm" label-width="120px">
+        <el-form-item label="账户配额" prop="accountQuota">
+          <el-input v-model="quotaForm.accountQuota" type="number" />
+        </el-form-item>
+        <el-form-item label="AI模型配额(角色)" prop="aiModelQuota">
+          <el-input v-model="quotaForm.aiModelQuota" type="number" />
+        </el-form-item>
+        <el-form-item label="Token配额(万)" prop="tokenQuota">
+          <el-input v-model="quotaForm.tokenQuota" type="number" />
+        </el-form-item>
+        <el-form-item label="短信配额(条)" prop="smsQuota">
+          <el-input v-model="quotaForm.smsQuota" type="number" />
+        </el-form-item>
+        <el-form-item label="外呼配额(分钟)" prop="callQuota">
+          <el-input v-model="quotaForm.callQuota" type="number" />
+        </el-form-item>
+        <el-form-item label="流量配额(GB)" prop="flowQuota">
+          <el-input v-model="quotaForm.flowQuota" type="number" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getQuotaList, updateQuota } from '@/api/quota'
+
+export default {
+  name: 'Quota',
+  data() {
+    return {
+      searchForm: {
+        keyword: ''
+      },
+      quotaList: [],
+      loading: false,
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      },
+      dialogVisible: false,
+      quotaForm: {
+        tenantId: '',
+        accountQuota: '',
+        aiModelQuota: '',
+        tokenQuota: '',
+        smsQuota: '',
+        callQuota: '',
+        flowQuota: ''
+      }
+    }
+  },
+  mounted() {
+    this.loadQuotaList()
+  },
+  methods: {
+    async loadQuotaList() {
+      this.loading = true
+      try {
+        const params = {
+          ...this.searchForm,
+          pageNum: this.pageInfo.pageNum,
+          pageSize: this.pageInfo.pageSize
+        }
+        const response = await getQuotaList(params)
+        // 兼容多种后端返回格式:{rows,total} 或 {data:{list,total}} 或 {data:[...]}
+        const list = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+        const total = response.total ?? response.data?.total ?? 0
+        this.quotaList = list
+        this.pageInfo.total = total
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadQuotaList()
+    },
+    handleAdjust(row) {
+      this.quotaForm = {
+        tenantId: row.tenantId,
+        accountQuota: row.accountQuota,
+        aiModelQuota: row.aiModelQuota,
+        tokenQuota: row.tokenQuota,
+        smsQuota: row.smsQuota,
+        callQuota: row.callQuota,
+        flowQuota: row.flowQuota
+      }
+      this.dialogVisible = true
+    },
+    async handleSubmit() {
+      await updateQuota(this.quotaForm)
+      this.dialogVisible = false
+      this.loadQuotaList()
+      this.$message.success('配额调整成功')
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadQuotaList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadQuotaList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.quota-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+}
+</style>

+ 12 - 0
src/views/redirect/index.vue

@@ -0,0 +1,12 @@
+<script>
+export default {
+  created() {
+    const { params, query } = this.$route
+    const { path } = params
+    this.$router.replace({ path: '/' + path, query })
+  },
+  render: function(h) {
+    return h() // avoid warning message
+  }
+}
+</script>

+ 174 - 0
src/views/tenant/index.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="tenant-container">
+    <div class="search-bar">
+      <el-input v-model="searchForm.keyword" placeholder="租户名称/联系电话" class="search-input" />
+      <el-select v-model="searchForm.status" placeholder="状态">
+        <el-option label="全部" :value="''" />
+        <el-option label="启用" :value="1" />
+        <el-option label="禁用" :value="0" />
+      </el-select>
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+      <el-button type="success" @click="handleAdd">新增租户</el-button>
+    </div>
+    <el-table :data="tenantList" border v-loading="loading">
+      <el-table-column prop="tenantName" label="租户名称" />
+      <el-table-column prop="contactMobile" label="联系电话" />
+      <el-table-column prop="bindTime" label="绑定时间" />
+      <el-table-column prop="profitShareRatio" label="分账比例(%)" />
+      <el-table-column prop="monthConsume" label="本月消费" />
+      <el-table-column prop="status" label="状态">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
+            {{ scope.row.status === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作">
+        <template slot-scope="scope">
+          <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+          <el-button size="mini" @click="handleToggleStatus(scope.row)">
+            {{ scope.row.status === 1 ? '禁用' : '启用' }}
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-pagination
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+      :current-page="pageInfo.pageNum"
+      :page-sizes="[10, 20, 50, 100]"
+      :page-size="pageInfo.pageSize"
+      :total="pageInfo.total"
+      layout="total, sizes, prev, pager, next, jumper"
+    />
+    <el-dialog title="新增/编辑租户" :visible.sync="dialogVisible" width="500px">
+      <el-form ref="tenantForm" :model="tenantForm" label-width="100px">
+        <el-form-item label="租户名称" prop="tenantName">
+          <el-input v-model="tenantForm.tenantName" />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="contactMobile">
+          <el-input v-model="tenantForm.contactMobile" />
+        </el-form-item>
+        <el-form-item label="分账比例(%)" prop="profitShareRatio">
+          <el-input v-model="tenantForm.profitShareRatio" type="number" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getTenantList, addTenant, updateTenant, toggleTenantStatus } from '@/api/tenant'
+
+export default {
+  name: 'Tenant',
+  data() {
+    return {
+      searchForm: {
+        keyword: '',
+        status: ''
+      },
+      tenantList: [],
+      loading: false,
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      },
+      dialogVisible: false,
+      tenantForm: {
+        tenantId: '',
+        tenantName: '',
+        contactMobile: '',
+        profitShareRatio: ''
+      }
+    }
+  },
+  mounted() {
+    this.loadTenantList()
+  },
+  methods: {
+    async loadTenantList() {
+      this.loading = true
+      try {
+        const params = {
+          ...this.searchForm,
+          pageNum: this.pageInfo.pageNum,
+          pageSize: this.pageInfo.pageSize
+        }
+        const response = await getTenantList(params)
+        this.tenantList = response.data.list
+        this.pageInfo.total = response.data.total
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadTenantList()
+    },
+    handleAdd() {
+      this.tenantForm = {
+        tenantId: '',
+        tenantName: '',
+        contactMobile: '',
+        profitShareRatio: ''
+      }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.tenantForm = { ...row }
+      this.dialogVisible = true
+    },
+    async handleSubmit() {
+      if (this.tenantForm.tenantId) {
+        await updateTenant(this.tenantForm)
+      } else {
+        await addTenant(this.tenantForm)
+      }
+      this.dialogVisible = false
+      this.loadTenantList()
+      this.$message.success('操作成功')
+    },
+    async handleToggleStatus(row) {
+      await toggleTenantStatus(row.tenantId, row.status === 1 ? 0 : 1)
+      this.loadTenantList()
+      this.$message.success('状态已更新')
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadTenantList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadTenantList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.tenant-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+
+  .el-select {
+    width: 150px;
+    margin-right: 10px;
+  }
+}
+</style>

+ 217 - 0
src/views/withdraw/index.vue

@@ -0,0 +1,217 @@
+<template>
+  <div class="withdraw-container">
+    <div class="search-bar">
+      <el-input v-model="searchForm.keyword" placeholder="租户名称" class="search-input" />
+      <el-select v-model="searchForm.status" placeholder="状态">
+        <el-option label="全部" :value="''" />
+        <el-option label="待审核" :value="0" />
+        <el-option label="通过" :value="1" />
+        <el-option label="拒绝" :value="2" />
+      </el-select>
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+      <el-button type="success" @click="handleApplyWithdraw">申请提现</el-button>
+    </div>
+    <el-table :data="withdrawList" border v-loading="loading">
+      <el-table-column prop="tenantName" label="租户名称" />
+      <el-table-column prop="amount" label="提现金额" />
+      <el-table-column prop="bankCard" label="银行卡号" />
+      <el-table-column prop="bankName" label="开户银行" />
+      <el-table-column prop="status" label="状态">
+        <template slot-scope="scope">
+          <el-tag :type="getStatusType(scope.row.status)">
+            {{ getStatusText(scope.row.status) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="applyTime" label="申请时间" />
+      <el-table-column prop="auditTime" label="审核时间" />
+      <el-table-column label="操作">
+        <template slot-scope="scope">
+          <template v-if="scope.row.status === 0">
+            <el-button size="mini" type="success" @click="handleApprove(scope.row)">通过</el-button>
+            <el-button size="mini" type="danger" @click="handleReject(scope.row)">拒绝</el-button>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-pagination
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+      :current-page="pageInfo.pageNum"
+      :page-size="pageInfo.pageSize"
+      :total="pageInfo.total"
+      layout="total, sizes, prev, pager, next, jumper"
+    />
+    <el-dialog title="拒绝理由" :visible.sync="rejectDialogVisible" width="400px">
+      <el-form>
+        <el-form-item label="拒绝理由">
+          <el-input v-model="rejectReason" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="rejectDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="confirmReject">确定</el-button>
+      </div>
+    </el-dialog>
+    <el-dialog title="申请提现" :visible.sync="applyDialogVisible" width="400px">
+      <el-form ref="applyForm" :model="applyForm" :rules="applyRules" label-width="100px">
+        <el-form-item label="提现金额" prop="amount">
+          <el-input-number v-model="applyForm.amount" :min="1" :precision="2" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="银行卡号" prop="bankCard">
+          <el-input v-model="applyForm.bankCard" placeholder="请输入银行卡号" />
+        </el-form-item>
+        <el-form-item label="开户银行" prop="bankName">
+          <el-input v-model="applyForm.bankName" placeholder="请输入开户银行" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="applyDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitApply">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getWithdrawList, addWithdraw, approveWithdraw, rejectWithdraw } from '@/api/withdraw'
+
+export default {
+  name: 'Withdraw',
+  data() {
+    return {
+      searchForm: {
+        keyword: '',
+        status: ''
+      },
+      withdrawList: [],
+      loading: false,
+      pageInfo: {
+        pageNum: 1,
+        pageSize: 10,
+        total: 0
+      },
+      rejectDialogVisible: false,
+      rejectReason: '',
+      currentWithdraw: null,
+      applyDialogVisible: false,
+      applyForm: {
+        amount: null,
+        bankCard: '',
+        bankName: ''
+      },
+      applyRules: {
+        amount: [{ required: true, message: '请输入提现金额', trigger: 'blur' }],
+        bankCard: [{ required: true, message: '请输入银行卡号', trigger: 'blur' }],
+        bankName: [{ required: true, message: '请输入开户银行', trigger: 'blur' }]
+      }
+    }
+  },
+  mounted() {
+    this.loadWithdrawList()
+  },
+  methods: {
+    async loadWithdrawList() {
+      this.loading = true
+      try {
+        const params = {
+          ...this.searchForm,
+          pageNum: this.pageInfo.pageNum,
+          pageSize: this.pageInfo.pageSize
+        }
+        const response = await getWithdrawList(params)
+        // 兼容多种后端返回格式:{rows,total} 或 {data:{list,total}} 或 {data:[...]}
+        const list = response.rows || response.data?.list || (Array.isArray(response.data) ? response.data : null) || []
+        const total = response.total ?? response.data?.total ?? 0
+        this.withdrawList = list
+        this.pageInfo.total = total
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.pageInfo.pageNum = 1
+      this.loadWithdrawList()
+    },
+    getStatusType(status) {
+      const types = {
+        0: 'warning',
+        1: 'success',
+        2: 'danger'
+      }
+      return types[status] || 'info'
+    },
+    getStatusText(status) {
+      const texts = {
+        0: '待审核',
+        1: '通过',
+        2: '拒绝'
+      }
+      return texts[status] || '未知'
+    },
+    async handleApprove(row) {
+      await approveWithdraw(row.withdrawId)
+      this.loadWithdrawList()
+      this.$message.success('审核通过')
+    },
+    handleReject(row) {
+      this.currentWithdraw = row
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    async confirmReject() {
+      if (!this.rejectReason) {
+        this.$message.error('请输入拒绝理由')
+        return
+      }
+      await rejectWithdraw(this.currentWithdraw.withdrawId, this.rejectReason)
+      this.rejectDialogVisible = false
+      this.loadWithdrawList()
+      this.$message.success('已拒绝')
+    },
+    handleApplyWithdraw() {
+      this.applyForm = { amount: null, bankCard: '', bankName: '' }
+      this.applyDialogVisible = true
+    },
+    async submitApply() {
+      this.$refs['applyForm'].validate(async valid => {
+        if (!valid) return
+        await addWithdraw(this.applyForm)
+        this.applyDialogVisible = false
+        this.loadWithdrawList()
+        this.$message.success('提现申请已提交')
+      })
+    },
+    handleSizeChange(val) {
+      this.pageInfo.pageSize = val
+      this.loadWithdrawList()
+    },
+    handleCurrentChange(val) {
+      this.pageInfo.pageNum = val
+      this.loadWithdrawList()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.withdraw-container {
+  padding: 20px;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+
+  .search-input {
+    width: 200px;
+    margin-right: 10px;
+  }
+
+  .el-select {
+    width: 150px;
+    margin-right: 10px;
+  }
+}
+</style>

+ 54 - 0
vue.config.js

@@ -0,0 +1,54 @@
+const path = require('path')
+const defaultSettings = require('./src/settings.js')
+
+function resolve(dir) {
+  return path.join(__dirname, dir)
+}
+
+const name = defaultSettings.title || '代理端管理系统'
+
+module.exports = {
+  transpileDependencies: [/@aws-sdk/, /@smithy/],
+  publicPath: '/',
+  outputDir: 'dist',
+  assetsDir: 'static',
+  lintOnSave: process.env.NODE_ENV === 'development',
+  productionSourceMap: false,
+  devServer: {
+    port: 82,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8004',
+        changeOrigin: true,
+        pathRewrite: { '^/api': '' }
+      }
+    }
+  },
+  configureWebpack: {
+    name: name,
+    resolve: {
+      alias: {
+        '@': resolve('src')
+      }
+    }
+  },
+  chainWebpack(config) {
+    config.plugins.delete('preload')
+    config.plugins.delete('prefetch')
+    config.module
+      .rule('svg')
+      .exclude.add(resolve('src/icons'))
+      .end()
+    config.module
+      .rule('icons')
+      .test(/\.svg$/)
+      .include.add(resolve('src/icons'))
+      .end()
+      .use('svg-sprite-loader')
+      .loader('svg-sprite-loader')
+      .options({
+        symbolId: 'icon-[name]'
+      })
+      .end()
+  }
+}