icodingicoding
主页
JavaScript
Vue
React
TypeScript
Node
bug
笔记
时间线
主页
JavaScript
Vue
React
TypeScript
Node
bug
笔记
时间线
  • Markdown 语法
  • 插件
  • Vue
  • 组件设计
  • Element-Ui
  • WebSocket
  • CSS
  • Uniapp
  • 进阶
  • 扫码枪
  • Nginx
  • Nuxt.js
  • Vue 时钟
  • Learning
  • Linux
  • 打包优化
  • 大屏可视化
  • Jenkins
  • SVN
  • JsDocs
  • 代码规范

Vue

泛型组件

  • 组件 props 如何添加泛型,<script setup lang="ts" generic="T">;vue 官网地址
<template>
  <div class="rtc-table">
    <el-table
      :border="border"
      :data="props.tableData"
      style="width: 100%"
      v-auto-height="autoHeight ? bottom : 0"
      v-bind="{ ...$attrs, ...tableConfig }"
    >
      <template v-for="item in curClums">
        <template v-if="item.type === 'selection'">
          <el-table-column
            :resizable="false"
            :prop="item.prop || ''"
            :label="item.label"
            :align="item.align || 'center'"
            :width="item.width"
            type="selection"
            v-bind="item.extral"
          >
          </el-table-column>
        </template>
        <el-table-column
          v-else
          :resizable="false"
          :prop="item.prop || ''"
          :label="item.label"
          :align="item.align || 'center'"
          :width="item.width"
          v-bind="item.extral"
        >
          <template #header="scope: TableColumnDefaultSlot">
            <slot name="header" v-bind="{ ...scope, colItem: item }">
              <ReText :tippyProps="{ content: scope.column.label }">
                {{ scope.column.label || "-" }}
              </ReText>
            </slot>
          </template>
          <template #default="scope: TableColumnDefaultSlot">
            <slot :name="item.slotName" v-bind="scope">
              <ReText :tippyProps="{ content: scope.row[item.prop] }">
                {{ scope.row[item.prop] || "-" }}
              </ReText>
            </slot>
          </template>
        </el-table-column>
      </template>
    </el-table>
    <div v-if="props.pagination" class="table-pagination">
      <el-pagination
        v-model:current-page="props.pagingInfo.pageIndex"
        v-model:page-size="props.pagingInfo.pageSize"
        :page-sizes="[10, 20, 50, 100]"
        :small="small"
        background
        layout="total, sizes, prev, pager, next, jumper"
        :total="props.pagingInfo.total"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts" generic="T">
import { computed, ref } from "vue";
import { ReText } from "@/components/ReText";
import type { ColumnItem, PagingInfo } from "./table";
import { type RenderRowData, type TableProps } from "element-plus";
defineOptions({
  name: "RtcTable",
});

type TableColumnDefaultSlot = RenderRowData<T>;

// 组件属性类型声明不能通过外部导入,否则 TableColumnDefaultSlot 会不生效
interface Props {
  tableData: T[];
  columns: ColumnItem<T>[];
  pagination?: boolean;
  pagingInfo?: PagingInfo;
  tableConfig?: Omit<TableProps<T>, "data">;
  autoHeight?: boolean;
  bottom?: number; // 距离底部距离
  border?: boolean; // 表格边框
}
const props = withDefaults(defineProps<Props>(), {
  tableData: () => [] as T[],
  columns: () => [],
  pagination: true,
  pagingInfo: () => ({
    pageIndex: 1,
    pageSize: 10,
    total: 0,
  }),
  tableConfig: () => ({}),
  bottom: 80,
  border: true,
});

const emits = defineEmits(["update:pagingInfo", "pagingChange"]);

const curClums = computed(() => {
  return props.columns.filter((item) => {
    if (typeof item.show === "function") return item.show(item);
    return item.show === undefined || item.show;
  });
});
const small = ref(false);

const emitPagingInfo = (pagingInfo: PagingInfo) => {
  emits("update:pagingInfo", pagingInfo);
  emits("pagingChange", pagingInfo);
};
const handleSizeChange = (val: number) => {
  const pagingInfo = { ...props.pagingInfo, pageSize: val, pageIndex: 1 };
  emitPagingInfo(pagingInfo);
};
const handleCurrentChange = (val: number) => {
  const pagingInfo = { ...props.pagingInfo, pageIndex: val };
  emitPagingInfo(pagingInfo);
};
</script>

<style scoped lang="scss">
.table-pagination {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}
</style>

具体步骤如下:

  • script 标签添加 generic 属性,<script setup lang="ts" generic="T">

注意

使用 generic 支持泛型后,组件不能使用 export 导出类型,因此对于需要导出组件的类型,要放到单独的文件中。这个问题不知后面 vue 脚手架是否得到解决

  • 给tableData添加泛型约束:tableData: T[];
interface Props {
  tableData: T[]; // T 把组件接收到的泛型赋值给 tableData
  columns: ColumnItem<T>[];
  pagination?: boolean;
  pagingInfo?: PagingInfo;
  tableConfig?: Omit<TableProps<T>, "data">;
  autoHeight?: boolean;
  bottom?: number; // 距离底部距离
  border?: boolean; // 表格边框
}
  • 指定插槽返回值类型
type TableColumnDefaultSlot = RenderRowData<T>;
  • 在template中使用
<template #header="scope: TableColumnDefaultSlot">
  <slot name="header" v-bind="{ ...scope, colItem: item }">
    <ReText :tippyProps="{ content: scope.column.label }">
      {{ scope.column.label || "-" }}
    </ReText>
  </slot>
</template>
<template #default="scope: TableColumnDefaultSlot">
  <slot :name="item.slotName" v-bind="scope">
    <ReText :tippyProps="{ content: scope.row[item.prop] }">
      {{ scope.row[item.prop] || "-" }}
    </ReText>
  </slot>
</template>
  • 插槽:在模板中就可以用鼠标看到属性的类型

iamge

递归组件

element plus 封装 table 组件,使用递归生成 el-table-column 实现多级表头

  • 封装 tableColumn 递归组件
<template>
  <template v-if="props.cItem.type === 'selection'">
    <el-table-column v-bind="{ type: props.cItem.type, ...columnProps }" />
  </template>
  <el-table-column v-else v-bind="columnProps">
    <template #header="scope: TableColumnDefaultSlot">
      <slot name="header" v-bind="{ ...scope, colItem: cItem }">
        <ReText :tippyProps="{ content: scope.column.label }">
          {{ scope.column.label || "-" }}
        </ReText>
      </slot>
    </template>
    <template #default="scope: TableColumnDefaultSlot">
      <slot :name="props.cItem.slotName" v-bind="scope">
        <ReText :tippyProps="{ content: scope.row[props.cItem.prop] }">
          {{
            scope.row[props.cItem.prop] === 0
              ? scope.row[props.cItem.prop]
              : scope.row[props.cItem.prop] || "-"
          }}
        </ReText>
      </slot>
      <tableColumn v-for="sitem in colChildren" :cItem="sitem">
        <template #header="scope: TableColumnDefaultSlot">
          <slot name="header" v-bind="scope"></slot>
        </template>
        <template #[sitem.slotName]="scope: TableColumnDefaultSlot">
          <slot :name="sitem.slotName" v-bind="scope"></slot>
        </template>
      </tableColumn>
    </template>
  </el-table-column>
</template>

<script setup lang="ts" generic="T">
import { ReText } from "@/components/ReText";
import { type RenderRowData } from "element-plus";
import type { ColumnItem } from "./table";
import { computed } from "vue";

type TableColumnDefaultSlot = RenderRowData<T>;
type TableColumnsItem = ColumnItem<T>;

interface Props {
  cItem: TableColumnsItem;
}
defineOptions({ name: "tableColumn" });
const props = withDefaults(defineProps<Props>(), {
  cItem: () => ({}) as TableColumnsItem,
});
const columnProps = computed(() => {
  const { cItem } = props;
  return {
    resizable: cItem.resizable || false,
    prop: cItem.prop,
    label: cItem.label,
    align: cItem.align || "center",
    width: cItem.width,
    ...cItem.extral,
  };
});
const colChildren = computed(() => {
  const { cItem } = props;
  if (cItem.children && cItem.children.length) {
    return cItem.children.filter((item) => item.show);
  }
  return [];
});
</script>
  • 封装table组件,使用tableColumn组件
<template>
  <div class="rtc-table">
    <diyColumn
      v-if="showSetting"
      :columns="curClums"
      @change="onColumnChange"
    />
    <el-table
      :border="border"
      :data="props.tableData"
      style="width: 100%"
      v-auto-height="autoHeight ? bottom : 0"
      v-bind="{ ...$attrs, ...tableConfig }"
    >
      <template v-for="item in showColumns">
        <tableColumn v-if="item.show" :cItem="item">
          <template v-for="(_, key) in slots" #[key]="scope">
            <slot :name="key" v-bind="scope"></slot>
          </template>
        </tableColumn>
      </template>
    </el-table>
    <div v-if="props.pagination" class="table-pagination">
      <el-pagination
        v-model:current-page="props.pagingInfo.pageIndex"
        v-model:page-size="props.pagingInfo.pageSize"
        :page-sizes="[10, 20, 50, 100]"
        :small="small"
        background
        layout="total, sizes, prev, pager, next, jumper"
        :total="props.pagingInfo.total"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts" generic="T">
import { ref, watch, useSlots } from "vue";
import type { ColumnItem, PagingInfo } from "./table";
import { type TableProps } from "element-plus";

import diyColumn from "./diyColumn.vue";
import tableColumn from "./tableColumn.vue";
import { deepClone } from "@/utils/data";

defineOptions({ name: "RtcTable" });

type TableColumnsItem = ColumnItem<T>;
interface Props {
  tableData: T[];
  columns: TableColumnsItem[];
  pagination?: boolean;
  pagingInfo?: PagingInfo;
  tableConfig?: Omit<TableProps<T>, "data">;
  autoHeight?: boolean;
  bottom?: number; // 距离底部距离
  border?: boolean; // 表格边框
  showSetting?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  tableData: () => [] as T[],
  columns: () => [],
  pagination: true,
  pagingInfo: () => ({
    pageIndex: 1,
    pageSize: 10,
    total: 0,
  }),
  tableConfig: () => ({}),
  bottom: 80,
  border: true,
  showSetting: false,
});

const emits = defineEmits(["update:pagingInfo", "pagingChange"]);
const slots = useSlots();

const small = ref(false);

const emitPagingInfo = (pagingInfo: PagingInfo) => {
  emits("update:pagingInfo", pagingInfo);
  emits("pagingChange", pagingInfo);
};
const handleSizeChange = (val: number) => {
  const pagingInfo = { ...props.pagingInfo, pageSize: val, pageIndex: 1 };
  emitPagingInfo(pagingInfo);
};
const handleCurrentChange = (val: number) => {
  const pagingInfo = { ...props.pagingInfo, pageIndex: val };
  emitPagingInfo(pagingInfo);
};

const curClums = ref<TableColumnsItem[]>([]);
const showColumns = ref<TableColumnsItem[]>([]);
const initColumns = (list: TableColumnsItem[]) => {
  list.forEach((item) => {
    item._columnKey = `${item.prop}_${item.label}`;
    item.show = item.show === undefined || item.show;
    if (item.children && item.children.length) {
      initColumns(item.children);
    }
  });
};
watch(
  () => props.columns,
  () => {
    curClums.value = deepClone(props.columns);
    initColumns(curClums.value);
    showColumns.value = curClums.value;
  },
  { immediate: true, deep: true }
);
const onColumnChange = (value: TableColumnsItem[]) => {
  showColumns.value = value;
};
</script>

<style scoped lang="scss">
.rtc-table {
  position: relative;
}
.table-pagination {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}
</style>

难点

父组件如何给递归组件传递插槽?

  • 使用 useSlots 钩子,把父组件接收到的插槽,传递给递归组件
  • 细节 slots = useSlots() 返回插槽对象,对象的 key 记录的是插槽的名称,value 是插槽对应的函数(调用该函数会得到插槽的 vdom 对象),因此遍历 slots 对象,把父组件接收到的插槽,动态传到递归组件
  • 核心代码如下
const slots = useSlots();
<template v-for="item in showColumns">
  <tableColumn v-if="item.show" :cItem="item">
    <template v-for="(_, key) in slots" #[key]="scope">
      <slot :name="key" v-bind="scope"></slot>
    </template>
  </tableColumn>
</template>

多系统共享登录方案

在 Vue 项目中,如果你希望在登录了 A 系统后,能够无缝访问 B 系统而不需要再次登录,可以考虑以下几种方法来传递登录态:

1. 使用 Token 传递

如果 A 系统和 B 系统都支持 Token 认证(如 JWT),你可以在 A 系统登录后,将获取到的 Token 通过 URL 或者 HTTP 请求的方式传递给 B 系统。

步骤:

  1. 在 A 系统中获取 Token:用户登录后,A 系统会返回一个 Token。

  2. 构造 B 系统的 URL:在 A 系统中,点击按钮时,构造 B 系统的 URL,并将 Token 作为参数附加到 URL 中。例如:

    const token = "your_token_here"; // 从 A 系统获取的 Token
    const bSystemUrl = `https://b-system.com?token=${token}`;
    window.location.href = bSystemUrl; // 跳转到 B 系统
    
  3. 在 B 系统中接收 Token:B 系统在接收到请求时,从 URL 中提取 Token,并进行验证。如果 Token 有效,则允许用户访问。

2. 使用 SSO(单点登录)

如果 A 系统和 B 系统都支持单点登录(SSO),可以通过 SSO 机制来实现无缝登录。

步骤:

  1. 用户在 A 系统登录:用户在 A 系统中进行登录。
  2. A 系统生成 SSO Token:A 系统生成一个 SSO Token,并将其存储在 Cookie 或 Local Storage 中。
  3. 跳转到 B 系统:用户点击按钮跳转到 B 系统。
  4. B 系统验证 SSO Token:B 系统在接收到请求时,从 Cookie 或 Local Storage 中获取 SSO Token,并进行验证。如果验证通过,则用户可以直接访问 B 系统。

3. 使用 OAuth 2.0

如果 A 系统和 B 系统都支持 OAuth 2.0,可以使用 OAuth 2.0 的授权码流程来实现。

步骤:

  1. 用户在 A 系统登录:用户在 A 系统中进行登录。
  2. 获取授权码:A 系统获取到授权码。
  3. 跳转到 B 系统:用户点击按钮跳转到 B 系统,并附带授权码。
  4. B 系统使用授权码获取 Token:B 系统使用授权码向 A 系统请求 Token,如果成功,则用户可以直接访问 B 系统。

4. 使用 Cookie 共享

如果 A 系统和 B 系统在同一个域名下(或子域名下),可以通过设置 Cookie 来共享登录态。

步骤:

  1. 在 A 系统中设置 Cookie:用户登录后,在 A 系统中设置一个 Cookie,包含登录信息。
  2. 跳转到 B 系统:用户点击按钮跳转到 B 系统。
  3. B 系统读取 Cookie:B 系统在接收到请求时,读取 Cookie 中的登录信息,并进行验证。

注意事项

  • 安全性:在传递 Token 或者 Cookie 时,确保使用 HTTPS,以防止中间人攻击。
  • Token 过期:需要考虑 Token 的过期时间,确保用户在访问 B 系统时,Token 是有效的。
  • 跨域问题:如果 A 系统和 B 系统不在同一个域名下,可能会遇到跨域问题,需要进行相应的处理。

选择合适的方法取决于你的具体需求和系统架构。

实现步骤

兼容低版本浏览器

  • 方案一:使用 babel-polyfill

安装依赖包:"core-js": "^3.38.1","@babel/preset-env": "^7.25.4","vite-plugin-babel": "^1.2.0","regenerator-runtime": "^0.14.1"

创建 .babelrc 文件,配置如下:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": ["Chrome >= 52"],
        "useBuiltIns": "entry",
        "corejs": 3
      }
    ]
  ]
}

在 main.ts 文件中

import "core-js/stable";
import "core-js/features/object/entries";
import "core-js/features/object/values";
import "regenerator-runtime/runtime";

注意

以上导入必须放到 main.ts 文件顶部,否则不会生效

  • 方案二:vite 官方推荐方案,使用 @vitejs/plugin-legacy
// vite.config.ts
import legacyPlugin from "@vitejs/plugin-legacy";
export default defineConfig({
  plugins: [
    // 浏览器兼容问题配置
    legacyPlugin({
      targets: ["firefox < 59", "chrome 52"],
      additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
      renderLegacyChunks: true,
      modernPolyfills: true, // 是否使用现代浏览器的 polyfill,默认 false。所以需要设置为 true,polyfills选项才会生效。
      polyfills: [
        "es.symbol",
        "es.array.filter",
        "es.promise",
        "es.promise.finally",
        "es/map",
        "es/set",
        "es.array.for-each",
        "es.object.define-properties",
        "es.object.define-property",
        "es.object.get-own-property-descriptor",
        "es.object.get-own-property-descriptors",
        "es.object.keys",
        "es.object.to-string",
        "es.object.values",
        "es.object.entries",
        "es.object.from-entries",
        "es.array.includes",
        "es.string.includes",
        "es.array.flat-map",
        "web.dom-collections.for-each",
        "esnext.global-this",
        "esnext.string.match-all",
      ],
    }),
  ],
});

注意

  • modernPolyfills选项必须设置为 true,选项polyfills才会生效。
  • vite 版本 5 以上,不安装 terser 插件也不会报错,terser插件主要用来压缩代码,而浏览器兼容问题配置不需要压缩代码,所以不需要安装。

.browserslistrc 文件配置如下:

Chrome >= 52
Firefox >= 78
Safari >= 13
Edge >= 88

name 属性

组件的 name 属性作用:

  • 用于在 Vue Devtools 中显示组件的名字;
  • 用于使用 keep-alive 缓存组件(不设置 name 属性,则不会缓存组件);
  • 用于使用路由懒加载;
  • 用于使用全局注册;
  • 用于使用自定义指令。

promise 队列发送请求, 控制每次请求数量

参考

vue3 动态组件

<template>
  <div>
    <component :is="asyncComponent"></component>
  </div>
</template>

<script lang="ts">
import { defineAsyncComponent } from "vue";
// 使用 defineAsyncComponent 定义异步组件
const asyncComponent = defineAsyncComponent(
  () => import("./components/HelloWorld.vue")
);
</script>
<script>
export default {
  name: "App",
  methods: {
    onLeftMove: debounce(
      function (event) {
        const draggedEl = event.dragged; // 被拖拽的元素
        const targetEl = event.related; // 目标位置的参考元素(放置位置)
        const toGroup = event.to.dataset.group; // 目

        // 获取元素类型
        const draggedType = draggedEl.dataset.type;
        const targetType = targetEl.dataset.type;

        console.log("event.to.dataset.group", draggedEl, targetEl, toGroup);
        console.log("type: ", draggedType, targetType);

        if (event.data.name === "SerialNumber") {
          if (
            targetType === "TableList" ||
            targetEl.classList.contains("TableList")
          ) {
            if (this.isErrorMsg) return false;
            this.isErrorMsg = true;
            this.$message({
              type: "error",
              message: "明细表组件不能添加流水号!",
              onClose: () => (this.isErrorMsg = false),
            });
            return false;
          }
          if (!this._formConfig.hasSerialNumber) {
            return true;
          } else {
            if (this.isErrorMsg) return false;
            this.isErrorMsg = true;
            console.log("targetType", targetType);
            this.$message({
              type: "error",
              message: "流水号组件只能添加一次!",
              onClose: () => (this.isErrorMsg = false),
            });
            return false;
          }
        }
        return true;
      },
      300,
      true
    ),
  },
};
</script>

Vue Cli 3.x/4.x 升级到 5.x

  • 参照 vue-cli5.x 版本脚手架创建的项目,查看 package.json 文件,将 vue-cli-service 升级到最新版本。

package.json

升级前

{
  "devDependencies": {
    "@vue/cli-plugin-babel": "4.4.4",
    "@vue/cli-plugin-eslint": "4.4.4",
    "@vue/cli-plugin-unit-jest": "4.4.4",
    "@vue/cli-service": "4.4.4",
    "@vue/test-utils": "1.0.0-beta.29",
    "autoprefixer": "9.5.1",
    "babel-eslint": "10.1.0",
    "babel-jest": "23.6.0",
    "babel-plugin-dynamic-import-node": "2.3.3",
    "chalk": "2.4.2",
    "connect": "3.6.6",
    "eslint": "6.7.2",
    "eslint-plugin-vue": "6.2.2",
    "html-webpack-plugin": "3.2.0",
    "mockjs": "1.0.1-beta3",
    "runjs": "4.3.2",
    "sass": "1.26.8",
    "sass-loader": "8.0.2",
    "script-ext-html-webpack-plugin": "2.1.3",
    "serve-static": "1.13.2",
    "svg-sprite-loader": "4.1.3",
    "svgo": "1.2.2",
    "vue-template-compiler": "2.6.10"
  }
}

升级后

{
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-plugin-router": "~5.0.0",
    "@vue/cli-plugin-vuex": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "@vue/test-utils": "1.0.0-beta.29",
    "autoprefixer": "9.5.1",
    "babel-jest": "23.6.0",
    "babel-plugin-dynamic-import-node": "2.3.3",
    "chalk": "2.4.2",
    "connect": "3.6.6",
    "cross-env": "^7.0.3",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-vue": "^8.0.3",
    "html-webpack-plugin": "3.2.0",
    "mockjs": "1.0.1-beta3",
    "path-browserify": "^1.0.1",
    "prettier": "^2.4.1",
    "runjs": "4.3.2",
    "sass": "1.26.8",
    "sass-loader": "8.0.2",
    "script-ext-html-webpack-plugin": "2.1.3",
    "serve-static": "1.13.2",
    "svg-sprite-loader": "^6.0.11",
    "svgo": "1.2.2",
    "vue-template-compiler": "^2.6.14",
    "webpack-bundle-analyzer": "^4.10.2"
  }
}
  • 添加 .prettierignore,.prettierrc 文件,配置如下:

.prettierignore

dist
.local
.output.js
node_modules
.nvmrc

**/*.svg
**/*.sh

public
.npmrc
*-lock.yaml

.prettierrc

{
  "$schema": "https://json.schemastore.org/prettierrc",
  "semi": true,
  "singleQuote": false,
  "trailingComma": "es5",
  "tabWidth": 2,
  "printWidth": 80,
  "bracketSpacing": true,
  "vueIndentScriptAndStyle": false
}
  • 修改 .eslintignore 和 .eslintrc.js 文件,配置如下:

.eslintignore

build/*.js
src/assets
public
dist

.eslintrc.js

module.exports = {
  root: true,
  env: {
    node: true,
  },
  extends: [
    "plugin:vue/essential",
    "eslint:recommended",
    "plugin:prettier/recommended",
  ],
  parserOptions: {
    parser: "@babel/eslint-parser",
  },
  rules: {
    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
    "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
  },
};
  • 修改 vue.config.js 文件,配置如下:

升级后

const { defineConfig } = require("@vue/cli-service");
const BundleAnalyzerPlugin =
  require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const path = require("path");
const port = process.env.port || process.env.npm_config_port || 9528; // dev port
const isDev = process.env.NODE_ENV === "development";
const isMock = process.env.VUE_APP_MOCK === "true";

function resolve(dir) {
  return path.join(__dirname, dir);
}
module.exports = defineConfig({
  transpileDependencies: true,
  productionSourceMap: false,
  lintOnSave: false,
  devServer: {
    port,
    open: false,
    client: {
      overlay: false, // 关闭全屏错误覆盖层
    },
    //代理本地接口地址
    proxy: {
      [process.env.VUE_APP_BASE_API]: {
        target: "http://192.168.1.127:18350/",
        changeOrigin: true,
        pathRewrite: {
          ["^" + process.env.VUE_APP_BASE_API]: "",
        },
        bypass: (req) => {
          // mock 模式下,所有请求都走 mock 服务,不再向后端(target)发送请求,
          // 联调时,记得修改 VUE_APP_MOCK = 'false' 关掉 mock 模式
          if (isMock) return req.path;
        },
      },
    },
    setupMiddlewares: (middlewares, devServer) => {
      if (!devServer) {
        throw new Error("webpack-dev-server is not defined");
      }
      // 按需加载 Mock
      if (isDev && isMock) {
        require("./mock/mock-server.js")(devServer.app);
      }
      return middlewares;
    },
  },
  configureWebpack: {
    // provide the app's title in webpack's name field, so that
    // it can be accessed in index.html to inject the correct title.
    name: process.env.VUETR_APP_NAME,
    resolve: {
      alias: {
        "@": resolve("src"),
      },
      fallback: {
        // 需要安装 path-browserify 依赖,因为 webpack 5 默认不再支持 Node.js 核心模块
        // 项目中的组件使用到了path模块(import path from "path"),需要使用浏览器兼容的 path 模块
        // 使用 import path from "path-browserify" 导入
        path: require.resolve("path-browserify"), // 引用浏览器兼容的 path 模块
      },
    },
    optimization: {
      splitChunks: {
        chunks: "all",
        minSize: 20000, // 20KB
        maxSize: 250000, // 250KB
        cacheGroups: {
          // 核心第三方库单独打包
          vue: {
            test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
            name: "chunk-vue",
            priority: 20,
          },
          // UI库单独打包
          elementUI: {
            test: /[\\/]node_modules[\\/]element-ui[\\/]/,
            name: "chunk-element",
            priority: 15,
          },
          // 其他node_modules打包
          vendors: {
            test: /[\\/]node_modules[\\/]/,
            name: "chunk-vendors",
            priority: 10,
            reuseExistingChunk: true,
          },
          // 公共模块
          common: {
            name: "chunk-common",
            minChunks: 2,
            priority: 5,
            reuseExistingChunk: true,
          },
        },
      },
    },
    plugins: [
      process.env.ANALYZE === "true" &&
        new BundleAnalyzerPlugin({
          openAnalyzer: false, // 是否自动打开浏览器
        }),
    ],
  },
  chainWebpack(config) {
    // 清除默认的 SVG 规则,因为默认配置会导致 svg-sprite-loader 无法正常工作,
    // 项目中使用<svg-icon icon-class="user" />组件渲染不出 SVG 图标。
    // 1. 清除默认的 SVG 规则
    config.module.rule("svg").uses.clear(); // 确保完全清除原有规则
    config.module.rule("svg").exclude.add(resolve("src/icons")).end();
    // 2. 新增 icons 规则
    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();

    config.module
      .rule("vue")
      .use("vue-loader")
      .tap((options) => {
        options.compilerOptions.preserveWhitespace = true;
        return options;
      });
  },
});

升级前

"use strict";
const path = require("path");
const defaultSettings = require("./src/settings.js");
// const { codeInspectorPlugin } = require("code-inspector-plugin");
function resolve(dir) {
  return path.join(__dirname, dir);
}

const name = defaultSettings.title || "vue Admin Template"; // page title
const port = process.env.port || process.env.npm_config_port || 9528; // dev port
const isDev = process.env.NODE_ENV === "development";
const isProd = process.env.NODE_ENV === "production";
const isMock = process.env.VUE_APP_MOCK === "true";

module.exports = {
  publicPath: "/",
  outputDir: "dist",
  assetsDir: "static",
  lintOnSave: isDev,
  productionSourceMap: false,
  devServer: {
    port: port,
    open: false,
    overlay: {
      warnings: false,
      errors: true,
    },
    //代理本地接口地址
    proxy: {
      [process.env.VUE_APP_BASE_API]: {
        target: "http://192.168.1.127:18350/",
        changeOrigin: true,
        pathRewrite: {
          ["^" + process.env.VUE_APP_BASE_API]: "",
        },
        bypass: (req) => {
          // mock 模式下,所有请求都走 mock 服务,不再向后端(target)发送请求,
          // 联调时,记得修改 VUE_APP_MOCK = 'false' 关掉 mock 模式
          if (isMock) return req.path;
        },
      },
    },
    before: isDev && isMock ? require("./mock/mock-server.js") : null,
  },
  configureWebpack: {
    // provide the app's title in webpack's name field, so that
    // it can be accessed in index.html to inject the correct title.
    name: name,
    resolve: {
      alias: {
        "@": resolve("src"),
      },
    },
  },
  chainWebpack(config) {
    // it can improve the speed of the first screen, it is recommended to turn on preload
    config.plugin("preload").tap(() => [
      {
        rel: "preload",
        // to ignore runtime.js
        // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
        fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
        include: "initial",
      },
    ]);

    // when there are many pages, it will cause too many meaningless requests
    config.plugins.delete("prefetch");

    // set svg-sprite-loader
    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();

    config.when(isProd, (config) => {
      config
        .plugin("ScriptExtHtmlWebpackPlugin")
        .after("html")
        .use("script-ext-html-webpack-plugin", [
          {
            // `runtime` must same as runtimeChunk name. default is `runtime`
            inline: /runtime\..*\.js$/,
          },
        ])
        .end();
      config.optimization.splitChunks({
        chunks: "all",
        cacheGroups: {
          libs: {
            name: "chunk-libs",
            test: /[\\/]node_modules[\\/]/,
            priority: 10,
            chunks: "initial", // only package third parties that are initially dependent
          },
          elementUI: {
            name: "chunk-elementUI", // split elementUI into a single package
            priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
            test: /[\\/]node_modules[\\/]_?element-ui(.*)/, // in order to adapt to cnpm
          },
          commons: {
            name: "chunk-commons",
            test: resolve("src/components"), // can customize your rules
            minChunks: 3, //  minimum common number
            priority: 5,
            reuseExistingChunk: true,
          },
        },
      });
      // https:// webpack.js.org/configuration/optimization/#optimizationruntimechunk
      config.optimization.runtimeChunk("single");
    });
  },
};

jsx 组件(vue2.7.x)

this.$createElement方法返回vnode对象,可以用jsx语法渲染组件。想要使用jsx语法渲染组件,需要安装@vue/babel-preset-jsx依赖。 并且在.babelrc文件中配置@vue/babel-preset-jsx插件。

{
  "presets": [
    [
      "@vue/cli-plugin-babel/preset",
      {
        "useBuiltIns": "entry",
        "corejs": {
          "version": 3,
          "proposals": true
        },
        "jsx": "transform"
      }
    ]
  ],
  "plugins": [["@vue/babel-plugin-jsx"]]
}

this.$createElement方法可以传入一个对象,对象中含有render方法,可以在render方法中使用jsx语法渲染组件。

<script>
export default {
  methods: {
    notify() {
      if (this.notifyVm && !this.notifyVm.closed) return;
      this.notifyVm = this.$notify({
        title: "通知",
        duration: 0,
        position: "bottom-right",
        dangerouslyUseHTMLString: true,
        // 使用 JSX 语法渲染,自定义事件绑定 on[eventName],eventName要是驼峰命名法,如 toMatchProject
        message: this.$createElement({
          render: () => (
            <MatchProjectTips
              onToMatchProject={this.toMatchProject}
              onTipChange={this.tipChange}
            />
          ),
        }),

        // 使用 createElement 语法渲染
        message: this.$createElement(MatchProjectTips, {
          // 使用 on 绑定自定义事件
          on: {
            toMatchProject: this.toMatchProject,
            tipChange: this.tipChange,
          },
        }),
      });
    },
  },
};
</script>

使用 createElement 语法渲染

<script>
export default {
  methods: {
    notify() {
      if (this.notifyVm && !this.notifyVm.closed) return;
      this.notifyVm = this.$notify({
        title: "通知",
        duration: 0,
        position: "bottom-right",
        dangerouslyUseHTMLString: true,
        // 使用 createElement 语法渲染
        message: this.$createElement(MatchProjectTips, {
          // 使用 on 绑定自定义事件
          on: {
            toMatchProject: this.toMatchProject,
            tipChange: this.tipChange,
          },
        }),
      });
    },
  },
};
</script>

注意

this.$createElement(MatchProjectTips, {
  // 使用 on 绑定自定义事件
  on: {
    toMatchProject: this.toMatchProject,
    tipChange: this.tipChange,
  },
});

使用上面的写法数据改变后,不会更新视图MatchProjectTips,因为没有render函数,如果想要数据改变后,视图会更新,那么需要通过render函数渲染组件。

在单文件组件中的render函数中使用jsx语法渲染组件。

<script>
export default {
  props: {
    value: {
      type: Number,
      default: 1,
    },
  },
  data() {
    return {
      active: 1,
      tips: [
        { id: 1, label: "30分钟提醒一次", value: 30 },
        { id: 2, label: "1天提醒一次", value: 1440 },
        { id: 3, label: "3天提醒一次", value: 4320 },
        { id: 4, label: "7天提醒一次", value: 10080 },
      ],
    };
  },
  methods: {
    toMatchProject() {
      this.$emit("toMatchProject");
    },
    selectTip(payload) {
      this.active = payload.id;
      this.$emit("tipChange", payload);
    },
  },
  render(h) {
    return (
      <div class="notify-match-project">
        你好,DCM文件存在检查项目匹配错误!
        请及时完成手动匹配,以免影响辐射剂量以及和图像评分的计算!
        <el-button
          class="btn-view"
          type="text"
          size="small"
          onClick={this.toMatchProject}
        >
          立即查看
        </el-button>
        <el-popover
          placement="bottom-end"
          trigger="click"
          popper-class="diy-match-project__popover"
          scopedSlots={{
            reference: () => <i class="el-icon-more tips-more"></i>,
            default: () => (
              <div class="match-project-tips">
                {this.tips.map((item) => {
                  return (
                    <div
                      class={["tip-item", { active: this.active === item.id }]}
                      key={item.id}
                      onClick={() => this.selectTip(item)}
                    >
                      {item.label}
                    </div>
                  );
                })}
              </div>
            ),
          }}
        ></el-popover>
      </div>
    );
  },
};
</script>

<style lang="scss" scoped>
.notify-match-project {
  position: relative;
  .btn-view {
    padding: 0;
  }
  .tips-more {
    position: absolute;
    top: -20px;
    right: 10px;
    cursor: pointer;
    color: var(--color-primary);
  }
}
</style>

<style lang="scss">
.diy-match-project__popover {
  padding: 0 !important;
  .match-project-tips {
    .tip-item {
      cursor: pointer;
      padding: 0 10px;
      line-height: 32px;
      font-size: 14px;
      &:not(:last-child) {
        border-bottom: 1px solid #e5e5e5;
      }
      &.active {
        background-color: #f1f6f6;
        color: #20a3b1;
      }
    }
  }
}
</style>

动态路由组件

  • 每次生成的组件内存地址都一样
() => import("@/views/universalPage/index.vue");
  • 每次生成新的组件地址

核心代码

() =>
  import("@/views/universalPage/index.vue").then((module) => {
    // 每次返回一个新的对象,vue会创建一个新的组件实例
    const vm = { ...module.default, name: tmpRoute.alias };
    return vm;
  });

完整代码

import router, { resetRouter } from "@/router";
import { asyncRoutes, constantRoutes } from "@/router";
import notFoundRoute from "@/router/404";
import store from "..";
import { deepClone, flattenTreeSimple } from "@/utils";
import { newGetGroupList } from "@/api/controlOfDepartment";
import Layout from "@/layout";
import {
  flattenPermissionTree,
  generatePrefixedPermissionCode,
} from "@/utils/permission";
import { removeCodeMap, setCodeMap } from "@/utils/auth";

/**
 * Use meta.role to determine if the current user has permission
 * @param testRole
 * @param route
 */
function hasPermission(route) {
  if (route.meta) {
    // 不需要权限控制的路由
    if (
      route.name == "equipmentDF" ||
      route.name == "equipmentDSA" ||
      route.name == "equipmentYPJ" ||
      route.name == "equipmentCBCT" ||
      route.name == "message" ||
      route.name == "diagnosticReportQues" ||
      route.name == "diagnosticQuestionList" ||
      route.name == "filmScanningQues" ||
      route.name == "filmScanningQuestionList" ||
      route.name == "crivalList" ||
      route.name == "tuberList" ||
      route.name == "difficultList" ||
      route.name == "recycleBin" ||
      route.name == "followUpList" ||
      route.name == "RtDeviceDetail" ||
      route.meta.isPlan
    ) {
      //路由特殊处理
      return true;
    } else {
      return false;
    }
  } else {
    return true;
  }
}

function getDic() {
  return JSON.parse(localStorage.getItem("dic" + window.location.port) || "{}");
}

/**
 * Filter asynchronous routing tables by recursion
 * @param routes asyncRoutes
 * @param role
 */
export function filterAsyncRoutes(routes, arr) {
  const res = [];
  routes.forEach((route) => {
    let tmpRoute = { ...route };
    if (tmpRoute.alias === "AppletReport") return;
    if (tmpRoute.childs && tmpRoute.childs.length && !tmpRoute.IsLeafNode) {
      tmpRoute.children = filterAsyncRoutes(tmpRoute.childs, arr);
    }
    let obj = arr.find((item1) => tmpRoute.alias == item1.name);
    // if (tmpRoute.level == 1 && tmpRoute.IsSys) {
    if (obj && obj.children && tmpRoute.IsSys) {
      obj.children.forEach((item) => {
        if (hasPermission(item)) {
          tmpRoute.children.push(item);
        }
      });
    }
    let result = {};
    if (!obj) {
      result = Object.assign(
        {},
        { children: tmpRoute.children },
        {
          component:
            tmpRoute.level == 1
              ? Layout
              : tmpRoute.CustomType == 1
                ? () => import("@/views/smartReportView/index.vue")
                : () =>
                    import("@/views/universalPage/index.vue").then((module) => {
                      // 每次返回一个新的对象,vue会创建一个新的组件实例
                      const vm = { ...module.default, name: tmpRoute.alias };
                      return vm;
                    }),
          meta: {
            title: tmpRoute.name,
            icon: tmpRoute.Icon,
            isIcon: true,
            id: tmpRoute.analyID,
            keepAlive: tmpRoute.CustomType == 2,
          },
          name: tmpRoute.alias,
          path: tmpRoute.level == 1 ? `/${tmpRoute.alias}` : tmpRoute.alias,
          redirect:
            tmpRoute.level == 1
              ? `/${tmpRoute.alias}/${tmpRoute?.childs[0]?.alias}`
              : undefined,
        }
      );
    } else {
      result = Object.assign(
        {},
        { children: tmpRoute.children },
        {
          component: obj.component,
          meta: obj.meta,
          name: obj.name,
          path: obj.path,
          redirect: tmpRoute.children?.length
            ? `${obj.path}/${tmpRoute.children[0].path}`
            : undefined,
          hidden: obj.hidden,
        }
      );
    }
    res.push(result);
  });
  return res;
}

const state = {
  routes: [],
  routesCopy: [],
  formDesignTable: [],
  addRoutes: [],
  permissions: {}, // 扁平化的权限表
  permissionCodes: new Map(), // 权限码映射表
};

const mutations = {
  SET_ROUTES: (state, routes) => {
    // state.addRoutes = routes
    // 实现动态添加左侧菜单的方法
    state.routes = constantRoutes.concat(routes);
  },
  SET_WHOLE_ROUTES(state, routes) {
    state.routes = routes;
    state.routesCopy = routes;
  },
  SET_FORMDESIGNTABLE(state, routes) {
    state.formDesignTable = routes;
  },
  SET_PERMISSIONS(state, permissionTree) {
    state.permissions = Object.freeze(flattenPermissionTree(permissionTree));
    const codeMap = {};
    // 同时构建权限码映射
    state.permissionCodes = Object.freeze(new Map());
    Object.keys(state.permissions).forEach((fullPath) => {
      const code = generatePrefixedPermissionCode(fullPath);
      const { alias, name } = state.permissions[fullPath];
      codeMap[fullPath] = { code, alias, name, fullPath };
      state.permissionCodes.set(code, { alias, name, fullPath });
    });
    if (process.env.NODE_ENV === "development") {
      // 开发环境下,将权限码映射存储在localStorage
      setCodeMap(codeMap);
    }
  },
};

const actions = {
  generateRoutes({ dispatch, commit }, payload) {
    return new Promise((resolve) => {
      let accessedRoutes;
      const dic = getDic();
      commit("SET_PERMISSIONS", dic);
      let flattenedArray = flattenTreeSimple(asyncRoutes);
      accessedRoutes = filterAsyncRoutes(dic, flattenedArray);
      // 更新左侧路由表
      commit("SET_ROUTES", accessedRoutes);
      // 设置左侧路由表选中状态
      resolve(accessedRoutes);
    });
  },
  setActiveMenu({ commit }, list) {
    let routeTitle = "";
    let routePath = "";
    for (let index = 0; index < list.length; index++) {
      const item = list[index];
      routeTitle = item.meta && item.meta.title;
      routePath = item.path;
      if (item.children.length > 0) {
        setMenu(item.children, routeTitle, routePath);
      }
    }

    function setMenu(list, routeTitle, routePath) {
      let activeMenu = "";
      for (let index = 0; index < list.length; index++) {
        const item = list[index];
        if (!item.hidden) break;
        if (index === 0) {
          item.hidden = false;
          activeMenu = `${routePath}/${item.path}`;
          item.meta = { ...item.meta, activeMenu, title: routeTitle };
        } else {
          item.meta = { ...item.meta, activeMenu };
        }
        if (item.children) {
          setMenu(item.children, routeTitle, activeMenu);
        }
      }
    }
  },
  async initRoutes({ commit, dispatch }) {
    const userInfo = await store.dispatch("user/getInfo");
    let accessRoutes = await dispatch("generateRoutes");
    accessRoutes = [...accessRoutes, ...notFoundRoute];
    console.log("accessRoutes", accessRoutes);
    commit("SET_WHOLE_ROUTES", accessRoutes);
    resetRouter();
    router.addRoutes(accessRoutes);
    return { routes: accessRoutes, userInfo };
  },
  newGetGroupList({ commit, state }) {
    return new Promise((resolve, reject) => {
      newGetGroupList()
        .then((response) => {
          const { Data } = response;
          let allData = Data.map((item, index) => {
            return {
              ...item,
              Rank: index + 1,
            };
          });
          commit("SET_FORMDESIGNTABLE", allData);
          resolve(allData);
        })
        .catch((error) => {
          reject(error);
        });
    });
  },
  clearPermissions({ state }) {
    state.permissions = {};
    state.permissionCodes = new Map();
    removeCodeMap();
  },
};

const getters = {
  /**
   * @param {hasPer} state
   * @returns boolean
   * @description 根据标识符判断是否有权限,通过localstorage[codeMap]查看标识权限表
   * @description 使用示例:hasPer('SEManagement.SEADepartment.check-s'),全局mixinHasPer('SEManagement.SEADepartment.check-s')
   */
  hasPer: (state) => (identifiers) => {
    if (!identifiers) return false;

    // 统一处理单个和多个标识符的情况
    const ids = Array.isArray(identifiers) ? identifiers : [identifiers];

    return ids.some((id) => {
      // 前端生成的code hash值会随着权限值的改变而改变,不好维护,所以暂时不使用,目前采用权限路径判断
      // if (state.permissionCodes.has(id)) return true;
      // 否则认为是fullPath
      return !!state.permissions[id];
    });
  },
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters,
};

账号变更提示

使用BroadcastChannel,监听用户登录信息变更,如果发现token发生变化,则弹出提示框,提醒用户刷新页面。

  • 发布账号变更提示
login({ commit }, userInfo) {
    const { userName, password } = userInfo;
    return new Promise((resolve, reject) => {
      login({ userName: userName.trim(), password: password })
        .then(response => {
          if (response.Status === 1 && response.Data) {
            const channel = new BroadcastChannel("account-change");
            channel.postMessage({
              token: response.Data.token,
              userID: response.Data.userID,
            });
            // 关闭频道, 防止内存泄漏
            channel.close();
          }
          resolve(response);
        })
        .catch(error => {
          reject(error);
        });
    });
  }
  • 订阅账号变更提示

添加路由守卫

this.unwatchRoute = this.$router.beforeEach((to, from, next) => {
  if (this.isAccountChangeAlertOpen) {
    return next(false);
  }
  next(true);
});

具体实现

import { mapState } from "vuex";

export default {
  data() {
    return {
      accountChangeChannel: null,
      isAccountChangeAlertOpen: false,
      unwatchRoute: null,
    };
  },
  computed: {
    ...mapState("user", ["token", "tokenExpired"]),
  },
  mounted() {
    this.reload();
    this.createRouteGuard();
  },
  beforeDestroy() {
    if (this.accountChangeChannel) {
      this.accountChangeChannel.close();
    }
    // 移除路由守卫
    if (this.unwatchRoute) {
      this.unwatchRoute();
    }
  },
  methods: {
    reload() {
      if (this.accountChangeChannel) this.accountChangeChannel.close();
      this.accountChangeChannel = new BroadcastChannel("account-change");
      this.accountChangeChannel.onmessage = (event) => {
        if (this.tokenExpired) return;
        let { token } = event.data;
        if (token && this.token && token !== this.token) {
          this.isAccountChangeAlertOpen = true;
          this.$alert(
            "检测到当前系统登录的用户信息有变更,请刷新页面以同步至最新登录状态。",
            "登录账号信息发生变更",
            {
              type: "warning",
              autofocus: false,
              showClose: false,
              confirmButtonText: "刷新页面",
              callback: () => {
                location.reload();
              },
            }
          );
        }
      };
    },
    createRouteGuard() {
      // 如果已有路由守卫,先移除
      if (this.unwatchRoute) {
        this.unwatchRoute();
      }

      // 创建新的路由守卫
      this.unwatchRoute = this.$router.beforeEach((to, from, next) => {
        if (this.isAccountChangeAlertOpen) {
          return next(false);
        }
        next(true);
      });
    },
  },
};

版本更新提醒

创建Updater类

import axios from "axios";
import { Notification, Button } from "element-ui";
import Vue from "vue";

export class Updater {
  static TIME_REFRESH = 4000;
  // 白名单列表
  static WHTTE_LIST = ["/schedulingv2/"];

  constructor() {
    this.oldScript = [];
    this.newScript = [];
    this.dispatch = {};
    this.loading = false;
    this.notifyVm = null;
    this.tipVm = null;
    this.timerId = undefined;
    this.init();
  }
  async init() {
    this.tipVm = this.initTipVm();
    const html = await this.getHtml();
    this.oldScript = this.parserScript(html);
  }
  async getHtml() {
    const text = await axios("/").then((res) => res.data);
    return text;
  }
  parserScript(html) {
    const reg = new RegExp(/<script(?:\s+[^>]*)?>(.*?)<\/script\s*>/gi);
    return html.match(reg);
  }
  // 发布订阅通知
  on(key, fn) {
    (this.dispatch[key] || (this.dispatch[key] = [])).push(fn);
    return this;
  }
  compare(oldArr, newArr) {
    const base = oldArr.length;
    const arr = Array.from(new Set(oldArr.concat(newArr)));
    // 如果新旧length 一样无更新
    if (arr.length !== base && Array.isArray(this.dispatch["update"])) {
      this.dispatch["update"].forEach((fn) => {
        fn();
      });
    }
  }
  initTipVm() {
    if (this.tipVm) return this.tipVm;
    const vm = Vue.extend({
      render() {
        return (
          <div>
            <span>检测到系统有更新,是否刷新页面进行更新?</span>
            <Button type="text" size="small" onClick={this.onUpdate}>
              立即更新
            </Button>
          </div>
        );
      },
      methods: {
        onUpdate() {
          window.location.reload();
        },
      },
    });
    return new vm().$mount();
  }
  notify() {
    this.timerId && clearTimeout(this.timerId);
    this.timerId = setTimeout(() => {
      if (Updater.WHTTE_LIST.some((url) => location.href.includes(url))) {
        return this.notifyVm && this.notifyVm.close();
      }
      if (this.notifyVm && !this.notifyVm.closed) return;
      this.notifyVm = Notification({
        title: "通知",
        position: "bottom-right",
        duration: 0,
        type: "warning",
        dangerouslyUseHTMLString: true,
        message: this.tipVm._vnode,
      });
    }, 200);
  }
  async checkUpdate() {
    if (this.loading) return;
    this.loading = true;
    try {
      const newHtml = await this.getHtml();
      setTimeout(() => {
        this.loading = false;
      }, Updater.TIME_REFRESH);
      this.newScript = this.parserScript(newHtml);
      this.compare(this.oldScript, this.newScript);
    } catch (error) {
      this.loading = false;
    }
  }
}

const updater = new Updater();
updater.on("update", () => {
  updater.notify();
});

export default updater;

使用

  • 在request响应拦截中添加
import axios from "axios";

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

service.interceptors.response.use((response) => {
  updater.checkUpdate();
});
  • 在路由守卫中添加
router.afterEach(() => {
  updater.checkUpdate();
});

单点登录

<template>
  <div class="oauth-container">
    <div class="loading-wrapper">
      <div class="spinner"></div>
      <p class="loading-text">正在授权登录中...</p>
    </div>
  </div>
</template>

<script>
import { mapMutations } from "vuex";
import { OtherLogon } from "@/api/user";
import { setPort } from "@/utils/auth";
import {
  setUrl,
  getOtherUrl,
  setToken,
  setUserID,
  setExpireTime,
} from "@/utils/auth";
export default {
  name: "OAuth",
  data() {
    return {
      isProcessing: true,
    };
  },
  computed: {},
  mounted() {
    this.handleOAuthCallback();
  },
  methods: {
    ...mapMutations("user", [
      "SET_MESSAGEDATA",
      "SET_TOKEN",
      "SET_EXPIRETIME",
      "SET_USERID",
    ]),
    getUrlParams() {
      const urlParams = new URLSearchParams(window.location.search);
      const params = {
        TenantId: urlParams.get("TenantId"),
        UserName: urlParams.get("UserName"),
        SecretKey: urlParams.get("SecretKey"),
        LoginTime: urlParams.get("LoginTime"),
        Token: urlParams.get("Token"),
      };
      return params;
    },
    handleOAuthCallback() {
      const TenantId = "UmFkdGVjaF9DRFFZ";
      const UserName = "张三%、name";
      const SecretKey = "TV2JIot2kz9Wdu5ecDtnR9CNcvfZOhAbRqZLhGNa3IM=";
      const LoginTime = "20251218141600";
      const Token =
        "/5UPh8bg7Lh0bQ0V/q0Zp9ERtvUNXPouIOdiJYSC6W4Poh4LX8o8+JJMovaPuvf7mRzvbqeOuhN97aq+qaQyog==";
      const url = `http://localhost:9529/oauth?TenantId=${TenantId}&UserName=${UserName}&SecretKey=${SecretKey}&LoginTime=${LoginTime}&Token=${Token}&code=xxx&state=xxx`;
      console.log("url", encodeURI(url));

      // 获取URL参数
      // const urlParams = new URLSearchParams(window.location.search);
      // const code = urlParams.get("code");
      // const state = urlParams.get("state");
      // const error = urlParams.get("error");

      // const userName = urlParams.get("UserName");
      // console.log("userName", userName);

      // 检查是否有错误
      // if (error) {
      //   this.$message.error("授权失败: " + error);
      //   setTimeout(() => {
      //     this.$router.push("/login");
      //   }, 2000);
      //   return;
      // }

      // 验证state参数(防止CSRF攻击)
      // const storedState = localStorage.getItem("oauth_state");
      // if (state !== storedState) {
      //   this.$message.error("授权状态验证失败");
      //   setTimeout(() => {
      //     console.log("-09090900");
      //     // this.$router.push("/login");
      //   }, 2000);
      //   return;
      // }

      // 清除存储的state
      // localStorage.removeItem("oauth_state");

      // 如果有授权码,则向后端换取token
      this.exchangeToken();

      // if (code) {
      //   this.exchangeToken(code);
      // } else {
      //   this.$message.error("缺少授权码");
      //   setTimeout(() => {
      //     // this.$router.push("/login");
      //   }, 2000);
      // }
    },

    async exchangeToken(code) {
      const params = {
        ...this.getUrlParams(),
      };
      console.log("params", params);
      OtherLogon(params)
        .then((res) => {
          if (res.Status === 1) {
            console.log("授权成功");
            this.iisData(res);
          }
        })
        .catch(() => {
          console.log("授权失败");
          this.$router.push("/login");
        });

      // try {
      //   // 这里调用后端API用code换取access_token
      //   const response = await this.$http.post("/api/oauth/token", {
      //     code: code,
      //     redirect_uri: window.location.origin + "/oauth",
      //   });

      //   if (response.data && response.data.access_token) {
      //     // 存储token
      //     localStorage.setItem("access_token", response.data.access_token);
      //     if (response.data.refresh_token) {
      //       localStorage.setItem("refresh_token", response.data.refresh_token);
      //     }

      //     // 获取用户信息
      //     await this.fetchUserInfo();
      //   } else {
      //     throw new Error("获取token失败");
      //   }
      // } catch (error) {
      //   console.error("Token exchange failed:", error);
      //   this.$message.error("授权失败,请重试");
      //   setTimeout(() => {
      //     this.$router.push("/login");
      //   }, 2000);
      // }
    },

    async fetchUserInfo() {
      try {
        const response = await this.$http.get("/api/user/info");
        if (response.data) {
          // 存储用户信息
          localStorage.setItem("user_info", JSON.stringify(response.data));

          // 跳转到首页或之前访问的页面
          const redirect = localStorage.getItem("redirect_url") || "/";
          localStorage.removeItem("redirect_url");
          this.$router.replace(redirect);
        }
      } catch (error) {
        console.error("Fetch user info failed:", error);
        this.$message.error("获取用户信息失败");
        this.$router.push("/login");
      }
    },
    async iisData(allData) {
      const res = allData;
      const token = res.Data.token;
      const ExpireTime = res.Data.expireTime;
      this.SET_TOKEN(token);
      this.SET_USERID(res.Data.userID);
      this.SET_EXPIRETIME(ExpireTime);

      localStorage.setItem(
        "dic" + window.location.port,
        JSON.stringify(res.Data.dic)
      );
      localStorage.setItem(
        "permission12" + window.location.port,
        JSON.stringify(res.Data.permission12)
      );
      localStorage.setItem(
        "showMode" + window.location.port,
        JSON.stringify(res.Data.showMode ? res.Data.showMode : 0)
      );
      setToken(token);
      setUserID(res.Data.userID);
      setExpireTime(ExpireTime);
      setPort();

      setUrl(window.location.origin);
      // this.getMessage();
      // this.openopenSocket(res);
      const { routes } = await this.$store.dispatch("permission/initRoutes");
      // if (this.$route.query.other) {
      //   return window.location.replace(getOtherUrl() + this.$route.query.other);
      // }
      console.log("route", this.$route);
      if (!routes[0] || (routes[0] && routes[0].redirect === "/404")) {
        return this.$message.error("暂无权限");
      }
      routes[0] && this.$router.push({ path: routes[0].redirect });
    },
  },
};
</script>

<style scoped lang="scss">
.oauth-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-color: #f5f7fa;
}

.loading-wrapper {
  text-align: center;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #1fa3b0;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

.loading-text {
  margin-top: 20px;
  font-size: 16px;
  color: #606266;
}
</style>

判断路由是否存在

  • vue-router 3.x.x版本
<script>
export default {
  methods: {
    isRouteExists(route) {
      try {
        const resolved = this.$router.resolve(route);
        return (
          !!resolved.route.name ||
          !!(resolved.resolved && resolved.resolved.name)
        );
      } catch (error) {
        // 如果路由不存在,router.resolve会抛出异常
        return false;
      }
    },
  },
};
</script>
  • vue-router 4.x.x-5.x.x版本
<script>
// TODO 以下这些方法需要验证
export default {
  methods: {
    // 方法一:使用hasRoute
    isRouteExists(route) {
      return this.$router.hasRoute(route);
    },
    // 方法二:使用getRoutes获取所有路由配置
    doesRouteExist(path) {
      const routes = this.$router.getRoutes(); // 获取所有路由配置
      return routes.some((route) => route.path === path); // 检查是否存在匹配的路径
    },
    // 方法三:使用resolve
    isRouteDefined(path) {
      try {
        this.$router.resolve(path); // 尝试解析路径
        return true; // 如果不抛出异常,则认为路由存在
      } catch (error) {
        return false; // 如果抛出异常,则认为路由不存在
      }
    },
  },
};
</script>

mixins 避免重名被覆盖

唯一标识+静态检查

  1. 模块级命名空间管理器
// utils/mixinNamespaceManager.js
class MixinNamespaceManager {
  constructor() {
    this.namespaces = new Set();
  }

  getNamespaces() {
    return Array.from(this.namespaces);
  }

  addNamespace(namespace) {
    if (this.namespaces.has(namespace)) {
      throw new Error(`Mixin namespace collision: ${namespace}`);
    }
    this.namespaces.add(namespace);
  }

  // 用于在构建时检查(Node.js环境)
  static getInstance() {
    if (!MixinNamespaceManager.instance) {
      MixinNamespaceManager.instance = new MixinNamespaceManager();
    }
    return MixinNamespaceManager.instance;
  }
}

export default MixinNamespaceManager;
// utils/mixinNamespaceManager.js
class MixinNamespaceManager {
  constructor() {
    this.namespaces = new Set();
    this.retryAttempts = 0;
  }

  getNamespaces() {
    return Array.from(this.namespaces);
  }

  addNamespace(namespace) {
    // 尝试重试生成唯一命名空间
    let attempt = 0;
    let candidate = namespace;

    while (this.namespaces.has(candidate)) {
      // 自动重试:添加序号
      candidate = `${namespace}${attempt}`;
      attempt++;

      // 防止无限循环(最多10次重试)
      if (attempt > 10) {
        throw new Error(
          `Failed to generate unique namespace after 10 attempts: ${namespace}`
        );
      }
    }

    this.namespaces.add(candidate);
    return candidate;
  }

  static getInstance() {
    if (!MixinNamespaceManager.instance) {
      MixinNamespaceManager.instance = new MixinNamespaceManager();
    }
    return MixinNamespaceManager.instance;
  }
}
export default MixinNamespaceManager;
  1. 创建 Mixin 模板(mixins/mixin-template.js)
// mixins/mixin-template.js
import MixinNamespaceManager from "@/utils/mixinNamespaceManager";
import { createHash } from "crypto";

// 生成唯一命名空间
const fileHash = createHash("sha256")
  .update(__filename)
  .digest("hex")
  .substr(0, 8);
const namespace = `mixin_${fileHash}_`;

// 添加到命名空间管理器
const manager = MixinNamespaceManager.getInstance();
manager.addNamespace(namespace);

export default {
  mixinNamespace: namespace,

  data() {
    return {
      [`${namespace}data`]: null,
      [`${namespace}loading`]: true,
      [`${namespace}error`]: null,
    };
  },

  methods: {
    fetch() {
      this[`${this.mixinNamespace}loading`] = true;
      this[`${this.mixinNamespace}error`] = null;

      return new Promise((resolve, reject) => {
        setTimeout(() => {
          this[`${this.mixinNamespace}data`] = { id: 1, name: "Default" };
          this[`${this.mixinNamespace}loading`] = false;
          resolve(this[`${this.mixinNamespace}data`]);
        }, 500);
      });
    },
  },
};
  1. 创建具体 Mixin(mixins/detailMixin.js)
// mixins/detailMixin.js
import mixinTemplate from "./mixin-template";
import axios from "axios";

export default {
  ...mixinTemplate,
  props: {
    ...mixinTemplate.props,
    detailId: {
      type: [Number, String],
      required: true,
    },
  },
  watch: {
    detailId: {
      immediate: true,
      handler(newId) {
        if (newId) {
          this.fetchDetail(newId);
        }
      },
    },
  },
  methods: {
    fetchDetail(id) {
      this[`${this.mixinNamespace}loading`] = true;
      this[`${this.mixinNamespace}error`] = null;

      return axios
        .get(`/api/details/${id}`)
        .then((response) => {
          this[`${this.mixinNamespace}data`] = response.data;
          return response.data;
        })
        .catch((error) => {
          this[`${this.mixinNamespace}error`] = error;
          throw error;
        })
        .finally(() => {
          this[`${this.mixinNamespace}loading`] = false;
        });
    },
  },
};
  1. 页面组件使用(ProductView.vue)
<template>
  <div>
    <BasicInfo
      :data="detailData"
      :loading="detailLoading"
      :error="detailError"
    />

    <button @click="showDetailDialog = true">查看详情</button>

    <DetailDialog
      v-model="showDetailDialog"
      :data="detailData"
      :loading="detailLoading"
      :error="detailError"
      @close="showDetailDialog = false"
    />
  </div>
</template>

<script>
import detailMixin from "@/mixins/detailMixin";
import BasicInfo from "@/components/BasicInfo.vue";
import DetailDialog from "@/components/DetailDialog.vue";

export default {
  mixins: [detailMixin],
  data() {
    return {
      showDetailDialog: false,
      detailId: 1,
    };
  },
  computed: {
    detailData() {
      return this[`${detailMixin.mixinNamespace}data`];
    },
    detailLoading() {
      return this[`${detailMixin.mixinNamespace}loading`];
    },
    detailError() {
      return this[`${detailMixin.mixinNamespace}error`];
    },
  },
};
</script>
最后编辑:
上一页
插件
下一页
组件设计