Vue3 的中后台管理系统基础建设

Vue3 的中后台管理系统基础建设

vue-cli 创建项目

如果你需要升级版本,那么可以通过以下指令进行升级:

1
npm update -g @vue/cli

终端输入 vue create 项目名称 ,即可进入 模板选择

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
// 利用 vue-cli 创建项目
vue create imooc-admin
// 进入模板选择
Vue CLI v4.5.13
? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3) ([Vue 3] babel, eslint)
> Manually select features // 选择手动配置
// ----------------------------------------------------------
? Check the features needed for your project:
(*) Choose Vue version // 选择 vue 版本
(*) Babel // 使用 babel
( ) TypeScript // 不使用 ts
( ) Progressive Web App (PWA) Support // 不使用 PWA
(*) Router // 添加 vue-router
(*) Vuex // 添加 vuex
>(*) CSS Pre-processors // 使用 css 预处理器
(*) Linter / Formatter // 代码格式化
( ) Unit Testing // 不配置测试
( ) E2E Testing // // 不配置测试
// ----------------------------------------------------------
Choose a version of Vue.js that you want to start the project with
2.x
> 3.x // 选择 vue 3.0 版本
// ----------------------------------------------------------
Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n // 不使用 history模式 的路由
// ----------------------------------------------------------
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default):
> Sass/SCSS (with dart-sass) // 使用基于 dart-sass 的 scss 预处理器
Sass/SCSS (with node-sass)
Less
Stylus
// ----------------------------------------------------------
? Pick a linter / formatter config:
ESLint with error prevention only
ESLint + Airbnb config
> ESLint + Standard config // 使用 ESLint 标准代码格式化方案
ESLint + Prettier
// ----------------------------------------------------------
? Pick additional lint features:
(*) Lint on save //
>(*) Lint and fix on commit // 保存时 && 提交时,都进行 lint
// ----------------------------------------------------------
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files // 单独的配置文件
In package.json
// ----------------------------------------------------------
Save this as a preset for future projects? (y/N) n // 不存储预设

代码规范

eslint

.eslintrc.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/vue3-essential", "@vue/standard"],
parserOptions: {
parser: "@babel/eslint-parser",
},
/**
* 错误级别分为三种:
* "off" 或 0 - 关闭规则
* "warn" 或 1 - 开启规则,使用警告级别的错误:warn (不会导致程序退出)
* "error" 或 2 - 开启规则,使用错误级别的错误:error (当被触发的时候,程序会退出)
* space-before-function-paren表示关闭《方法名后增加空格》的规则
* vue/multi-word-component-names vue文件名关闭
*/
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"space-before-function-paren": "off",
"vue/multi-word-component-names": "off",
},
};
prettier 代码格式化
  1. VSCode 中安装 prettier 插件。

  2. 在项目中新建 .prettierrc 文件,该文件为 perttier 默认配置文件

  3. 在该文件中写入如下配置:

    1
    2
    3
    4
    5
    6
    7
    8
    {
    // 不尾随分号
    "semi": false,
    // 使用单引号
    "singleQuote": true,
    // 多行逗号分割的语法中,最后一行不加逗号
    "trailingComma": "none"
    }
  4. 打开 VSCode 《设置面板》, 搜索 save,勾选 Format On Save

git 提交规范

使用较多的 Angular 团队规范 延伸出的 Conventional Commits specification(约定式提交) git 提交规范

  1. 约定式提交规范要求如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

-------- 翻译 -------------

<类型>[可选 范围]: <描述>

[可选 正文]

[可选 脚注]
  1. 全局安装Commitizen

    1
    npm install -g commitizen@4.2.4
  2. 安装并配置 cz-customizable 插件

    1. 使用 npm 下载 cz-customizable

      1
      npm i cz-customizable@6.3.0 --save-dev
    2. 添加以下配置到 package.json

      1
      2
      3
      4
      5
      6
      ...
      "config": {
      "commitizen": {
      "path": "node_modules/cz-customizable"
      }
      }
  3. 项目根目录下创建 .cz-config.js 自定义提示文件

    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
    module.exports = {
    // 可选类型
    types: [
    { value: "feat", name: "feat: 新功能" },
    { value: "fix", name: "fix: 修复" },
    { value: "docs", name: "docs: 文档变更" },
    { value: "style", name: "style: 代码格式(不影响代码运行的变动)" },
    {
    value: "refactor",
    name: "refactor: 重构(既不是增加feature,也不是修复bug)",
    },
    { value: "perf", name: "perf: 性能优化" },
    { value: "test", name: "test: 增加测试" },
    { value: "chore", name: "chore: 构建过程或辅助工具的变动" },
    { value: "revert", name: "revert: 回退" },
    { value: "build", name: "build: 打包" },
    ],
    // 消息步骤
    messages: {
    type: "请选择提交类型:",
    customScope: "请输入修改范围(可选):",
    subject: "请简要描述提交(必填):",
    body: "请输入详细描述(可选):",
    footer: "请输入要关闭的issue(可选):",
    confirmCommit: "确认使用以上信息提交?(y/n/e/h)",
    },
    // 跳过问题
    skipQuestions: ["body", "footer"],
    // subject文字长度默认是72
    subjectLimit: 72,
    };
  4. 使用 git cz 代替 git commit
    使用 git cz 代替 git commit,即可看到提示内容

  5. 安装依赖:

    1
    npm install --save-dev @commitlint/config-conventional@12.1.4 @commitlint/cli@12.1.4
  6. 创建 commitlint.config.js 文件

    1
    echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
  7. 打开 commitlint.config.js , 增加配置项( config-conventional 默认配置点击可查看 ):

    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
    module.exports = {
    // 继承的规则
    extends: ["@commitlint/config-conventional"],
    // 定义规则类型
    rules: {
    // type 类型定义,表示 git 提交的 type 必须在以下类型范围内
    "type-enum": [
    2,
    "always",
    [
    "feat", // 新功能 feature
    "fix", // 修复 bug
    "docs", // 文档注释
    "style", // 代码格式(不影响代码运行的变动)
    "refactor", // 重构(既不增加新功能,也不是修复bug)
    "perf", // 性能优化
    "test", // 增加测试
    "chore", // 构建过程或辅助工具的变动
    "revert", // 回退
    "build", // 打包
    ],
    ],
    // subject 大小写不做校验
    "subject-case": [0],
    },
    };
  8. 安装依赖:

    1
    npm install husky@7.0.1 --save-dev
  9. 启动 hooks , 生成 .husky 文件夹

    1
    npx husky install
  10. package.json 中生成 prepare 指令( 需要 npm > 7.0 版本

    1
    npm set-script prepare "husky install"
  11. 执行 prepare 指令

    1
    npm run prepare
  12. 执行成功

  13. 添加 commitlinthookhusky中,并指令在 commit-msghooks 下执行 npx --no-install commitlint --edit "$1" 指令

    1
    npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'
  14. 修改 package.json 配置

    1
    2
    3
    4
    5
    6
    "lint-staged": {
    "src/**/*.{js,vue}": [
    "eslint --fix",
    "git add"
    ]
    }
1
2
3
4
5
6
7
8

15. 修改 `.husky/pre-commit` 文件

```js
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged

svg 组件开发

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
<template>
<div
v-if="isExternal"
:style="styleExternalIcon"
class="svg-external-icon svg-icon"
:class="className"
/>
<svg v-else class="svg-icon" :class="className" aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>

<script setup>
import { isExternal as external } from "@/utils/validate";
import { defineProps, computed } from "vue";
const props = defineProps({
// icon 图标
icon: {
type: String,
required: true,
},
// 图标类名
className: {
type: String,
default: "",
},
});

/**
* 判断是否为外部图标
*/
const isExternal = computed(() => external(props.icon));
/**
* 外部图标样式
*/
const styleExternalIcon = computed(() => ({
mask: `url(${props.icon}) no-repeat 50% 50%`,
"-webkit-mask": `url(${props.icon}) no-repeat 50% 50%`,
}));
/**
* 项目内图标
*/
const iconName = computed(() => `#icon-${props.icon}`);
</script>

<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}

.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>

创建 utils/validate.js

1
2
3
4
5
6
/**
* 判断是否为外部资源
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path);
}

views/login/index.vue 中使用 外部 svghttps://res.lgdsunday.club/user.svg):

1
2
3
<span class="svg-container">
<svg-icon icon="https://res.lgdsunday.club/user.svg"></svg-icon>
</span>
  1. icons 文件下 index.js 代码:
1
2
3
4
5
6
7
8
9
10
11
12
import SvgIcon from "@/components/SvgIcon";

// 通过 require.context() 函数来创建自己的 context
const svgRequire = require.context("./svg", false, /\.svg$/);
// 此时返回一个 require 的函数,可以接受一个 request 的参数,用于 require 的导入。
// 该函数提供了三个属性,可以通过 require.keys() 获取到所有的 svg 图标
// 遍历图标,把图标作为 request 传入到 require 导入函数中,完成本地 svg 图标的导入
svgRequire.keys().forEach((svgIcon) => svgRequire(svgIcon));

export default (app) => {
app.component("svg-icon", SvgIcon);
};
  1. main.js 中引入该文件
1
2
3
4
5
6
...
// 导入 svgIcon
import installIcons from '@/icons'
...
installIcons(app)
...

axios 封装

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
import axios from "axios";
import { ElMessage } from "element-plus";
import store from "@/store";
import { isCheckTimeOut } from "@/utils/auth";

const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000,
});

// 请求拦截器
service.interceptors.request.use(
(config) => {
// 在这个位置需要统一的去注入token
if (store.getters.token) {
// 时效 token处理
if (isCheckTimeOut()) {
store.dispatch("user/logout");
return Promise.reject(new Error("token失效"));
}
// 如果token存在 注入token
config.headers.Authorization = `Bearer ${store.getters.token}`;
}
return config; // 必须返回配置
},
(error) => {
// token超时问题,根据后端接口定义的code值进行判断
// if (error.data.code === 401) {}
return Promise.reject(error);
}
);

// 响应拦截器
service.interceptors.response.use(
(response) => {
const { success, message, data } = response.data;
// 要根据success的成功与否决定下面的操作
if (success) {
return data;
} else {
// 业务错误
ElMessage.error(message); // 提示错误消息
return Promise.reject(new Error(message));
}
},
(error) => {
// TODO: 将来处理 token 超时问题
ElMessage.error(error.message); // 提示错误信息
return Promise.reject(error);
}
);

export default service;
  1. 在用户登陆时,记录当前 登录时间
  2. 制定一个 失效时长
  3. 在接口调用时,根据 当前时间 对比 登录时间 ,看是否超过了 时效时长
    1. 如果未超过,则正常进行后续操作
    2. 如果超过,则进行 退出登录 操作
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
import { TIME_STAMP, TOKEN_TIMEOUT_VALUE } from "@/constant";
import { setItem, getItem } from "@/utils/storage";

/**
* 获取时间戳
*/
export function getTimeStamp() {
return getItem(TIME_STAMP);
}

/**
* 设置时间戳
*/
export function setTimeStamp() {
return setItem(TIME_STAMP, Date.now());
}

/**
* 是否超时
*/
export function isCheckTimeOut() {
// 当前时间戳
const currentTime = Date.now();
// 缓存时间戳
const timeStamp = getTimeStamp();
return currentTime - timeStamp > TOKEN_TIMEOUT_VALUE;
}

面包屑结构数据

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
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbData"
:key="item.path"
>
<!-- 不可点击项 -->
<span v-if="index === breadcrumbData.length - 1" class="no-redirect">{{
generateTitle(item.meta.title)
}}</span>
<!-- 可点击项 -->
<a v-else class="redirect" @click.prevent="onLinkClick(item)">{{
generateTitle(item.meta.title)
}}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>

<script setup>
import { generateTitle } from "@/utils/i18n";
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";

// generateTitle国际化处理title

const route = useRoute();
// 生成数组数据
const breadcrumbData = ref([]);
const getBreadcrumbData = () => {
breadcrumbData.value = route.matched.filter(
(item) => item.meta && item.meta.title
);
};
// 监听路由变化时触发
watch(
route,
() => {
getBreadcrumbData();
},
{
immediate: true,
}
);

// 处理点击事件
const router = useRouter();
const onLinkClick = (item) => {
router.push(item.path);
};

// 将来需要进行主题替换,所以这里获取下动态样式
const store = useStore();
// eslint-disable-next-line
const linkHoverColor = ref(store.getters.cssVar.menuBg);
</script>

<style lang="scss" scoped>
.breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;

.no-redirect {
color: #97a8be;
cursor: text;
}

.redirect {
color: #666;
font-weight: 600;
}

.redirect:hover {
// 将来需要进行主题替换,所以这里不去写死样式
color: v-bind(linkHoverColor);
}
}
</style>

国际化 vue-i18n

vue3 下需要使用 V 9.x 的 i18n

  1. 安装 vue-i18n

    1
    npm install vue-i18n@next
  2. 创建 i18n/index.js 文件

    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
    import { createI18n } from "vue-i18n";
    import mZhLocale from "./lang/zh";
    import mEnLocale from "./lang/en";
    import store from "@/store";

    const messages = {
    en: {
    msg: {
    ...mEnLocale,
    },
    },
    zh: {
    msg: {
    ...mZhLocale,
    },
    },
    };
    // vuex管理语言的state
    function getLanguage() {
    return store && store.getters && store.getters.language;
    }
    const i18n = createI18n({
    // 使用 Composition API 模式,则需要将其设置为false
    legacy: false,
    // 全局注入 $t 函数
    globalInjection: true,
    locale: getLanguage(),
    messages,
    });

    export default i18n;
  3. main.js 中导入

    1
    2
    3
    import i18n from "@/i18n";

    app.use(i18n);
  4. element-puls 语言处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import zh from "element-plus/es/locale/lang/zh-cn";
    import en from "element-plus/es/locale/lang/en";
    import "element-plus/dist/index.css";
    app
    .use(ElementPlus, {
    locale: store.getters.language === "en" ? en : zh,
    })
    .use(store)
    .use(router)
    .use(i18n)
    .mount("#app");

动态换肤原理

动态换肤在实际的后台管理系统项目开发中基本用不到的,只简单讲下实现原理方法。

  1. 动态换肤的关键是修改 css 变量 的值, vuex 设置全局 css 变量动态棒的。

  2. element-plus 主体变更通过修改 scss 变量 的形式修改主题色完成主题变更。获取 element-plus 所有的 style,更换样式的部分使用正则完成替换。把替换后的样式写入到 style 标签中。

  3. rgb-hex:转换 RGB(A)颜色为十六进制

  4. css-color-function:在 CSS 中提出的颜色函数的解析器和转换器

页面全屏

浏览器本身已经提供 API,Document.exitFullscreen()和 Element.requestFullscreen(),但有一定的小问题,比如 appmain 区域背景颜色为黑色。

  1. screenfull

    1
    npm i screenfull@5.1.0
  2. 创建 components/Screenfull/index

    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
    <template>
    <div>
    <svg-icon
    :icon="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
    @click="onToggle"
    />
    </div>
    </template>

    <script setup>
    import { ref, onMounted, onUnmounted } from "vue";
    import screenfull from "screenfull";

    // 是否全屏
    const isFullscreen = ref(false);

    // 监听变化
    const change = () => {
    isFullscreen.value = screenfull.isFullscreen;
    };

    // 切换事件
    const onToggle = () => {
    screenfull.toggle();
    };

    // 设置侦听器
    onMounted(() => {
    screenfull.on("change", change);
    });

    // 删除侦听器
    onUnmounted(() => {
    screenfull.off("change", change);
    });
    </script>

    <style lang="scss" scoped></style>

headerSearch 原理及方案分析

指定搜索框中对当前应用中所有页面进行检索,以 select 的形式展示出被检索的页面,以达到快速进入的目的

  1. 获取所有的页面数据,用作被检索的数据源
  2. 根据用户输入内容在数据源中进行模糊搜索

    1
    npm install --save fuse.js
  3. 把搜索到的内容以 select 进行展示

  4. 监听 selectchange 事件,完成对应跳转

tabsView

  1. 监听路由的变化,获取路由的数据

  2. 右键根据不同类型处理展示数据

/TagsView/ContextMenu.vue

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
<template>
<ul class="context-menu-container">
<li @click="onRefreshClick">
{{ $t("msg.tagsView.refresh") }}
</li>
<li @click="onCloseRightClick">
{{ $t("msg.tagsView.closeRight") }}
</li>
<li @click="onCloseOtherClick">
{{ $t("msg.tagsView.closeOther") }}
</li>
</ul>
</template>

<script setup>
import { defineProps } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
const props = defineProps({
index: {
type: Number,
required: true,
},
});

const router = useRouter();
const onRefreshClick = () => {
router.go(0);
};

const store = useStore();
const onCloseRightClick = () => {
store.commit("app/removeTagsView", {
type: "right",
index: props.index,
});
};

const onCloseOtherClick = () => {
store.commit("app/removeTagsView", {
type: "other",
index: props.index,
});
};
</script>

<style lang="scss" scoped>
.context-menu-container {
position: fixed;
background: #fff;
z-index: 3000;
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, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
</style>

/TagsView/index.vue

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
153
154
155
156
157
<template>
<div class="tags-view-container">
<el-scrollbar class="tags-view-wrapper">
<router-link
class="tags-view-item"
:class="isActive(tag) ? 'active' : ''"
:style="{
backgroundColor: isActive(tag) ? $store.getters.cssVar.menuBg : '',
borderColor: isActive(tag) ? $store.getters.cssVar.menuBg : '',
}"
v-for="(tag, index) in $store.getters.tagsViewList"
:key="tag.fullPath"
:to="{ path: tag.fullPath }"
@contextmenu.prevent="openMenu($event, index)"
>
{{ tag.title }}
<i
v-show="!isActive(tag)"
class="el-icon-close"
@click.prevent.stop="onCloseClick(index)"
/>
</router-link>
</el-scrollbar>
<context-menu
v-show="visible"
:style="menuStyle"
:index="selectIndex"
></context-menu>
</div>
</template>

<script setup>
import ContextMenu from "./ContextMenu.vue";
import { ref, reactive, watch } from "vue";
import { useRoute } from "vue-router";
import { useStore } from "vuex";
const route = useRoute();

/**
* 是否被选中
*/
const isActive = (tag) => {
return tag.path === route.path;
};

/**
* 关闭 tag 的点击事件
*/
const store = useStore();
const onCloseClick = (index) => {
store.commit("app/removeTagsView", {
type: "index",
index: index,
});
};

// contextMenu 相关
const selectIndex = ref(0);
const visible = ref(false);
const menuStyle = reactive({
left: 0,
top: 0,
});
/**
* 展示 menu
*/
const openMenu = (e, index) => {
const { x, y } = e;
menuStyle.left = x + "px";
menuStyle.top = y + "px";
selectIndex.value = index;
visible.value = true;
};

/**
* 关闭 menu
*/
const closeMenu = () => {
visible.value = false;
};

/**
* 监听变化
*/
watch(visible, (val) => {
if (val) {
document.body.addEventListener("click", closeMenu);
} else {
document.body.removeEventListener("click", 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, 0.12), 0 0 3px 0 rgba(0, 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 {
color: #fff;
&::before {
content: "";
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 4px;
}
}
// close 按钮
.el-icon-close {
width: 16px;
height: 16px;
line-height: 10px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 监听路由变化
*/
const store = useStore();
watch(
route,
(to, from) => {
if (!isTags(to.path)) return;
const { fullPath, meta, name, params, path, query } = to;
store.commit("app/addTagsViewList", {
fullPath,
meta,
name,
params,
path,
query,
title: getTitle(to),
});
},
{
immediate: 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
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
import { TAGS_VIEW } from "@/constant";
import { getItem, setItem } from "@/utils/storage";

export default {
namespaced: true,
state: () => ({
tagsViewList: getItem(TAGS_VIEW) || [],
}),
mutations: {
/**
* 添加 tags
*/
addTagsViewList(state, tag) {
const isFind = state.tagsViewList.find((item) => {
return item.path === tag.path;
});
// 处理重复
if (!isFind) {
state.tagsViewList.push(tag);
setItem(TAGS_VIEW, state.tagsViewList);
}
},
/**
* 为指定的 tag 修改 title
*/
changeTagsView(state, { index, tag }) {
state.tagsViewList[index] = tag;
setItem(TAGS_VIEW, state.tagsViewList);
},
/**
* 删除 tag
* @param {type: 'other'||'right'||'index', index: index} payload
*/
removeTagsView(state, payload) {
if (payload.type === "index") {
state.tagsViewList.splice(payload.index, 1);
return;
} else if (payload.type === "other") {
state.tagsViewList.splice(
payload.index + 1,
state.tagsViewList.length - payload.index + 1
);
state.tagsViewList.splice(0, payload.index);
} else if (payload.type === "right") {
state.tagsViewList.splice(
payload.index + 1,
state.tagsViewList.length - payload.index + 1
);
}
setItem(TAGS_VIEW, state.tagsViewList);
},
},
actions: {},
};

局部打印

浏览器右键时,其实可以直接看到对应的 打印 选项,但是这个打印选项是直接打印整个页面,不能指定打印页面中的某一部分的。

1
npm i vue3-print-nb@0.1.4

然后利用该工具完成下载功能:

  1. 指定 printLoading

    1
    2
    3
    4
    5
    6
    <el-button
    type="primary"
    :loading="printLoading"
    >{{ $t('msg.userInfo.print')}}</el-button>
    // 打印相关
    const printLoading = ref(false)
  2. 创建打印对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const printObj = {
    // 打印区域
    id: "userInfoBox",
    // 打印标题
    popTitle: "imooc-vue-element-admin",
    // 打印前
    beforeOpenCallback(vue) {
    printLoading.value = true;
    },
    // 执行打印
    openCallback(vue) {
    printLoading.value = false;
    },
    };
  3. 指定打印区域 id 匹配

    1
    <div id="userInfoBox" class="user-info-box"></div>
  4. 写入如下代码

    1
    2
    3
    4
    5
    import print from "vue3-print-nb";

    export default (app) => {
    app.use(print);
    };
  5. main.js 中导入该指令

    1
    2
    import installDirective from "@/directives";
    installDirective(app);
  6. 将打印指令挂载到 el-button

    1
    2
    3
    <el-button type="primary" v-print="printObj" :loading="printLoading"
    >{{ $t('msg.userInfo.print') }}</el-button
    >

RBAC 权限系统

基于 角色的权限 控制 用户的访问。员工管理为用户指定角色、通过角色列表为角色指定权限、通过权限列表查看当前项目所有权限。

permission.js,在 main.js 引入,进行路由权限控制判断,获取当前用户权限菜单进行数据处理整合,最好路由菜单的渲染

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
import router from "./router";
import store from "./store";

// 白名单
const whiteList = ["/login"];
/**
* 路由前置守卫
*/
router.beforeEach(async (to, from, next) => {
if (store.getters.token) {
if (to.path === "/login") {
next("/");
} else {
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action,并获取用户当前权限
const { permission } = await store.dispatch("user/getUserInfo");
// 处理用户权限,筛选出需要添加的权限
const filterRoutes = await store.dispatch(
"permission/filterRoutes",
permission.menus
);
// 利用 addRoute 循环添加
filterRoutes.forEach((item) => {
router.addRoute(item);
});
// 添加完动态路由之后,需要在进行一次主动跳转
return next(to.path);
}
next();
}
} else {
// 没有token的情况下,可以进入白名单
if (whiteList.indexOf(to.path) > -1) {
next();
} else {
next("/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
// 专门处理权限路由的模块,privateRoutes:依据权限进行动态配置的
import { publicRoutes, privateRoutes } from "@/router";
export default {
namespaced: true,
state: {
// 路由表:初始拥有静态路由权限
routes: publicRoutes,
},
mutations: {
/**
* 增加路由
*/
setRoutes(state, newRoutes) {
// 永远在静态路由的基础上增加新路由
state.routes = [...publicRoutes, ...newRoutes];
},
},
actions: {
/**
* 根据权限筛选路由
*/
filterRoutes(context, menus) {
const routes = [];
// 路由权限匹配
menus.forEach((key) => {
// 权限名 与 路由的 name 匹配
routes.push(...privateRoutes.filter((item) => item.name === key));
});
// 最后添加 不匹配路由进入 404
routes.push({
path: "/:catchAll(.*)",
redirect: "/404",
});
context.commit("setRoutes", routes);
return routes;
},
},
};

按钮级别权限自定义指令

  1. 我们期望最终可以通过这样格式的指令进行功能受控 v-permission="['importUser']"

  2. 以此创建对应的自定义指令 directives/permission

    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
    import store from "@/store";

    function checkPermission(el, binding) {
    // 获取绑定的值,此处为权限
    const { value } = binding;
    // 获取所有的功能指令
    const points = store.getters.userInfo.permission.points;
    // 当传入的指令集为数组时
    if (value && value instanceof Array) {
    // 匹配对应的指令
    const hasPermission = points.some((point) => {
    return value.includes(point);
    });
    // 如果无法匹配,则表示当前用户无该指令,那么删除对应的功能按钮
    if (!hasPermission) {
    el.parentNode && el.parentNode.removeChild(el);
    }
    } else {
    // eslint-disabled-next-line
    throw new Error('v-permission value is ["admin","editor"]');
    }
    }

    export default {
    // 在绑定元素的父组件被挂载后调用
    mounted(el, binding) {
    checkPermission(el, binding);
    },
    // 在包含组件的 VNode 及其子组件的 VNode 更新后调用
    update(el, binding) {
    checkPermission(el, binding);
    },
    };
  3. directives/index 中绑定该指令

    1
    2
    3
    4
    5
    6
    7
    ...
    import permission from './permission'

    export default (app) => {
    ...
    app.directive('permission', permission)
    }
  4. 在所有功能中,添加该指令

  5. views/role-list/index

    1
    <el-button v-permission="['distributePermission']"> 删除 </el-button>

动态表格渲染方案

展示可勾选的列,动态展示表格的列。通过 v-for 渲染 el-table-column ,当数据改变时 el-table-column 的渲染自然也就发生了变化

  1. v-for 渲染所有可勾选的列的列,默认全部勾选,当数据发生变化,通过 watch 监听,filter 得到勾选后变化的列。

  2. table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<el-table ref="tableRef" :data="tableData" border>
<el-table-column
v-for="(item, index) in tableColumns"
:key="index"
:prop="item.prop"
:label="item.label"
>
<template #default="{ row }" v-if="item.prop === 'publicDate'"> </template>
<template #default="{ row }" v-else-if="item.prop === 'action'">
<el-button type="primary" size="mini" @click="onShowClick(row)"
>查看</el-button
>
<el-button type="danger" size="mini" @click="onRemoveClick(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>

markdown

功能需要满足基本需求,star 在 10K 以上的,作者对该库的维护程度,文档越详尽越好。

  1. markdown 编辑器:
    1. tui.editorMarkdown 所见即所得编辑器-高效且可扩展,使用 MIT 开源协议。
    2. editor:纯文本 markdown 编辑器
    3. editor.md:开源可嵌入的在线markdown编辑器(组件),基于 CodeMirror & jQuery & Marked。国产
    4. markdown-here:谷歌开源,但是已经 多年不更新
    5. stackedit:基于PageDownStack Overflow和其他 Stack Exchange 站点使用的Markdown库的功能齐全的开源 Markdown 编辑器。两年未更新了
    6. markdown-it:可配置语法,可添加、替换规则。挺长时间未更新了
  2. 富文本编辑器:
    1. wangEditor:国产、文档详尽、更新快速
    2. tinymce:对 IE6+Firefox1.5+ 都有着非常良好的支持
    3. quill:代码高亮功能、视频加载功能、公式处理比较强。
    4. ckeditor5:编辑能力强
    5. wysiwyg-editor收费的 , 就是牛
使用
  1. 下载 tui.editor

    1
    npm i @toast-ui/editor@3.0.2
  2. 渲染基本结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <template>
    <div class="markdown-container">
    <!-- 渲染区 -->
    <div id="markdown-box"></div>
    </div>
    </div>
    </template>

    <script setup>
    import {} from 'vue'
    </script>

    <style lang="scss" scoped>
    .markdown-container {
    .bottom {
    margin-top: 20px;
    text-align: right;
    }
    }
    </style>
  3. 初始化 editor ,处理国际化内容

    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
    <script setup>
    import MkEditor from "@toast-ui/editor";
    import "@toast-ui/editor/dist/toastui-editor.css";
    import "@toast-ui/editor/dist/i18n/zh-cn";
    import { onMounted } from "vue";
    import { useStore } from "vuex";

    // Editor实例
    let mkEditor;
    // 处理离开页面切换语言导致 dom 无法被获取
    let el;
    onMounted(() => {
    el = document.querySelector("#markdown-box");
    initEditor();
    });

    const store = useStore();
    const initEditor = () => {
    mkEditor = new MkEditor({
    el,
    height: "500px",
    previewStyle: "vertical",
    language: store.getters.language === "zh" ? "zh-CN" : "en",
    });

    mkEditor.getMarkdown();
    };
    </script>
  4. 在语言改变时,重置 editor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import { watchSwitchLang } from "@/utils/i18n";

    watchSwitchLang(() => {
    if (!el) return;
    const htmlStr = mkEditor.getHTML();
    mkEditor.destroy();
    initEditor();
    mkEditor.setHTML(htmlStr);
    });

wangeditor 富文本

1
npm i wangeditor@4.7.6

安装完成之后,我们就去实现对应的代码逻辑:

  1. 创建基本组件结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <template>
    <div class="editor-container">
    <div id="editor-box"></div>
    <div class="bottom">
    <el-button type="primary" @click="onSubmitClick">{{
    $t("msg.article.commit")
    }}</el-button>
    </div>
    </div>
    </template>

    <script setup>
    import {} from "vue";
    </script>

    <style lang="scss" scoped>
    .editor-container {
    .bottom {
    margin-top: 20px;
    text-align: right;
    }
    }
    </style>
  2. 初始化 wangEditor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    <script setup>
    import E from "wangeditor";
    import { onMounted } from "vue";

    // Editor实例
    let editor;
    // 处理离开页面切换语言导致 dom 无法被获取
    let el;
    onMounted(() => {
    el = document.querySelector("#editor-box");
    initEditor();
    });

    const initEditor = () => {
    editor = new E(el);
    editor.config.zIndex = 1;
    // 菜单栏提示
    editor.config.showMenuTooltips = true;
    editor.config.menuTooltipPosition = "down";
    editor.create();
    };
    </script>
  3. wangEditor国际化处理,官网支持 i18next,所以想要处理 wangEditor 的国际化,那么我们需要安装 i18next

    1
    npm i --save i18next@20.4.0
  4. 处理提交事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import { onMounted, defineProps, defineEmits } from 'vue'
    import { commitArticle } from './commit'

    const props = defineProps({
    title: {
    required: true,
    type: String
    }
    })

    const emits = defineEmits(['onSuccess'])
    ...
    const onSubmitClick = async () => {
    // 创建文章
    await commitArticle({
    title: props.title,
    content: editor.txt.html()
    })

    editor.txt.html('')
    emits('onSuccess')
    }
  5. 不要忘记在 article-create 中处理对应事件

    1
    <editor :title="title" :detail="detail" @onSuccess="onSuccess"></editor>
  6. 最后处理编辑

    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
    const props = defineProps({
    ...
    detail: {
    type: Object
    }
    })

    // 编辑相关
    watch(
    () => props.detail,
    (val) => {
    if (val && val.content) {
    editor.txt.html(val.content)
    }
    },
    {
    immediate: true
    }
    )

    const onSubmitClick = async () => {
    if (props.detail && props.detail._id) {
    // 编辑文章
    await editArticle({
    id: props.detail._id,
    title: props.title,
    content: editor.txt.html()
    })
    } else {
    // 创建文章
    await commitArticle({
    title: props.title,
    content: editor.txt.html()
    })
    }

    editor.txt.html('')
    emits('onSuccess')
    }