移动端h5-tpl-vue模版

# 简介

vant 是一套轻量、可靠的移动端 Vue 组件库,非常适合基于 vue 技术栈的移动端开发。基于 vue-cli4 实现的移动端框架,其中包含项目常用的配置,组件封装及webpack优化方法模板封装,可快速生成项目;

两种可选技术栈

  • vue-cli4.x + webpack4.x + vant2.x + axios + postcss-px2rem + less;
  • vue-cli4.x + webpack4.x + vant2.x + axios + postcss-px2rem + sass ;【TODO】

# 技术点

主要包括如下技术点:

  • vue-cli4脚手架
  • vant按需引入
  • 移动端rem适配
  • axios拦截封装
  • util工具类函数封装
  • vue-router配置
  • 登录权限校验
  • 多环境变量配置
  • vue.config.js配置
  • 跨域代理设置
  • webpack打包可视化分析
  • CDN资源优化
  • gzip打包优化
  • 首页添加骨架屏
  • toast组件封装
  • dialog组件封装

# 使用

# 初始化

结合rat-cli脚手架快速初始化;

rat init tpl
1

# 配置多环境变量

package.json 里的 scripts 配置 serve stage build,通过 --mode xxx 来执行不同环境

  • 通过 npm run serve 启动本地 , 执行 development
  • 通过 npm run stage 打包测试 , 执行 staging
  • 通过 npm run build 打包正式 , 执行 production
"scripts": {
  "serve": "vue-cli-service serve --open",
  "stage": "vue-cli-service build --mode staging",
  "build": "vue-cli-service build",
  #"test": "vue-cli-service build --mode test",
}
1
2
3
4
5
6

# 配置介绍

VUE_APP_ 开头的变量,在代码中可以通过 process.env.VUE_APP_ 访问。

比如,VUE_APP_ENV = 'development' 通过process.env.VUE_APP_ENV 访问。

除了 VUE_APP_* 变量之外,在你的应用代码中始终可用的还有两个特殊的变量NODE_ENVBASE_URL

在项目根目录中新建.env.*

  • .env.development 本地开发环境配置
NODE_ENV='development'
VUE_APP_ENV = 'dev'
1
2
  • .env.staging 测试预备环境配置
NODE_ENV='staging'
VUE_APP_ENV = 'staging'
1
2
  • .env.production 正式环境配置
 NODE_ENV='production'
VUE_APP_ENV = 'prod'
1
2

这里我们并没有定义很多变量,只定义了基础的 VUE_APP_ENV dev staging prod 变量我们统一在 src/config/env.*.js 里进行管理。

# config 配置

config 下新建三个对应的文件;修改起来方便,不需要重启项目,符合开发习惯。

config/index.js

const environment = process.env.VUE_APP_ENV || 'prod'
const config = require('./env.' + environment)
module.exports = {
  ...config,
  IS_PROD: ['production', 'prod'].includes(process.env.VUE_APP_ENV) //// 获取 VUE_APP_ENV 非 NODE_ENV,
}
1
2
3
4
5
6

配置对应环境的变量,拿本地环境文件 env.development.js 举例,用户可以根据需求修改

module.exports = {
  title: 'h5-tpl-vue',
  baseUrl: 'http://localhost:9527', 
  baseApi: 'https://test.yessz.cn/adm/api',
  port: 9527,
  $cdn: 'https://www.yessz.cn/static',
  APPID: 'xxx',
  APPSECRET: 'xxx',
}
1
2
3
4
5
6
7
8
9

根据环境不同,变量使用;

// 根据环境不同引入不同baseApi地址
import { baseApi } from '@/config'

// 设置 js中可以访问 $cdn
import { $cdn } from '@/config'
Vue.prototype.$cdn = $cdn
1
2
3
4
5
6

# 适配屏幕

# rem 适配方案

Vant 中的样式默认使用px作为单位,如果需要使用rem单位,推荐使用以下两个工具:

# PostCSS 配置

基本的 .postcssrc 配置,可以在此配置的基础上根据项目需求进行修改

// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: ['Android 4.1', 'iOS 7.1', 'Chrome > 31', 'ff > 31', 'ie >= 8']
    },
    'postcss-pxtorem': {
      rootValue: 37.5,
      propList: ['*']
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

更多详细信息: vant (opens new window)

# 引入lib-flexible

src/main.js 引入如下代码

import 'lib-flexible/flexible.js'// 移动端适配

"lib-flexible": "^0.3.2",
"postcss-pxtorem": "^5.1.1",
1
2
3
4

# vm 适配方案

本模板默认使用的是 rem 的 适配方案,其实无论你使用哪种方案,都不需要你去计算 12px 是多少 rem 或者 vw, 会有专门的工具去帮你做; 如果你想用 vw,可以按照下面的方式切换。

安装依赖

npm install postcss-px-to-viewport -D
1

修改 .postcssrc.js

// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: ['Android 4.1', 'iOS 7.1', 'Chrome > 31', 'ff > 31', 'ie >= 8']
    },
    'postcss-px-to-viewport': {
      viewportWidth: 375, // 视窗的宽度,对应的是我们设计稿的宽度,一般是750
      unitPrecision: 3, // 指定`px`转换为视窗单位值的小数位数(很多时候无法整除)
      viewportUnit: 'vw', // 指定需要转换成的视窗单位,建议使用vw
      selectorBlackList: ['.ignore', '.hairlines'], // 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名
      minPixelValue: 1, // 小于或等于`1px`不转换为视窗单位,你也可以设置为你想要的值
      mediaQuery: false // 允许在媒体查询中转换`px`
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

删除原来的 rem 相关代码

src/main.js 删除如下代码

import 'lib-flexible/flexible.js'// 移动端适配
1

package.json 删除如下代码

"lib-flexible": "^0.3.2",
"postcss-pxtorem": "^5.1.1",
1
2

运行起来,F12 元素 css 就是 vw 单位了

# 组件自动按需加载

babel-plugin-import (opens new window) 是一款 babel 插件,它会在编译过程中将import 的写法自动转换为按需引入的方式;vue引入组件 (opens new window)

# 安装/配置

npm i babel-plugin-import -D
1

配置 .babelrc 或者 babel.config.js 文件

const IS_PROD = ['production', 'prod'].includes(process.env.VUE_APP_ENV)
const plugins = [
  [
    'import',
    {
      libraryName: 'vant',
      libraryDirectory: 'es',
      style: true
    },
    'vant'
  ]
]
if (IS_PROD) {
  plugins.push('transform-remove-console') //测试环境依然 console
}

module.exports = {
  presets: [['@vue/cli-plugin-babel/preset', { useBuiltIns: 'usage', corejs: 3 }]],
  plugins
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 使用组件

项目在 src/plugins/vant.js 下统一管理组件;

import Vue from 'vue'
// import Vant from 'vant'
// Vue.use(Vant) //导入所有组件
// Vue.use(Tabbar).use(TabbarItem)//链式按需导入
import {
  Tab,
  Tabs,
  Form,
  Field,
  Button,
  Image,
  Icon,
  Uploader,
  NavBar,
  Col,
  Row,
  Empty
}
Object.entries(components).forEach(([key, component]) => {
  Vue.use(component)
})

Toast.setDefaultOptions('loading', {forbidClick: true})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 全局样式

# Less样式【推荐】

# vue.config.js配置

"less": "^3.11.1",
"less-loader": "^6.0.0",
1
2

vue.config.js

  css: {
    extract: IS_PROD, // 是否将组件中的 CSS 提取至一个独立的 CSS 文件中 (而不是动态注入到 JavaScript 中的 inline 代码)。
    sourceMap: false,
    loaderOptions: {
      // scss: {
      //   // 向全局sass样式传入共享的全局变量, $src可以配置图片cdn前缀
      //   // 详情: https://cli.vuejs.org/guide/css.html#passing-options-to-pre-processor-loaders
      //   prependData: `
      //     @import "assets/css2/mixin.scss";
      //     @import "assets/css2/variables.scss";
      //     $cdn: "${$cdn}";
      //     `
      // },
      less: {
        lessOptions: {
          modifyVars: {
            // less 文件覆盖(文件路径为绝对路径)`
            hack: `true; @import "${path.join(
              __dirname,
              "./src/assets/css/variables.less"
            )}";`
          },
          javascriptEnabled: true
        }
      }
    }
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# Sass样式

首先 你可能会遇到 node-sass 安装不成功,别放弃多试几次!!!

每个页面自己对应的样式都写在自己的 .vue 文件之中 scoped 它顾名思义给 css 加了一个域的概念。

<style lang="scss">
  /* global styles */
</style>

<style lang="scss" scoped>
  /* local styles */
</style>
1
2
3
4
5
6
7

# 目录结构

所有全局样式都在 @/src/assets/css 目录下设置

├── assets
│   ├── css
│   │   ├── index.scss               # 全局通用样式
│   │   ├── mixin.scss               # 全局mixin
│   │   └── variables.scss           # 全局变量
1
2
3
4
5

# 自定义 vant-ui 样式

现在我们来说说怎么重写 vant-ui 样式。由于 vant-ui 的样式我们是在全局引入的,所以想在某个页面里面覆盖它的样式就不能 加 scoped,但又想只覆盖这个页面的 vant 样式,就可在它的父级加一个 class,用命名空间来解决问题。

.about-container {
  /* 你的命名空间 */
  .van-button {
    /* vant-ui 元素*/
    margin-right: 0px;
  }
}
1
2
3
4
5
6
7

# 深度选择器

# 父组件改变子组件样式

当你子组件使用了 scoped 但在父组件又想修改子组件的样式可以 通过 >>> 来实现:

<style scoped>
.a >>> .b { /* ... */ }
</style>
1
2
3

# 全局变量

vue.config.js 配置使用 css.loaderOptions 选项,注入 sassmixin variables 到全局,不需要手动引入 ,配 置$cdn通过变量形式引入 cdn 地址,这样向所有 Sass/Less 样式传入共享的全局变量:

const IS_PROD = ['production', 'prod'].includes(process.env.NODE_ENV)
const defaultSettings = require('./src/config/index.js')
module.exports = {
  css: {
    extract: IS_PROD,
    sourceMap: false,
    loaderOptions: {
      // 给 scss-loader 传递选项
      scss: {
        // 注入 `sass` 的 `mixin` `variables` 到全局, $cdn可以配置图片cdn
        // 详情: https://cli.vuejs.org/guide/css.html#passing-options-to-pre-processor-loaders
        prependData: `
          @import "assets/css/mixin.scss";
          @import "assets/css/variables.scss";
          $cdn: "${defaultSettings.$cdn}";
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

设置 js 中可以访问 $cdn,.vue 文件中使用this.$cdn访问

// 引入全局样式
import '@/assets/css/index.scss'

// 设置 js中可以访问 $cdn
// 引入cdn
import { $cdn } from '@/config'
Vue.prototype.$cdn = $cdn
1
2
3
4
5
6
7

在 css 和 js 使用

<script>
  console.log(this.$cdn)
</script>
<style lang="scss" scoped>
  .logo {
    width: 120px;
    height: 120px;
    background: url($cdn + '/weapp/logo.png') center / contain no-repeat;
  }
</style>
1
2
3
4
5
6
7
8
9
10

# Vuex 状态管理

目录结构

├── store
│   ├── modules
│   │   └── app.js
│   ├── index.js
│   ├── getters.js
1
2
3
4
5

main.js 引入

import Vue from 'vue'
import App from './App.vue'
import store from './store'
new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})
1
2
3
4
5
6
7
8
9

使用

<script>
  import { mapGetters } from 'vuex'
  export default {
    computed: {
      ...mapGetters(['userName'])
    },

    methods: {
      // Action 通过 store.dispatch 方法触发
      doDispatch() {
        this.$store.dispatch('setUserName', 'samy~')
      }
    }
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# Vue-router

模板采用 hash 模式,开发者根据需求修改 mode base

注意:如果你使用了 history 模式,vue.config.js 中的 publicPath 要做对应的修改

路由可以实现一下功能:

  • 路由懒加载配置
  • 改变单页面应用的 title
  • 登录权限校验
  • 页面缓存配置

# 基本配置

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)
//在路由跳转的时候不允许两次push/replace的path地址相同;对路由的push和replace方法重写规则
const [routerPush, routerReplace] = [
  Router.prototype.push,
  Router.prototype.replace
];
Router.prototype.push = function push(location) {
  return routerPush.call(this, location).catch(error => error);
};
Router.prototype.replace = function replace(location) {
  return routerReplace.call(this, location).catch(error => error);
};
export const router = [
  {
    path: '/',
    name: 'index',
    component: () => import('@/views/home/index'), // 路由懒加载
    meta: {
      title: '首页', // 页面标题
      keepAlive: false // keep-alive 标识
    }
  }
]
const createRouter = () =>
  new Router({
    // mode: 'history', // 如果你是 history模式 需要配置 vue.config.js publicPath
    // base: '/app/',
    scrollBehavior: () => ({ y: 0 }),
    routes: router
  })

export default createRouter()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 路由懒加载配置

Vue 项目中实现路由按需加载(路由懒加载)的 3 中方式:

// 1、Vue异步组件技术:
{
	path: '/home',
	name: 'Home',
	component: resolve => reqire(['../views/Home.vue'], resolve)
}

// 2、es6提案的import()
{
  path: '/',
  name: 'home',
  component: () => import('../views/Home.vue')
}

// 3、webpack提供的require.ensure()
{
	path: '/home',
	name: 'Home',
	component: r => require.ensure([],() =>  r(require('../views/Home.vue')), 'home')
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

本项目采用的是第二种方式,为了后续 webpack 打包优化。

# 改变单页面应用的 title

由于单页面应用只有一个 html,所有页面的 title 默认是不会改变的,但是我们可以才路由配置中加入相关属性,再在路由守卫中通过 js 改变页面的 title

router.beforeEach((to, from, next) => {
  document.title = to.meta.title
})
1
2
3

# 登录权限校验

在应用中,通常会有以下的场景,比如商城:有些页面是不需要登录即可访问的,如首页,商品详情页等,都是用户在任何情况都能看到的;但是也有是需要登录后才能访问的,如个人中心,购物车等。此时就需要对页面访问进行控制了。

此外,像一些需要记录用户信息和登录状态的项目,也是需要做登录权限校验的,以防别有用心的人通过直接访问页面的 url 打开页面。

此时。路由守卫可以帮助我们做登录校验。具体如下:

1、配置路由的 meta 对象的 auth 属性

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('../views/Home.vue'),
    meta: { title: '首页', keepAlive: false, auth: false },
  },
  {
    path: '/mine',
    name: 'mine',
    component: () => import('../views/mine.vue'),
    meta: { title: '我的', keepAlive: false, auth: true },
  },
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

2、在路由首页进行判断。当to.meta.authtrue(需要登录),且不存在登录信息缓存时,需要重定向去登录页面

router.beforeEach((to, from, next) => {
  document.title = to.meta.title
  const userInfo = sessionStorage.getItem('userInfo') || null
  if (!userInfo && to.meta.auth) {
    next('/login')
  } else {
    next()
  }
})
1
2
3
4
5
6
7
8
9

# 页面缓存配置

项目中,总有一些页面我们是希望加载一次就缓存下来的,此时就用到 keep-alive 了。keep-alive 是 Vue 提供的一个抽象组件,用来对组件进行缓存,从而节省性能,由于是一个抽象组件,所以在 v 页面渲染完毕后不会被渲染成一个 DOM 元素。

1、通过配置路由的 meta 对象的 keepAlive 属性值来区分页面是否需要缓存

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('../views/Home.vue'),
    meta: { title: '首页', keepAlive: false, auth: false },
  },
  {
    path: '/list',
    name: 'list',
    component: () => import('../views/list.vue'),
    meta: { title: '列表页', keepAlive: true, auth: false },
  },
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

2、在 app.vue 做缓存判断

<div id="app">
  <router-view v-if="!$route.meta.keepAlive"></router-view>
  <keep-alive>
    <router-view v-if="$route.meta.keepAlive"></router-view>
  </keep-alive>
</div>
1
2
3
4
5
6

# Axios 请求封装

utils/request.js 封装 axios ,开发者需要根据后台接口做修改。

  • service.interceptors.request.use 里可以设置请求头,比如设置 token
  • config.hideloading 是在 api 文件夹下的接口参数里设置,下文会讲
  • service.interceptors.response.use 里可以对接口返回数据处理,比如 401 删除本地信息,重新登录

# 基本封装

import axios from 'axios'
import store from '@/store'
import { Toast } from 'vant'
import { baseApi } from '@/config'

const service = axios.create({
  baseURL: baseApi, // url = base api url + request url
  withCredentials: true, // send cookies when cross-domain requests
  timeout: 5000 // request timeout
})

// respone拦截器
service.interceptors.response.use(
  response => {
    if (!response.config.hideloading) {
      // Toast.clear()
      hideLoading()
    }
    const res = response.data
    if (response.status && response.status !== 200) {
      if (response.status === 401 || response.status === 403) {
        logout({state: 'h5LogOut'})
          .then(res => {
            store.commit('LOG_OUT')
            if (res.data) {
              window.location.href = `${res.data}&state=h5LogOut`
            } else {
              vueInstance.$router.replace('/login')
            }
            return Promise.reject(error)
          })
          .catch(() => {
            store.commit('LOG_OUT')
            vueInstance.$router.replace('/login')
            return Promise.reject(error)
          })
      }
      return Promise.reject(res || 'error')
    } else {
      const {data, code, msg} = response.data || {}
      const isSuccess = code === 0 ? true : false
      if (!isSuccess) {
        Toast.fail({
          duration: 3000,
          message: msg
        })
      }
      response.data.isSuccess = isSuccess
      return Promise.resolve(response.data)
    }
  },
  error => {
    // Toast.clear()
    // console.error('err' + error)
    hideLoading()
    if (error.response) {
      if (error.response.status == 401 || error.response.status == 403) {
        logout({state: 'h5LogOut'})
          .then(res => {
            store.commit('LOG_OUT')
            if (res.data) {
              window.location.href = `${res.data}&state=h5LogOut`
            } else {
              vueInstance.$router.replace('/login')
            }
            return Promise.reject(error)
          })
          .catch(() => {
            store.commit('LOG_OUT')
            vueInstance.$router.replace('/login')
            return Promise.reject(error)
          })
      }
    }
    return Promise.reject(error)
  }

export default service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

# loading优化

let loadingRequestCount = 0
let toastInstance
function showLoading() {
  if (loadingRequestCount === 0) {
    toastInstance = Toast.loading({
      forbidClick: true,
      duration: 10000 // 持续展示 toast
    })
  }
  loadingRequestCount += 1
}

function hideLoading() {
  if (loadingRequestCount <= 0) {
    return
  }
  loadingRequestCount -= 1
  if (loadingRequestCount === 0) {
    toastInstance.clear()
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 接口管理

src/api 文件夹下统一管理接口

  • 你可以建立多个模块对接接口, 比如 home.js 里是首页的接口这里讲解 user.js
  • url 接口地址,请求的时候会拼接上 config 下的 baseApi
  • method 请求方法
  • data 请求参数 qs.stringify(params) 是对数据系列化操作
  • hideloading 默认 false,设置为 true 后,不显示 loading ui 交互中有些接口不需要让用户感知
import qs from 'qs'
// axios
import request from '@/utils/request'
//user api

// 用户信息
export function getUserInfo(params) {
  return request({
    url: '/user/userinfo',
    method: 'post',
    data: qs.stringify(params),
    hideloading: true // 隐藏 loading 组件
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 调用

import { getUserInfo } from '@/api/user.js'
const params = { user: 'samy' }
getUserInfo(params)
    .then(() => {})
    .catch(() => {})
1
2
3
4
5

# 监听网络提示

<script>
export default {
  name: 'App',
  mounted() {
    window.addEventListener('offline', this.offline)
    window.addEventListener('online', this.online)
    localStorage.setItem('network', 1)
  },
  methods: {
    offline() {
      this.$toast.fail({
        message: 'Network anomaly',
        className: 'network-error'
      })
      localStorage.setItem('network', 0)
    },
    online() {
      localStorage.setItem('network', 1)
    }
  }
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 国际化设置

处理momentvant第三方库的国际化;

# 初始化

locales/index.js

const i18n = new VueI18n({
  locale: getLang(),
  messages,
  silentTranslationWarn: true
})

function i18nRender(key, defaultMessage) {
  const value = i18n.t(key)
  if (value === key && defaultMessage) {
    return defaultMessage
  }
  return value
}

function pluginLocales(lang = 'zh-CN') {
  if (lang === 'zh-CN') {
    Locale.use(lang, zhCN)
    moment.locale('zh-cn')
  } else if (lang === 'en-US') {
    Locale.use(lang, enUS)
    moment.locale('en')
  }
}

pluginLocales(i18n.locale)

function setLang(lang) {
  i18n.locale = lang
  pluginLocales(i18n.locale)
  localStorage.setItem('lang', lang)
  // store.dispatch("setLang", lang);
}

export {i18n, getLang, setLang, i18nRender}
export default i18n
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

main.js

import { i18n, i18nRender } from "@/locales";

Vue.prototype.i18nRender = i18nRender;
const vueInstance = new Vue({
  el: "#app",
  router,
  i18n,
  store,
  render: h => h(App)
});
1
2
3
4
5
6
7
8
9
10

# 切换语言

import {setLang, getLang} from '@/locales/index'
data() {
    const userInfo = getLoginInfo() || {};
    const {username, password} = userInfo
    return {
        columns: ['中文简体', 'English'],
        lang: getLang() || 'zh-CN',
    };
}
methods: {
    onConfirm(value) {
        if (value == 'English') {
            setLang('en-US');
            this.lang = 'en-US'
        } else {
            setLang('zh-CN')
            this.lang = 'zh-CN'
        }
        this.showPicker = false;
        window.location.reload()

    },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 使用

this.$toast(this.i18nRender('login.enterUsrName', '请输入用户名'));
1
import { i18n } from "@/locales";
export const router = [
    {
        path: "/flowData/index",
        name: "FlowDataIndex",
        component: () => import("@/views/flowReport/flowData/index"),
        meta: {
        title: i18n.t("menu.flowData"),
    keepAlive: false,
    requireLogin: true,
    requirePerm: "history_data"
    }
  }
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 权限设置

# 登录/登出请求

 import { mapMutations } from 'vuex' 
import {getLoginInfo, aesEncrypt, setLoginInfo, clearLoginInfo, setToken} from '@/utils/util.js'
  import {setLang, getLang} from '@/locales/index'
  import {captcha, encryptKey, login} from '@/service/login'
 methods: {
      ...mapMutations(['SET_USERINFO']),
login() {
        const {username, password, uuid, key, captcha, checked} = this;
        if (!username) {
          this.$toast(this.i18nRender('login.enterUsrName', '请输入用户名'));
          return
        }
        if (!password) {
          this.$toast(this.i18nRender('login.enterpassword', '请输入密码'));
          return
        }
        if (!captcha) {
          this.$toast(this.i18nRender('login.enterCode', '请输入验证码'));
          return
        }
        const newKey = key ? key.substring(1, key.length - 1).split(',') : []
        login({username, password: aesEncrypt(password, newKey), uuid, captcha}).then((res) => {
          if (res.success) {
            // 登录成功
            const {data: {token, staffInfo}} = res
            setToken(token);
            this.SET_USERINFO(staffInfo)
            if (checked) {
              setLoginInfo({username, password})
            } else {
              clearLoginInfo()
            }
            this.$router.push('/')
          } else {
            this.getCaptcha();
          }
        })
      },
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
 if (response.status === 401 || response.status === 403) {
        logout({state: 'h5LogOut'})
          .then(res => {
            store.commit('LOG_OUT')
            if (res.data) {
              window.location.href = `${res.data}&state=h5LogOut`
            } else {
              vueInstance.$router.replace('/login')
            }
            return Promise.reject(error)
          })
          .catch(() => {
            store.commit('LOG_OUT')
            vueInstance.$router.replace('/login')
            return Promise.reject(error)
          })
      }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 路由权限

src\router\permission.js

import router from "@/router";
import store from "@/store";
import { setToken, setDocumentTitle, i18nRender } from "@/utils/util";
import Vue from "vue";

setDocumentTitle(i18nRender("index.ubr"));

router.beforeEach((to, from, next) => {
  const { query } = to;
  // oss跳转过来的页面
  if (query && query.token) {
    setToken(query.token);
  }
  if (localStorage.getItem("token")) {
    if (to.path === "/login") {
      next({ path: "/" }); // 已经登录过,不需要重新登录
    } else {
      // 检查是否有访问权限
      const checkPerm = (routeTo, nextFunc) => {
        const { permission } = store.state.app;
        if (routeTo.meta.requirePerm) {
          // 分区的页面处理
          if (Array.isArray(routeTo.meta.requirePerm)) {
            if (query && query.id) {
              let itemData = "";
              const menuUrl = query.id;
              Object.values(permission).forEach(item => {
                if (
                  menuUrl == item.menuUrl &&
                  routeTo.meta.requirePerm.indexOf(item.menuCode) > -1
                ) {
                  itemData = item;
                }
              });
              if (itemData) {
                nextFunc();
              } else {
                nextFunc({ path: "/login" });
              }
            } else {
              nextFunc();
            }
          } else if (permission[routeTo.meta.requirePerm]) {
            nextFunc();
          } else {
            nextFunc({ path: "/login" });
          }
        } else {
          nextFunc();
        }
      };

      if (to.meta.requireLogin && !store.state.app.permission) {
        // 判断当前用户是否已拉取完用户信息
        store
          .dispatch("getUserInfo")
          .then(res => {
            // 拉取用户信息
            if (
              res &&
              res.data &&
              res.data.staffInfo &&
              res.data.staffInfo.roleId
            ) {
              const {
                data: { staffInfo }
              } = res;
              store
                .dispatch("getPormieesion", staffInfo)
                .then(() => {
                  Vue.prototype.permission = store.state.app.permission;
                  checkPerm(to, next);
                })
                .catch(() => {
                  // 登出
                  // 要手动调一下接口
                  next({ path: "/403" });
                });
            } else {
              next({ path: "/403" });
            }
          })
          .catch(() => {
            store.commit("LOG_OUT");
            next({ path: "/login" });
          });
      } else {
        checkPerm(to, next);
        // next()
      }
    }
  } else {
    to.meta.requireLogin === false ? next() : next({ path: "/login" }); // 否则重定向到登录页
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95

# 菜单权限

// 判断当前用户是否已拉取完用户信息
store
    .dispatch("getUserInfo")
    .then(res => {
    if (
        res &&
        res.data &&
        res.data.staffInfo &&
        res.data.staffInfo.roleId
    ) {
        const {
            data: { staffInfo }
        } = res;
        store
            .dispatch("getPormieesion", staffInfo)
            .then(() => {
            Vue.prototype.permission = store.state.app.permission;
            checkPerm(to, next);
        })
            .catch(() => {
            // 登出
            // 要手动调一下接口
            next({ path: "/403" });
        });
    } else {
        next({ path: "/403" });
    }
})
    .catch(() => {
    store.commit("LOG_OUT");
    next({ path: "/login" });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 按钮权限

<ExportFile
            :pdfInfo="pdfInfo"
            :excelInfo="excelInfo"
            v-if="
                  isTotal
                  ? $root.permission.history_overview_export1
                  : $root.permission.history_zone_export1
                  "
            >
    <u-icon class="icon" name="iconbianzu2" />
</ExportFile>

<div class="groupBox" v-if="pageType !== 'data_revision'">
    <div class="editButton" @click="modifyFlow" v-if="$root.permission[`${authorityKey[id]}modify`]">
        <UIcon name="iconedit" size="16" color="#ffffff"></UIcon>
        {{i18nRender('control.currentEdit', '实时修订')}}
    </div>
    <div
         class="publishButton"
         @click="publishWarning"
         v-if="$root.permission[`${authorityKey[id]}warning`]"
         >
        <UIcon name="iconalert" size="16" color="#ffffff"></UIcon>
        {{i18nRender('control.publishWarning', '发布预警')}}
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 辅助设置

# 开发日志调试vconsole

main.js

import {IS_PROD}from '@/config';
if (!IS_PROD) {
  const VConsole = require("vconsole");
  new VConsole();
}
1
2
3
4
5

# 统一日志统计上报

# Webpack配置

vue.config.js配置

如果你的 Vue Router 模式是 hash publicPath: './',

如果你的 Vue Router 模式是 history 这里的 publicPath 和你的 Vue Router base 保持一直, publicPath: '/app/',

vue-cli3 开始,新建的脚手架都需要在 vue.config.js 配置项目的东西。主要包括

  • 打包后文件输出位置
  • 关闭生产环境 souecemap
  • 配置 rem 转化 px
  • 配置 alias 别名
  • 去除生产环境 console
  • 跨域代理设置

# 基础配置

const { title, port, IS_PROD, $cdn } = require('./src/config/index.js')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const { name: pkgName } = require('./package.json')
const resolve = dir => path.join(__dirname, dir)

const name = title || pkgName

module.exports = {
   publicPath: './', // 署应用包时的基本 URL。 vue-router hash 模式使用
  //  publicPath: '/app/', //署应用包时的基本 URL。  vue-router history模式使用
  outputDir: 'dist', //  生产环境构建文件的目录
  assetsDir: 'static', //  outputDir的静态资源(js、css、img、fonts)目录
  indexPath: 'index.html', // 指定生成的 index.html 的输出路径
   runtimeCompiler: false, // 是否使用包含运行时编译器的 Vue 构建版本。
  // 默认情况下 babel-loader 会忽略所有 node_modules 中的文件。如果你想要通过 Babel 显式转译一个依赖,可以在这个选项中列出来。
  transpileDependencies: [],
  lintOnSave: !IS_PROD,
  productionSourceMap: false, // 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
  devServer: {
    port: port, // 端口
    open: false, // 启动后打开浏览器
      // host: "localhost",
    // port: "8080", // 代理断就
    // https: false,
    // hotOnly: false, // 热更新
    overlay: {
      //  当出现编译器错误或警告时,在浏览器中显示全屏覆盖层
      warnings: false,
      errors: true
    },
    proxy: {
      [`${apiBase}`]: {
        target: apiHost,
        changOrigin: true
        // ws:true,
        // pathRewrite: {
        //   '^/api': '/'
        // }
      }
    }
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 配置 alias 别名

const path = require('path')
const resolve = dir => path.join(__dirname, dir)

module.exports = {
  chainWebpack: config => {
    config.resolve.alias
      .set('@', resolve('src'))
      .set('assets', resolve('src/assets'))
      .set('api', resolve('src/api'))
      .set('views', resolve('src/views'))
      .set('components', resolve('src/components'))
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 配置 proxy 跨域

如果你的项目需要跨域设置,你需要打来 vue.config.js proxy 注释 并且配置相应参数

!!!注意:还需要将 src/config/env.development.js 里的 baseApi 设置成 '/'

  devServer: {
    port: port, // 端口
    https: false, // https:{type:Boolean}
    open: false, // 配置自动启动浏览器  open: 'Google Chrome'-默认启动谷歌
    overlay: {
      warnings: false,//  当出现编译器错误或警告时,在浏览器中显示全屏覆盖层
      errors: true
    },
    proxy: {
      [`${apiBase}`]: {
        target: apiHost,
        changOrigin: true // 开启代理,在本地创建一个虚拟服务端
        // ws:true, // 是否启用websockets
        // pathRewrite: {
        //   '^/api': '/'
        // }
      }
    }
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

使用 例如: src/api/home.js

export function getUserInfo(params) {
  return request({
    url: '/api/userinfo',
    method: 'post',
    data: qs.stringify(params)
  })
}
1
2
3
4
5
6
7

# 配置 打包分析

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
  chainWebpack: config => {
    if (IS_PROD) {
      config.plugin('webpack-report').use(BundleAnalyzerPlugin, [
        {
          analyzerMode: 'static'
        }
      ])
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
npm run build
1

# 配置 externals 引入 cdn 资源

因为页面每次遇到<script>标签都会停下来解析执行,所以应该尽可能减少<script>标签的数量 HTTP请求存在一定的开销,100K的文件比 5 个 20K 的文件下载的更快,所以较少脚本数量也是很有必要的

const path = require('path')
const { title, port, IS_PROD, $cdn, apiHost, apiBase } = require('./src/config/index.js')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const { name: pkgName } = require('./package.json')
const resolve = dir => path.join(__dirname, dir)

const name = title || pkgName
// externals
const externals = {
  vue: 'Vue',
  'vue-router': 'VueRouter',
  vuex: 'Vuex',
  vant: 'vant',
  axios: 'axios'
}
// CDN外链,会插入到index.html中
const cdn = {
  // 开发环境
  dev: {
    css: [],
    js: []
  },
  // 生产环境
  build: {
    css: ['https://cdn.jsdelivr.net/npm/vant@2.4.7/lib/index.css'],
    js: [
      'https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js',
      'https://cdn.jsdelivr.net/npm/vue-router@3.1.5/dist/vue-router.min.js',
      'https://cdn.jsdelivr.net/npm/axios@0.19.2/dist/axios.min.js',
      'https://cdn.jsdelivr.net/npm/vuex@3.1.2/dist/vuex.min.js',
      'https://cdn.jsdelivr.net/npm/vant@2.4.7/lib/index.min.js'
    ]
  }
}
module.exports = {
  configureWebpack: config => {
    config.name = name
    // 为生产环境修改配置...
    if (IS_PROD) {
      // externals
      config.externals = externals
    }
  },
  chainWebpack: config => {
    /**
     * 添加CDN参数到htmlWebpackPlugin配置中
     */
    config.plugin('html').tap(args => {
      if (IS_PROD) {
        args[0].cdn = cdn.build
      } else {
        args[0].cdn = cdn.dev
      }
      return args
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

在 public/index.html 中添加

    <!-- 使用CDNCSS文件 -->
    <% for (var i in
      htmlWebpackPlugin.options.cdn&&htmlWebpackPlugin.options.cdn.css) { %>
      <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="preload" as="style" />
      <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="stylesheet" />
    <% } %>
     <!-- 使用CDN加速的JS文件,配置在vue.config.js下 -->
    <% for (var i in
      htmlWebpackPlugin.options.cdn&&htmlWebpackPlugin.options.cdn.js) { %>
      <script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
    <% } %>
1
2
3
4
5
6
7
8
9
10
11

# 去掉 console.log

保留了测试环境和本地环境的 console.log

npm i -D babel-plugin-transform-remove-console
1

在 babel.config.js 中配置

// 获取 VUE_APP_ENV 非 NODE_ENV,测试环境依然 console
const IS_PROD = ['production', 'prod'].includes(process.env.VUE_APP_ENV)
const plugins = [
  [
    'import',
    {
      libraryName: 'vant',
      libraryDirectory: 'es',
      style: true
    },
    'vant'
  ]
]
// 去除 console.log
if (IS_PROD) {
  plugins.push('transform-remove-console')
}

module.exports = {
  presets: [['@vue/cli-plugin-babel/preset', { useBuiltIns: 'entry' }]],
  plugins
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# splitChunks 单独打包第三方模块

module.exports = {
  chainWebpack: config => {
    config.when(IS_PROD, config => {
      config
        .plugin('ScriptExtHtmlWebpackPlugin')
        .after('html')
        .use('script-ext-html-webpack-plugin', [
          {
            // 将 runtime 作为内联引入不单独存在
            inline: /runtime\..*\.js$/
          }
        ])
        .end()
      config.optimization.splitChunks({
        chunks: 'all',
        cacheGroups: {
          // cacheGroups 下可以可以配置多个组,每个组根据test设置条件,符合test条件的模块
          commons: {
            name: 'chunk-commons',
            test: resolve('src/components'),
            minChunks: 3, //  被至少用三次以上打包分离
            priority: 5, // 优先级
            reuseExistingChunk: true // 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
          },
          node_vendors: {
            name: 'chunk-libs',
            chunks: 'initial', // 只打包初始时依赖的第三方
            test: /[\\/]node_modules[\\/]/,
            priority: 10
          },
          vantUI: {
            name: 'chunk-vantUI', // 单独将 vantUI 拆包
            priority: 20, // 数字大权重到,满足多个 cacheGroups 的条件时候分到权重高的
            test: /[\\/]node_modules[\\/]_?vant(.*)/
          }
        }
      })
      config.optimization.runtimeChunk('single')
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

# 打包加速

多线程打包happypack;

TODO:

  // 是否为 Babel 或 TypeScript 使用 thread-loader。该选项在系统的 CPU 有多于一个内核时自动启用,仅作用于生产构建。
  parallel: require('os').cpus().length > 1,
1
2

# 可视化分析

分析一下 webpack 打包性能瓶颈,找出问题所在,然后才能对症下药。此时就用到 webpack-bundle-analyzer 了。 1、安装依赖

npm install webpack-bundle-analyzer -D
1

2、在 vue.config.js 配置

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
configureWebpack: (config) => {
  if (process.env.NODE_ENV === 'production') {
    config.plugins.push(new BundleAnalyzerPlugin())
  }
}
1
2
3
4
5
6

打包后,我们可以看到这样一份依赖图

image-20210307234837051

从以上的界面中,我们可以得到以下信息:

  • 打包出的文件中都包含了什么,以及模块之间的依赖关系
  • 每个文件的大小在总体中的占比,找出较大的文件,思考是否有替换方案,是否使用了它包含了不必要的依赖?
  • 是否有重复的依赖项,对此可以如何优化?
  • 每个文件的压缩后的大小。

# gZip 加速优化

所有现代浏览器都支持 gzip 压缩,启用 gzip 压缩可大幅缩减传输资源大小,从而缩短资源下载时间,减少首次白屏时间,提升用户体验。

gzip 对基于文本格式文件的压缩效果最好(如:CSS、JavaScript 和 HTML),在压缩较大文件时往往可实现高达 70-90% 的压缩率,对已经压缩过的资源(如:图片)进行 gzip 压缩处理,效果很不好。

const CompressionPlugin = require('compression-webpack-plugin')
configureWebpack: (config) => {
  if (process.env.NODE_ENV === 'production') {
    config.plugins.push(
      new CompressionPlugin({
        // gzip压缩配置
        test: /\.js$|\.html$|\.css/, // 匹配文件名
        threshold: 10240, // 对超过10kb的数据进行压缩
        deleteOriginalAssets: false, // 是否删除原文件
      })
    )
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# Eslint/Pettier统一编码规范

安装 eslint prettier vetur 插件 .vue 文件使用 vetur 进行格式化, .prettierrc

{
   "printWidth": 120,
   "tabWidth": 2,
   "singleQuote": true,
   "trailingComma": "none",
   "semi": false,
   "wrap_line_length": 120,
   "wrap_attributes": "auto",
   "proseWrap": "always",
   "arrowParens": "avoid",
   "bracketSpacing": false,
   "jsxBracketSameLine": true,
   "useTabs": false,
   "overrides": [{
       "files": ".prettierrc",
       "options": {
           "parser": "json"
       }
   }]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

setting.json 设置

    {
  // 将设置放入此文件中以覆盖默认设置
  "files.autoSave": "off",
  // 控制字体系列。
  "editor.fontFamily": "Consolas, 'Courier New', monospace,'宋体'",
  "terminal.integrated.shell.windows": "C:\\Program Files\\Git\\bin\\bash.exe",
  // 以像素为单位控制字号。
  "editor.fontSize": 16,
  // 控制选取范围是否有圆角
  "editor.roundedSelection": false,
  // 建议小组件的字号
  "editor.suggestFontSize": 16,
  // 在“打开的编辑器”窗格中显示的编辑器数量。将其设置为 0 可隐藏窗格。
  "explorer.openEditors.visible": 0,
  // 是否已启用自动刷新
  "git.autorefresh": true,
  // 以像素为单位控制终端的字号,这是 editor.fontSize 的默认值。
  "terminal.integrated.fontSize": 14,
  // 控制终端游标是否闪烁。
  "terminal.integrated.cursorBlinking": true,
  // 一个制表符等于的空格数。该设置在 `editor.detectIndentation` 启用时根据文件内容进行重写。
  // Tab Size
  "editor.tabSize": 2,
  // By default, common template. Do not modify it!!!!!
  "editor.formatOnType": true,
  "window.zoomLevel": 0,
  "editor.detectIndentation": false,
  "css.fileExtensions": ["css", "scss"],
  "files.associations": {
    "*.string": "html",
    "*.vue": "vue",
    "*.wxss": "css",
    "*.wxml": "wxml",
    "*.wxs": "javascript",
    "*.cjson": "jsonc",
    "*.js": "javascript"
  },
  // 为指定的语法定义配置文件或使用带有特定规则的配置文件。
  "emmet.syntaxProfiles": {
    "vue-html": "html",
    "vue": "html"
  },
  "search.exclude": {
    "**/node_modules": true,
    "**/bower_components": true
  },
  //保存时eslint自动修复错误
  "editor.formatOnSave": true,
  // Enable per-language
  //配置 ESLint 检查的文件类型
  "editor.quickSuggestions": {
    "strings": true
  },
  // 添加 vue 支持
  // 这里是针对vue文件的格式化设置,vue的规则在这里生效
  "vetur.format.options.tabSize": 2,
  "vetur.format.options.useTabs": false,
  "vetur.format.defaultFormatter.html": "js-beautify-html",
  "vetur.format.defaultFormatter.css": "prettier",
  "vetur.format.defaultFormatter.scss": "prettier",
  "vetur.format.defaultFormatter.postcss": "prettier",
  "vetur.format.defaultFormatter.less": "prettier",
  "vetur.format.defaultFormatter.js": "vscode-typescript",
  "vetur.format.defaultFormatter.sass": "sass-formatter",
  "vetur.format.defaultFormatter.ts": "prettier",
  "vetur.format.defaultFormatterOptions": {
    "js-beautify-html": {
      "wrap_attributes": "aligned-multiple", // 超过150折行
      "wrap-line-length": 150
    },
    // #vue组件中html代码格式化样式
    "prettier": {
      "printWidth": 120,
      "tabWidth": 2,
      "singleQuote": false,
      "trailingComma": "none",
      "semi": false,
      "wrap_line_length": 120,
      "wrap_attributes": "aligned-multiple", // 超过150折行
      "proseWrap": "always",
      "arrowParens": "avoid",
      "bracketSpacing": true,
      "jsxBracketSameLine": true,
      "useTabs": false,
      "overrides": [
        {
          "files": ".prettierrc",
          "options": {
            "parser": "json"
          }
        }
      ]
    }
  },
  // Enable per-language
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "vetur.validation.template": false,
  "html.format.enable": false,
  "json.format.enable": false,
  "javascript.format.enable": false,
  "typescript.format.enable": false,
  "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false,
  "[html]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[jsonc]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[vue]": {
    "editor.defaultFormatter": "octref.vetur"
  },
  "emmet.includeLanguages": {
    "wxml": "html"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  // 开启eslint自动修复js/ts功能
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "minapp-vscode.disableAutoConfig": true,
  "javascript.implicitProjectConfig.experimentalDecorators": true,
  "editor.maxTokenizationLineLength": 200000
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130

# 封装常用组件

# Icon组件

使用iconfont

<template>
  <span @click="$emit('click')" class="iconfont" :class="name" :style="{ color: color, fontSize: size + 'px' }" />
</template>

<script>
import "@/assets/iconfont/iconfont.css";
export default {
  name: "UIcon",
  props: {
    name: {
      type: String,
      default: ""
    },
    color: {
      type: String,
      default: '#1D8AF2'
    },
    size: {
      type: String,
      default: '20'
    }
  },
  data() {
    return {};
  },
  computed: {},
  methods: {}
};
</script>

<style lang="less">
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 角标组件

<template>
  <span 
    class="badge"
    :style="{ background: color || getStatusColor(status) }"
  ></span>
</template>

<script>
  const statuColors = {
    'success': '#52c41a',
    'error': '#f5222d',
    'default': '#d9d9d9',
    'processing': '#1890ff',
    'warning': '#faad14',
  }
  export default {
    name: 'Badge',
    props: {
      status: {
        type: String,
        default: ''
      },
      color: {
        type: String,
        default: ''
      }
    },
    components: {},
    data() {
      return {}
    },
    methods: {
      getStatusColor(status) {
        if (status) {
          return statuColors[status] || statuColors['default']
        }
        return statuColors['default']
      }
    }
  }
</script>

<style lang="less" scoped>
  .badge {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 100%;
    vertical-align: middle;
    margin-right: 8px;
  }
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

# 头部组件

<template>
  <div class="full-page layout">
    <van-nav-bar :title="title" left-arrow @click-left="onClickLeft">
      <template #right>
        <slot name="right"></slot>
      </template>
    </van-nav-bar>
    <div class="content" id="layoutContent">
      <slot></slot>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'Layout',
    props:['title'],
    data() {
      return {

      };
    },
    computed: {},
    methods: {
      onClickLeft(){
        const parentLeftClick = this.$listeners.leftClick
        if (parentLeftClick && typeof parentLeftClick === 'function') {
          this.$emit('leftClick')
          return
        }
        this.$router.go(-1);
      },
    },
  }
</script>

<style lang="less" scoped>
//  @import "index";
  .layout {
  display: flex;
  flex-direction: column;
  .content {
    flex: 1;
    overflow: auto;
  }

  .van-nav-bar {
    height: 88px;
    line-height: 88px;
    // background-color: #112042;
    background-color: @card-background;
    /deep/ .van-icon {
      color: #ffffff;
    }
  }
  .van-hairline--bottom::after {
    border-bottom: none;
  }
}

</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

# 头部编辑组件

<template>
  <div class="wrapper">
    <Layout 
      :title="pageTitle" 
      @leftClick="$emit('cancel')"
      class="fixed-page"
      v-if="type === 'newPageEdit'"
    >
      <div class="fixed-page-wrapper">
        <div class="page-con">
          <slot></slot>
        </div>
        <div class="bottom-btn-group">
          <van-button block square native-type="button" @click="$emit('cancel')">{{ i18nRender('common.btn.cancel') }}</van-button>
          <van-button block square :native-type="confirmBtnType" type="info" @click="onConfirm" :class="{disabled: comfirmBtnDisabled}">{{ i18nRender('common.btn.confirm') }}</van-button>
        </div>
      </div>
    </Layout>
    <div v-else class="fixed-page-wrapper">
      <div class="page-con">
        <slot></slot>
      </div>
      <div class="bottom-btn-group">
        <van-button block square native-type="button" @click="$emit('cancel')">{{ i18nRender('common.btn.cancel') }}</van-button>
        <van-button block square :native-type="confirmBtnType" type="info" @click="onConfirm" :loading="submitLoading" :loading-text="i18nRender('common.btn.confirm')" :class="{disabled: comfirmBtnDisabled}">{{ i18nRender('common.btn.confirm') }}</van-button>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'EditLayout',
    components: {
    },
    data() {
      return {
      }
    },
    props: {
      type: {
        type: String,
        default: 'newPageEdit', // newPageEdit/content
      },
      pageTitle: {
        type: String,
        default: ''
      },
      submitLoading: {
        type: Boolean,
        default: false
      },
      confirmBtnType: {
        type: String,
        default: 'submit'
      },
      comfirmBtnDisabled: {
        type: Boolean,
        default: false
      }
    },
    mounted() {
    },
    computed: {

    },
    methods: {
      onConfirm() {
        if (!this.submitLoading && !this.comfirmBtnDisabled) {
          this.$emit('confirm')
        }
      }
    },
  }
</script>

<style lang="less" scoped>
  @import "index";
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

# 列表组件

<template>
  <div class="common-list-box">
    <div class="operate-title">
      <slot name="add"></slot>
    </div>
    <van-list
      v-model="loading"
      :finished="finished"
      finished-text
      @load="onLoad"
    >
      <!-- :immediate-check="false" -->
      <div v-for="record in list" :key="record[rowKey]" class="common-list-item">
        <div class="common-list-item-title-box">
          <div class="common-list-item-title-con">
            <span class="label">{{ title.label }}:</span>
            <span>{{ record[title.dataIndex] }}</span>
          </div>
          <div class="item-operate">
            <slot name="item-operate" :record="record"></slot>
          </div>
        </div>
        <div class="common-list-item-con">
          <div v-for="(item, index) in contents" :key="index" class="common-list-item-col">
            <span class="label">{{ item.label }}:</span>
            <slot :name="item.dataIndex" :record="record" :text="record[item.dataIndex]"></slot>
            <span v-if="!$scopedSlots[item.dataIndex]">{{ record[item.dataIndex] }}</span>
          </div>
        </div>
      </div>
    </van-list>
  </div>
</template>

<script>
export default {
  name: "CommonList",
  props: {
    // {label:'', dataIndex:''}
    title: {
      type: Object,
      default: () => {
        return {};
      }
    },
    // [{label:'', dataIndex:''}]
    contents: {
      type: Array,
      default: () => {
        return [];
      }
    },
    rowKey: {
      type: String,
      default: "id"
    },
    loadData: {
      type: Function,
      default: () => {}
    },
    // 不分页不滚动加载
    noPagination: {
      type: Boolean,
      default: false
    },
    // 不分页不滚动加载时显性传数组,直接控制列表数据,若noPagination为false不要传
    data: {
      type: Array,
      default: () => {
        return [];
      }
    }
  },
  data() {
    return {
      list: this.data || [], // 列表
      loading: false, // 加载中
      finished: this.noPagination, // 全部加载完
      pageSize: 10,
      pageNum: 0
    };
  },
  watch: {
    data: {
      handler(curVal) {
        this.list = curVal;
      },
      deep: true
    }
  },
  methods: {
    // 加载
    onLoad(callback) {
      if (!this.finished && !this.noPagination) {
        // 异步更新数据
        const params = { pageNum: this.pageNum + 1, pageSize: this.pageSize };
        const result = this.loadData(params);
        if (
          (typeof result === "object" || typeof result === "function") &&
          typeof result.then === "function"
        ) {
          result.then((r = {}) => {
            const { data = [], pageNum, pageSize, total } = r;
            if (params.pageNum === 1) {
              this.list = [...data];
            } else {
              this.list = [...this.list, ...data];
            }
            this.pageSize = pageSize;
            this.pageNum = pageNum;
            // 加载状态结束
            this.loading = false;
            // 数据全部加载完成
            if (pageNum * pageSize >= total) {
              this.finished = true;
            }
            if (callback && typeof callback === 'function') {
              callback()
            }
          });
        }
      }
    },
    // 刷新(flag为true是从第一页开始重置)
    refresh(flag, record, callback) {
      if (flag) {
        this.pageNum = 0;
        this.finished = false;
        this.onLoad(callback);
      } else if (!flag && record[this.rowKey]) {
        this.list = this.list.map(item => {
          if (item[this.rowKey] === record[this.rowKey]) {
            return record;
          }
          return item;
        });
        if (callback && typeof callback === 'function') {
          callback()
        }
      }
    },
    // 删除
    remove(id) {
      this.list = this.list.filter(item => item[this.rowKey] !== id);
    }
  }
};
</script>

<style lang="less" scoped>
@import "index";
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152

# 按钮组组件

<template>
  <div class="buttonList">
    <span class="button" @click="selectdButton(item,i)" v-bind:class="{ active: value===item.value }"
          v-for="(item,i) in listData" :key="item.value" :a="item.value">
      {{item.label}}
    </span>
  </div>
</template>

<script>
  export default {
    name: 'buttonList',
    props: {
      defaultValue:{
        type:String,
        default:''
      },
      listData: {
        type: Array,
        default: () => {
          return []
        }
      },
    },
    components: {},
    data() {
      return {
        activeIndex: 0,
        value:this.defaultValue,
      };
    },
    mounted() {
      console.log(this.value)
    },
    computed: {},
    methods: {
      selectdButton(data) {
        this.value = data.value;
        this.$emit('change', data)
      },
    },
  }
</script>

<style lang="less" scoped>
  @import "index";
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
上次更新: 2022/04/15, 05:41:29
×