From 387083ef40c31fed2f7f5588e8d9048ff97bdf38 Mon Sep 17 00:00:00 2001
From: autumnal_wind@yeah.net <autumnal_wind@yeah.net>
Date: 星期一, 27 五月 2024 16:42:24 +0800
Subject: [PATCH] feat: 参数设置改造(弹出框)

---
 src/assets/styles/index.scss                   |    1 
 src/components/Icon/src/IconSelect.vue         |  229 +++
 src/hooks/web/useLocale.ts                     |   35 
 src/components/Dialog/index.ts                 |    3 
 src/hooks/web/useConfigGlobal.ts               |    9 
 src/views/basis/sysParam/config/DetailForm.vue |   17 
 src/components/Icon/src/Icon.vue               |   86 +
 src/hooks/web/useNProgress.ts                  |   33 
 src/hooks/web/useEmitt.ts                      |   22 
 src/hooks/web/useForm.ts                       |   94 +
 src/views/basis/sysParam/config/index.vue      |    2 
 src/hooks/web/useI18n.ts                       |   53 
 src/hooks/web/usePageLoading.ts                |   18 
 src/components/Icon/index.ts                   |    4 
 src/hooks/web/useDesign.ts                     |   18 
 src/components/Dialog/src/Dialog.vue           |  140 ++
 src/hooks/web/useGuide.ts                      |   49 
 src/assets/styles/variables.scss               |    4 
 src/hooks/web/useTable.ts                      |  223 +++
 src/hooks/web/useTagsView.ts                   |   63 
 src/hooks/web/useCache.ts                      |   39 
 src/hooks/web/useNetwork.ts                    |   21 
 src/hooks/web/useValidator.ts                  |   60 
 src/hooks/web/useIcon.ts                       |    8 
 src/hooks/web/useMessage.ts                    |   95 +
 src/hooks/web/useCrudSchemas.ts                |  326 ++++
 src/utils/is.ts                                |  117 +
 src/main.ts                                    |    1 
 src/hooks/web/useNow.ts                        |   60 
 src/components/Icon/src/data.ts                | 1961 +++++++++++++++++++++++++++++
 src/hooks/web/useTimeAgo.ts                    |   49 
 src/hooks/event/useScrollTo.ts                 |   60 
 src/hooks/web/useWatermark.ts                  |   55 
 package.json                                   |    1 
 src/assets/styles/global.module.scss           |    6 
 src/hooks/web/useTitle.ts                      |   24 
 36 files changed, 3,977 insertions(+), 9 deletions(-)

diff --git a/package.json b/package.json
index 87c7fe9..3498421 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
     "@unocss/preset-uno": "^0.50.8",
     "@vitejs/plugin-vue": "5.0.4",
     "@vue/compiler-sfc": "3.2.45",
+    "@purge-icons/generated": "^0.9.0",
     "autoprefixer": "10.4.14",
     "eslint": "8.36.0",
     "eslint-config-prettier": "8.8.0",
diff --git a/src/assets/styles/global.module.scss b/src/assets/styles/global.module.scss
new file mode 100644
index 0000000..8448a92
--- /dev/null
+++ b/src/assets/styles/global.module.scss
@@ -0,0 +1,6 @@
+@import './variables.scss';
+// 导出变量
+:export {
+  namespace: $namespace;
+  elNamespace: $elNamespace;
+}
diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss
index 7494381..3d52e5a 100644
--- a/src/assets/styles/index.scss
+++ b/src/assets/styles/index.scss
@@ -7,6 +7,7 @@
 @import './ruoyi.scss';
 @import 'animate.css';
 @import 'element-plus/dist/index.css';
+@import "./variables.scss";
 
 body {
   height: 100%;
diff --git a/src/assets/styles/variables.scss b/src/assets/styles/variables.scss
new file mode 100644
index 0000000..00b66f1
--- /dev/null
+++ b/src/assets/styles/variables.scss
@@ -0,0 +1,4 @@
+// 命名空间
+$namespace: v;
+// el命名空间
+$elNamespace: el;
diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts
new file mode 100644
index 0000000..1655dad
--- /dev/null
+++ b/src/components/Dialog/index.ts
@@ -0,0 +1,3 @@
+import Dialog from './src/Dialog.vue'
+
+export { Dialog }
diff --git a/src/components/Dialog/src/Dialog.vue b/src/components/Dialog/src/Dialog.vue
new file mode 100644
index 0000000..9a8ca54
--- /dev/null
+++ b/src/components/Dialog/src/Dialog.vue
@@ -0,0 +1,140 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { isNumber } from '@/utils/is'
+defineOptions({ name: 'Dialog' })
+
+const slots = useSlots()
+
+const props = defineProps({
+  modelValue: propTypes.bool.def(false),
+  title: propTypes.string.def('Dialog'),
+  fullscreen: propTypes.bool.def(true),
+  width: propTypes.oneOfType([String, Number]).def('40%'),
+  scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度
+  maxHeight: propTypes.oneOfType([String, Number]).def('400px')
+})
+
+const getBindValue = computed(() => {
+  const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody']
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+})
+
+const isFullscreen = ref(false)
+
+const toggleFull = () => {
+  isFullscreen.value = !unref(isFullscreen)
+}
+
+const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
+
+watch(
+  () => isFullscreen.value,
+  async (val: boolean) => {
+    await nextTick()
+    if (val) {
+      const windowHeight = document.documentElement.offsetHeight
+      dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
+    } else {
+      dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+const dialogStyle = computed(() => {
+  return {
+    height: unref(dialogHeight)
+  }
+})
+</script>
+
+<template>
+  <ElDialog
+    v-bind="getBindValue"
+    :close-on-click-modal="true"
+    :fullscreen="isFullscreen"
+    :width="width"
+    destroy-on-close
+    lock-scroll
+    draggable
+    class="com-dialog"
+    :show-close="false"
+  >
+    <template #header="{ close }">
+      <div class="relative h-54px flex items-center justify-between pl-15px pr-15px">
+        <slot name="title">
+          {{ title }}
+        </slot>
+        <div
+          class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center justify-between"
+        >
+          <Icon
+            v-if="fullscreen"
+            class="is-hover mr-10px cursor-pointer"
+            :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
+            color="var(--el-color-info)"
+            hover-color="var(--el-color-primary)"
+            @click="toggleFull"
+          />
+          <Icon
+            class="is-hover cursor-pointer"
+            icon="ep:close"
+            hover-color="var(--el-color-primary)"
+            color="var(--el-color-info)"
+            @click="close"
+          />
+        </div>
+      </div>
+    </template>
+
+    <ElScrollbar v-if="scroll" :style="dialogStyle">
+      <slot></slot>
+    </ElScrollbar>
+    <slot v-else></slot>
+    <template v-if="slots.footer" #footer>
+      <slot name="footer"></slot>
+    </template>
+  </ElDialog>
+</template>
+
+<style lang="scss">
+.com-dialog {
+  .el-overlay-dialog {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .el-dialog {
+    margin: 0 !important;
+
+    &__header {
+      height: 54px;
+      padding: 0;
+      margin-right: 0 !important;
+      border-bottom: 1px solid var(--el-border-color);
+    }
+
+    &__body {
+      padding: 15px !important;
+    }
+
+    &__footer {
+      border-top: 1px solid var(--el-border-color);
+    }
+
+    &__headerbtn {
+      top: 0;
+    }
+  }
+}
+</style>
diff --git a/src/components/Icon/index.ts b/src/components/Icon/index.ts
new file mode 100644
index 0000000..33d1de3
--- /dev/null
+++ b/src/components/Icon/index.ts
@@ -0,0 +1,4 @@
+import Icon from './src/Icon.vue'
+import IconSelect from './src/IconSelect.vue'
+
+export { Icon, IconSelect }
diff --git a/src/components/Icon/src/Icon.vue b/src/components/Icon/src/Icon.vue
new file mode 100644
index 0000000..4246539
--- /dev/null
+++ b/src/components/Icon/src/Icon.vue
@@ -0,0 +1,86 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import Iconify from '@purge-icons/generated'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'Icon' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('icon')
+
+const props = defineProps({
+  // icon name
+  icon: propTypes.string,
+  // icon color
+  color: propTypes.string,
+  // icon size
+  size: propTypes.number.def(16),
+  // icon svg class
+  svgClass: propTypes.string.def('')
+})
+
+const elRef = ref<ElRef>(null)
+
+const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
+
+const symbolId = computed(() => {
+  return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon
+})
+
+const getIconifyStyle = computed(() => {
+  const { color, size } = props
+  return {
+    fontSize: `${size}px`,
+    height: '1em',
+    color
+  }
+})
+
+const getSvgClass = computed(() => {
+  const { svgClass } = props
+  return `iconify ${svgClass}`
+})
+
+const updateIcon = async (icon: string) => {
+  if (unref(isLocal)) return
+
+  const el = unref(elRef)
+  if (!el) return
+
+  await nextTick()
+
+  if (!icon) return
+
+  const svg = Iconify.renderSVG(icon, {})
+  if (svg) {
+    el.textContent = ''
+    el.appendChild(svg)
+  } else {
+    const span = document.createElement('span')
+    span.className = 'iconify'
+    span.dataset.icon = icon
+    el.textContent = ''
+    el.appendChild(span)
+  }
+}
+
+watch(
+  () => props.icon,
+  (icon: string) => {
+    updateIcon(icon)
+  }
+)
+</script>
+
+<template>
+  <ElIcon :class="prefixCls" :color="color" :size="size">
+    <svg v-if="isLocal" :class="getSvgClass" aria-hidden="true">
+      <use :xlink:href="symbolId" />
+    </svg>
+
+    <span v-else ref="elRef" :class="$attrs.class" :style="getIconifyStyle">
+      <span :class="getSvgClass" :data-icon="symbolId"></span>
+    </span>
+  </ElIcon>
+</template>
diff --git a/src/components/Icon/src/IconSelect.vue b/src/components/Icon/src/IconSelect.vue
new file mode 100644
index 0000000..d4a5b07
--- /dev/null
+++ b/src/components/Icon/src/IconSelect.vue
@@ -0,0 +1,229 @@
+<script lang="ts" setup>
+import { CSSProperties } from 'vue'
+import { cloneDeep } from 'lodash-es'
+import { IconJson } from '@/components/Icon/src/data'
+
+defineOptions({ name: 'IconSelect' })
+
+type ParameterCSSProperties = (item?: string) => CSSProperties | undefined
+
+const props = defineProps({
+  modelValue: {
+    require: false,
+    type: String
+  }
+})
+const emit = defineEmits<{ (e: 'update:modelValue', v: string) }>()
+
+const visible = ref(false)
+const inputValue = toRef(props, 'modelValue')
+const iconList = ref(IconJson)
+const icon = ref('add-location')
+const currentActiveType = ref('ep:')
+// 深拷贝图标数据,前端做搜索
+const copyIconList = cloneDeep(iconList.value)
+
+const pageSize = ref(96)
+const currentPage = ref(1)
+
+// 搜索条件
+const filterValue = ref('')
+
+const tabsList = [
+  {
+    label: 'Element Plus',
+    name: 'ep:'
+  },
+  {
+    label: 'Font Awesome 4',
+    name: 'fa:'
+  },
+  {
+    label: 'Font Awesome 5 Solid',
+    name: 'fa-solid:'
+  }
+]
+
+const pageList = computed(() => {
+  if (currentPage.value === 1) {
+    return copyIconList[currentActiveType.value]
+      ?.filter((v) => v.includes(filterValue.value))
+      .slice(currentPage.value - 1, pageSize.value)
+  } else {
+    return copyIconList[currentActiveType.value]
+      ?.filter((v) => v.includes(filterValue.value))
+      .slice(
+        pageSize.value * (currentPage.value - 1),
+        pageSize.value * (currentPage.value - 1) + pageSize.value
+      )
+  }
+})
+const iconCount = computed(() => {
+  return copyIconList[currentActiveType.value] == undefined
+    ? 0
+    : copyIconList[currentActiveType.value].length
+})
+
+const iconItemStyle = computed((): ParameterCSSProperties => {
+  return (item) => {
+    if (inputValue.value === currentActiveType.value + item) {
+      return {
+        borderColor: 'var(--el-color-primary)',
+        color: 'var(--el-color-primary)'
+      }
+    }
+  }
+})
+
+function handleClick({ props }) {
+  currentPage.value = 1
+  currentActiveType.value = props.name
+  emit('update:modelValue', currentActiveType.value + iconList.value[currentActiveType.value][0])
+  icon.value = iconList.value[currentActiveType.value][0]
+}
+
+function onChangeIcon(item) {
+  icon.value = item
+  emit('update:modelValue', currentActiveType.value + item)
+  visible.value = false
+}
+
+function onCurrentChange(page) {
+  currentPage.value = page
+}
+
+watch(
+  () => {
+    return props.modelValue
+  },
+  () => {
+    if (props.modelValue && props.modelValue.indexOf(':') >= 0) {
+      currentActiveType.value = props.modelValue.substring(0, props.modelValue.indexOf(':') + 1)
+      icon.value = props.modelValue.substring(props.modelValue.indexOf(':') + 1)
+    }
+  }
+)
+watch(
+  () => {
+    return filterValue.value
+  },
+  () => {
+    currentPage.value = 1
+  }
+)
+</script>
+
+<template>
+  <div class="selector">
+    <ElInput v-model="inputValue" @click="visible = !visible">
+      <template #append>
+        <ElPopover
+          :popper-options="{
+            placement: 'auto'
+          }"
+          :visible="visible"
+          :width="350"
+          popper-class="pure-popper"
+          trigger="click"
+        >
+          <template #reference>
+            <div
+              class="h-32px w-40px flex cursor-pointer items-center justify-center"
+              @click="visible = !visible"
+            >
+              <Icon :icon="currentActiveType + icon" />
+            </div>
+          </template>
+
+          <ElInput v-model="filterValue" class="p-2" clearable placeholder="搜索图标" />
+          <ElDivider border-style="dashed" />
+
+          <ElTabs v-model="currentActiveType" @tab-click="handleClick">
+            <ElTabPane
+              v-for="(pane, index) in tabsList"
+              :key="index"
+              :label="pane.label"
+              :name="pane.name"
+            >
+              <ElDivider border-style="dashed" class="tab-divider" />
+              <ElScrollbar height="220px">
+                <ul class="ml-2 flex flex-wrap px-2">
+                  <li
+                    v-for="(item, key) in pageList"
+                    :key="key"
+                    :style="iconItemStyle(item)"
+                    :title="item"
+                    class="icon-item mr-2 mt-1 w-1/10 flex cursor-pointer items-center justify-center border border-solid p-2"
+                    @click="onChangeIcon(item)"
+                  >
+                    <Icon :icon="currentActiveType + item" />
+                  </li>
+                </ul>
+              </ElScrollbar>
+            </ElTabPane>
+          </ElTabs>
+          <ElDivider border-style="dashed" />
+
+          <ElPagination
+            :current-page="currentPage"
+            :page-size="pageSize"
+            :total="iconCount"
+            background
+            class="h-10 flex items-center justify-center"
+            layout="prev, pager, next"
+            small
+            @current-change="onCurrentChange"
+          />
+        </ElPopover>
+      </template>
+    </ElInput>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.el-divider--horizontal {
+  margin: 1px auto !important;
+}
+
+.tab-divider.el-divider--horizontal {
+  margin: 0 !important;
+}
+
+.icon-item {
+  &:hover {
+    color: var(--el-color-primary);
+    border-color: var(--el-color-primary);
+    transform: scaleX(1.05);
+    transition: all 0.4s;
+  }
+}
+
+:deep(.el-tabs__nav-next) {
+  font-size: 15px;
+  line-height: 32px;
+  box-shadow: -5px 0 5px -6px #ccc;
+}
+
+:deep(.el-tabs__nav-prev) {
+  font-size: 15px;
+  line-height: 32px;
+  box-shadow: 5px 0 5px -6px #ccc;
+}
+
+:deep(.el-input-group__append) {
+  padding: 0;
+}
+
+:deep(.el-tabs__item) {
+  height: 30px;
+  font-size: 12px;
+  font-weight: normal;
+  line-height: 30px;
+}
+
+:deep(.el-tabs__header),
+:deep(.el-tabs__nav-wrap) {
+  position: static;
+  margin: 0;
+}
+</style>
diff --git a/src/components/Icon/src/data.ts b/src/components/Icon/src/data.ts
new file mode 100644
index 0000000..2a4ed5a
--- /dev/null
+++ b/src/components/Icon/src/data.ts
@@ -0,0 +1,1961 @@
+export const IconJson = {
+  'ep:': [
+    'add-location',
+    'aim',
+    'alarm-clock',
+    'apple',
+    'arrow-down',
+    'arrow-down-bold',
+    'arrow-left',
+    'arrow-left-bold',
+    'arrow-right',
+    'arrow-right-bold',
+    'arrow-up',
+    'arrow-up-bold',
+    'avatar',
+    'back',
+    'baseball',
+    'basketball',
+    'bell',
+    'bell-filled',
+    'bicycle',
+    'bottom',
+    'bottom-left',
+    'bottom-right',
+    'bowl',
+    'box',
+    'briefcase',
+    'brush',
+    'brush-filled',
+    'burger',
+    'calendar',
+    'camera',
+    'camera-filled',
+    'caret-bottom',
+    'caret-left',
+    'caret-right',
+    'caret-top',
+    'cellphone',
+    'chat-dot-round',
+    'chat-dot-square',
+    'chat-line-round',
+    'chat-line-square',
+    'chat-round',
+    'chat-square',
+    'check',
+    'checked',
+    'cherry',
+    'chicken',
+    'circle-check',
+    'circle-check-filled',
+    'circle-close',
+    'circle-close-filled',
+    'circle-plus',
+    'circle-plus-filled',
+    'clock',
+    'close',
+    'close-bold',
+    'cloudy',
+    'coffee',
+    'coffee-cup',
+    'coin',
+    'cold-drink',
+    'collection',
+    'collection-tag',
+    'comment',
+    'compass',
+    'connection',
+    'coordinate',
+    'copy-document',
+    'cpu',
+    'credit-card',
+    'crop',
+    'd-arrow-left',
+    'd-arrow-right',
+    'd-caret',
+    'data-analysis',
+    'data-board',
+    'data-line',
+    'delete',
+    'delete-filled',
+    'delete-location',
+    'dessert',
+    'discount',
+    'dish',
+    'dish-dot',
+    'document',
+    'document-add',
+    'document-checked',
+    'document-copy',
+    'document-delete',
+    'document-remove',
+    'download',
+    'drizzling',
+    'edit',
+    'edit-pen',
+    'eleme',
+    'eleme-filled',
+    'expand',
+    'failed',
+    'female',
+    'files',
+    'film',
+    'filter',
+    'finished',
+    'first-aid-kit',
+    'flag',
+    'fold',
+    'folder',
+    'folder-add',
+    'folder-checked',
+    'folder-delete',
+    'folder-opened',
+    'folder-remove',
+    'food',
+    'football',
+    'fork-spoon',
+    'fries',
+    'full-screen',
+    'goblet',
+    'goblet-full',
+    'goblet-square',
+    'goblet-square-full',
+    'goods',
+    'goods-filled',
+    'grape',
+    'grid',
+    'guide',
+    'headset',
+    'help',
+    'help-filled',
+    'histogram',
+    'home-filled',
+    'hot-water',
+    'house',
+    'ice-cream',
+    'ice-cream-round',
+    'ice-cream-square',
+    'ice-drink',
+    'ice-tea',
+    'info-filled',
+    'iphone',
+    'key',
+    'knife-fork',
+    'lightning',
+    'link',
+    'list',
+    'loading',
+    'location',
+    'location-filled',
+    'location-information',
+    'lock',
+    'lollipop',
+    'magic-stick',
+    'magnet',
+    'male',
+    'management',
+    'map-location',
+    'medal',
+    'menu',
+    'message',
+    'message-box',
+    'mic',
+    'microphone',
+    'milk-tea',
+    'minus',
+    'money',
+    'monitor',
+    'moon',
+    'moon-night',
+    'more',
+    'more-filled',
+    'mostly-cloudy',
+    'mouse',
+    'mug',
+    'mute',
+    'mute-notification',
+    'no-smoking',
+    'notebook',
+    'notification',
+    'odometer',
+    'office-building',
+    'open',
+    'operation',
+    'opportunity',
+    'orange',
+    'paperclip',
+    'partly-cloudy',
+    'pear',
+    'phone',
+    'phone-filled',
+    'picture',
+    'picture-filled',
+    'picture-rounded',
+    'pie-chart',
+    'place',
+    'platform',
+    'plus',
+    'pointer',
+    'position',
+    'postcard',
+    'pouring',
+    'present',
+    'price-tag',
+    'printer',
+    'promotion',
+    'question-filled',
+    'rank',
+    'reading',
+    'reading-lamp',
+    'refresh',
+    'refresh-left',
+    'refresh-right',
+    'refrigerator',
+    'remove',
+    'remove-filled',
+    'right',
+    'scale-to-original',
+    'school',
+    'scissor',
+    'search',
+    'select',
+    'sell',
+    'semi-select',
+    'service',
+    'set-up',
+    'setting',
+    'share',
+    'ship',
+    'shop',
+    'shopping-bag',
+    'shopping-cart',
+    'shopping-cart-full',
+    'smoking',
+    'soccer',
+    'sold-out',
+    'sort',
+    'sort-down',
+    'sort-up',
+    'stamp',
+    'star',
+    'star-filled',
+    'stopwatch',
+    'success-filled',
+    'sugar',
+    'suitcase',
+    'sunny',
+    'sunrise',
+    'sunset',
+    'switch',
+    'switch-button',
+    'takeaway-box',
+    'ticket',
+    'tickets',
+    'timer',
+    'toilet-paper',
+    'tools',
+    'top',
+    'top-left',
+    'top-right',
+    'trend-charts',
+    'trophy',
+    'turn-off',
+    'umbrella',
+    'unlock',
+    'upload',
+    'upload-filled',
+    'user',
+    'user-filled',
+    'van',
+    'video-camera',
+    'video-camera-filled',
+    'video-pause',
+    'video-play',
+    'view',
+    'wallet',
+    'wallet-filled',
+    'warning',
+    'warning-filled',
+    'watch',
+    'watermelon',
+    'wind-power',
+    'zoom-in',
+    'zoom-out'
+  ],
+  'fa:': [
+    '500px',
+    'address-book',
+    'address-book-o',
+    'address-card',
+    'address-card-o',
+    'adjust',
+    'adn',
+    'align-center',
+    'align-justify',
+    'align-left',
+    'amazon',
+    'ambulance',
+    'american-sign-language-interpreting',
+    'anchor',
+    'android',
+    'angellist',
+    'angle-double-left',
+    'angle-double-up',
+    'angle-down',
+    'angle-left',
+    'angle-up',
+    'apple',
+    'archive',
+    'area-chart',
+    'arrow-circle-left',
+    'arrow-circle-o-left',
+    'arrow-circle-o-up',
+    'arrow-circle-up',
+    'arrow-left',
+    'arrow-up',
+    'arrows',
+    'arrows-alt',
+    'arrows-h',
+    'arrows-v',
+    'assistive-listening-systems',
+    'asterisk',
+    'at',
+    'audio-description',
+    'automobile',
+    'backward',
+    'balance-scale',
+    'ban',
+    'bandcamp',
+    'bank',
+    'bar-chart',
+    'barcode',
+    'bars',
+    'bath',
+    'battery',
+    'battery-0',
+    'battery-1',
+    'battery-2',
+    'battery-3',
+    'bed',
+    'beer',
+    'behance',
+    'behance-square',
+    'bell',
+    'bell-o',
+    'bell-slash',
+    'bell-slash-o',
+    'bicycle',
+    'binoculars',
+    'birthday-cake',
+    'bitbucket',
+    'bitbucket-square',
+    'bitcoin',
+    'black-tie',
+    'blind',
+    'bluetooth',
+    'bluetooth-b',
+    'bold',
+    'bolt',
+    'bomb',
+    'book',
+    'bookmark',
+    'bookmark-o',
+    'braille',
+    'briefcase',
+    'bug',
+    'building',
+    'building-o',
+    'bullhorn',
+    'bullseye',
+    'bus',
+    'buysellads',
+    'cab',
+    'calculator',
+    'calendar',
+    'calendar-check-o',
+    'calendar-minus-o',
+    'calendar-o',
+    'calendar-plus-o',
+    'calendar-times-o',
+    'camera',
+    'camera-retro',
+    'caret-down',
+    'caret-left',
+    'caret-square-o-left',
+    'caret-square-o-up',
+    'caret-up',
+    'cart-arrow-down',
+    'cart-plus',
+    'cc',
+    'cc-amex',
+    'cc-diners-club',
+    'cc-discover',
+    'cc-jcb',
+    'cc-mastercard',
+    'cc-paypal',
+    'cc-stripe',
+    'cc-visa',
+    'certificate',
+    'chain',
+    'chain-broken',
+    'check',
+    'check-circle',
+    'check-circle-o',
+    'check-square',
+    'check-square-o',
+    'chevron-circle-left',
+    'chevron-circle-up',
+    'chevron-down',
+    'chevron-left',
+    'chevron-up',
+    'child',
+    'chrome',
+    'circle',
+    'circle-o',
+    'circle-o-notch',
+    'circle-thin',
+    'clipboard',
+    'clock-o',
+    'clone',
+    'close',
+    'cloud',
+    'cloud-download',
+    'cloud-upload',
+    'cny',
+    'code',
+    'code-fork',
+    'codepen',
+    'codiepie',
+    'coffee',
+    'cog',
+    'cogs',
+    'columns',
+    'comment',
+    'comment-o',
+    'commenting',
+    'commenting-o',
+    'comments',
+    'comments-o',
+    'compass',
+    'compress',
+    'connectdevelop',
+    'contao',
+    'copy',
+    'copyright',
+    'creative-commons',
+    'credit-card',
+    'credit-card-alt',
+    'crop',
+    'crosshairs',
+    'css3',
+    'cube',
+    'cubes',
+    'cut',
+    'cutlery',
+    'dashboard',
+    'dashcube',
+    'database',
+    'deaf',
+    'dedent',
+    'delicious',
+    'desktop',
+    'deviantart',
+    'diamond',
+    'digg',
+    'dollar',
+    'dot-circle-o',
+    'download',
+    'dribbble',
+    'drivers-license',
+    'drivers-license-o',
+    'dropbox',
+    'drupal',
+    'edge',
+    'edit',
+    'eercast',
+    'eject',
+    'ellipsis-h',
+    'ellipsis-v',
+    'empire',
+    'envelope',
+    'envelope-o',
+    'envelope-open',
+    'envelope-open-o',
+    'envelope-square',
+    'envira',
+    'eraser',
+    'etsy',
+    'eur',
+    'exchange',
+    'exclamation',
+    'exclamation-circle',
+    'exclamation-triangle',
+    'expand',
+    'expeditedssl',
+    'external-link',
+    'external-link-square',
+    'eye',
+    'eye-slash',
+    'eyedropper',
+    'fa',
+    'facebook',
+    'facebook-official',
+    'facebook-square',
+    'fast-backward',
+    'fax',
+    'feed',
+    'female',
+    'fighter-jet',
+    'file',
+    'file-archive-o',
+    'file-audio-o',
+    'file-code-o',
+    'file-excel-o',
+    'file-image-o',
+    'file-movie-o',
+    'file-o',
+    'file-pdf-o',
+    'file-powerpoint-o',
+    'file-text',
+    'file-text-o',
+    'file-word-o',
+    'film',
+    'filter',
+    'fire',
+    'fire-extinguisher',
+    'firefox',
+    'first-order',
+    'flag',
+    'flag-checkered',
+    'flag-o',
+    'flask',
+    'flickr',
+    'floppy-o',
+    'folder',
+    'folder-o',
+    'folder-open',
+    'folder-open-o',
+    'font',
+    'fonticons',
+    'fort-awesome',
+    'forumbee',
+    'foursquare',
+    'free-code-camp',
+    'frown-o',
+    'futbol-o',
+    'gamepad',
+    'gavel',
+    'gbp',
+    'genderless',
+    'get-pocket',
+    'gg',
+    'gg-circle',
+    'gift',
+    'git',
+    'git-square',
+    'github',
+    'github-alt',
+    'github-square',
+    'gitlab',
+    'gittip',
+    'glass',
+    'glide',
+    'glide-g',
+    'globe',
+    'google',
+    'google-plus',
+    'google-plus-circle',
+    'google-plus-square',
+    'google-wallet',
+    'graduation-cap',
+    'grav',
+    'group',
+    'h-square',
+    'hacker-news',
+    'hand-grab-o',
+    'hand-lizard-o',
+    'hand-o-left',
+    'hand-o-up',
+    'hand-paper-o',
+    'hand-peace-o',
+    'hand-pointer-o',
+    'hand-scissors-o',
+    'hand-spock-o',
+    'handshake-o',
+    'hashtag',
+    'hdd-o',
+    'header',
+    'headphones',
+    'heart',
+    'heart-o',
+    'heartbeat',
+    'history',
+    'home',
+    'hospital-o',
+    'hourglass',
+    'hourglass-1',
+    'hourglass-2',
+    'hourglass-3',
+    'hourglass-o',
+    'houzz',
+    'html5',
+    'i-cursor',
+    'id-badge',
+    'ils',
+    'image',
+    'imdb',
+    'inbox',
+    'indent',
+    'industry',
+    'info',
+    'info-circle',
+    'inr',
+    'instagram',
+    'internet-explorer',
+    'intersex',
+    'ioxhost',
+    'italic',
+    'joomla',
+    'jsfiddle',
+    'key',
+    'keyboard-o',
+    'krw',
+    'language',
+    'laptop',
+    'lastfm',
+    'lastfm-square',
+    'leaf',
+    'leanpub',
+    'lemon-o',
+    'level-up',
+    'life-bouy',
+    'lightbulb-o',
+    'line-chart',
+    'linkedin',
+    'linkedin-square',
+    'linode',
+    'linux',
+    'list',
+    'list-alt',
+    'list-ol',
+    'list-ul',
+    'location-arrow',
+    'lock',
+    'long-arrow-left',
+    'long-arrow-up',
+    'low-vision',
+    'magic',
+    'magnet',
+    'mail-forward',
+    'mail-reply',
+    'mail-reply-all',
+    'male',
+    'map',
+    'map-marker',
+    'map-o',
+    'map-pin',
+    'map-signs',
+    'mars',
+    'mars-double',
+    'mars-stroke',
+    'mars-stroke-h',
+    'mars-stroke-v',
+    'maxcdn',
+    'meanpath',
+    'medium',
+    'medkit',
+    'meetup',
+    'meh-o',
+    'mercury',
+    'microchip',
+    'microphone',
+    'microphone-slash',
+    'minus',
+    'minus-circle',
+    'minus-square',
+    'minus-square-o',
+    'mixcloud',
+    'mobile',
+    'modx',
+    'money',
+    'moon-o',
+    'motorcycle',
+    'mouse-pointer',
+    'music',
+    'neuter',
+    'newspaper-o',
+    'object-group',
+    'object-ungroup',
+    'odnoklassniki',
+    'odnoklassniki-square',
+    'opencart',
+    'openid',
+    'opera',
+    'optin-monster',
+    'pagelines',
+    'paint-brush',
+    'paper-plane',
+    'paper-plane-o',
+    'paperclip',
+    'paragraph',
+    'pause',
+    'pause-circle',
+    'pause-circle-o',
+    'paw',
+    'paypal',
+    'pencil',
+    'pencil-square',
+    'percent',
+    'phone',
+    'phone-square',
+    'pie-chart',
+    'pied-piper',
+    'pied-piper-alt',
+    'pied-piper-pp',
+    'pinterest',
+    'pinterest-p',
+    'pinterest-square',
+    'plane',
+    'play',
+    'play-circle',
+    'play-circle-o',
+    'plug',
+    'plus',
+    'plus-circle',
+    'plus-square',
+    'plus-square-o',
+    'podcast',
+    'power-off',
+    'print',
+    'product-hunt',
+    'puzzle-piece',
+    'qq',
+    'qrcode',
+    'question',
+    'question-circle',
+    'question-circle-o',
+    'quora',
+    'quote-left',
+    'quote-right',
+    'ra',
+    'random',
+    'ravelry',
+    'recycle',
+    'reddit',
+    'reddit-alien',
+    'reddit-square',
+    'refresh',
+    'registered',
+    'renren',
+    'repeat',
+    'retweet',
+    'road',
+    'rocket',
+    'rotate-left',
+    'rouble',
+    'rss-square',
+    'safari',
+    'scribd',
+    'search',
+    'search-minus',
+    'search-plus',
+    'sellsy',
+    'server',
+    'share-alt',
+    'share-alt-square',
+    'share-square',
+    'share-square-o',
+    'shield',
+    'ship',
+    'shirtsinbulk',
+    'shopping-bag',
+    'shopping-basket',
+    'shopping-cart',
+    'shower',
+    'sign-in',
+    'sign-language',
+    'sign-out',
+    'signal',
+    'simplybuilt',
+    'sitemap',
+    'skyatlas',
+    'skype',
+    'slack',
+    'sliders',
+    'slideshare',
+    'smile-o',
+    'snapchat',
+    'snapchat-ghost',
+    'snapchat-square',
+    'snowflake-o',
+    'sort',
+    'sort-alpha-asc',
+    'sort-alpha-desc',
+    'sort-amount-asc',
+    'sort-amount-desc',
+    'sort-asc',
+    'sort-numeric-asc',
+    'sort-numeric-desc',
+    'soundcloud',
+    'space-shuttle',
+    'spinner',
+    'spoon',
+    'spotify',
+    'square',
+    'square-o',
+    'stack-exchange',
+    'stack-overflow',
+    'star',
+    'star-half',
+    'star-half-empty',
+    'star-o',
+    'steam',
+    'steam-square',
+    'step-backward',
+    'stethoscope',
+    'sticky-note',
+    'sticky-note-o',
+    'stop',
+    'stop-circle',
+    'stop-circle-o',
+    'street-view',
+    'strikethrough',
+    'stumbleupon',
+    'stumbleupon-circle',
+    'subscript',
+    'subway',
+    'suitcase',
+    'sun-o',
+    'superpowers',
+    'superscript',
+    'table',
+    'tablet',
+    'tag',
+    'tags',
+    'tasks',
+    'telegram',
+    'television',
+    'tencent-weibo',
+    'terminal',
+    'text-height',
+    'text-width',
+    'th',
+    'th-large',
+    'th-list',
+    'themeisle',
+    'thermometer',
+    'thermometer-0',
+    'thermometer-1',
+    'thermometer-2',
+    'thermometer-3',
+    'thumb-tack',
+    'thumbs-down',
+    'thumbs-o-up',
+    'thumbs-up',
+    'ticket',
+    'times-circle',
+    'times-circle-o',
+    'times-rectangle',
+    'times-rectangle-o',
+    'tint',
+    'toggle-off',
+    'toggle-on',
+    'trademark',
+    'train',
+    'transgender-alt',
+    'trash',
+    'trash-o',
+    'tree',
+    'trello',
+    'tripadvisor',
+    'trophy',
+    'truck',
+    'try',
+    'tty',
+    'tumblr',
+    'tumblr-square',
+    'twitch',
+    'twitter',
+    'twitter-square',
+    'umbrella',
+    'underline',
+    'universal-access',
+    'unlock',
+    'unlock-alt',
+    'upload',
+    'usb',
+    'user',
+    'user-circle',
+    'user-circle-o',
+    'user-md',
+    'user-o',
+    'user-plus',
+    'user-secret',
+    'user-times',
+    'venus',
+    'venus-double',
+    'venus-mars',
+    'viacoin',
+    'viadeo',
+    'viadeo-square',
+    'video-camera',
+    'vimeo',
+    'vimeo-square',
+    'vine',
+    'vk',
+    'volume-control-phone',
+    'volume-down',
+    'volume-off',
+    'volume-up',
+    'wechat',
+    'weibo',
+    'whatsapp',
+    'wheelchair',
+    'wheelchair-alt',
+    'wifi',
+    'wikipedia-w',
+    'window-maximize',
+    'window-minimize',
+    'window-restore',
+    'windows',
+    'wordpress',
+    'wpbeginner',
+    'wpexplorer',
+    'wpforms',
+    'wrench',
+    'xing',
+    'xing-square',
+    'y-combinator',
+    'yahoo',
+    'yelp',
+    'yoast',
+    'youtube',
+    'youtube-play',
+    'youtube-square'
+  ],
+  'fa-solid:': [
+    'abacus',
+    'ad',
+    'address-book',
+    'address-card',
+    'adjust',
+    'air-freshener',
+    'align-center',
+    'align-justify',
+    'align-left',
+    'align-right',
+    'allergies',
+    'ambulance',
+    'american-sign-language-interpreting',
+    'anchor',
+    'angle-double-down',
+    'angle-double-left',
+    'angle-double-right',
+    'angle-double-up',
+    'angle-down',
+    'angle-left',
+    'angle-right',
+    'angle-up',
+    'angry',
+    'ankh',
+    'apple-alt',
+    'archive',
+    'archway',
+    'arrow-alt-circle-down',
+    'arrow-alt-circle-left',
+    'arrow-alt-circle-right',
+    'arrow-alt-circle-up',
+    'arrow-circle-down',
+    'arrow-circle-left',
+    'arrow-circle-right',
+    'arrow-circle-up',
+    'arrow-down',
+    'arrow-left',
+    'arrow-right',
+    'arrow-up',
+    'arrows-alt',
+    'arrows-alt-h',
+    'arrows-alt-v',
+    'assistive-listening-systems',
+    'asterisk',
+    'at',
+    'atlas',
+    'atom',
+    'audio-description',
+    'award',
+    'baby',
+    'baby-carriage',
+    'backspace',
+    'backward',
+    'bacon',
+    'bacteria',
+    'bacterium',
+    'bahai',
+    'balance-scale',
+    'balance-scale-left',
+    'balance-scale-right',
+    'ban',
+    'band-aid',
+    'barcode',
+    'bars',
+    'baseball-ball',
+    'basketball-ball',
+    'bath',
+    'battery-empty',
+    'battery-full',
+    'battery-half',
+    'battery-quarter',
+    'battery-three-quarters',
+    'bed',
+    'beer',
+    'bell',
+    'bell-slash',
+    'bezier-curve',
+    'bible',
+    'bicycle',
+    'biking',
+    'binoculars',
+    'biohazard',
+    'birthday-cake',
+    'blender',
+    'blender-phone',
+    'blind',
+    'blog',
+    'bold',
+    'bolt',
+    'bomb',
+    'bone',
+    'bong',
+    'book',
+    'book-dead',
+    'book-medical',
+    'book-open',
+    'book-reader',
+    'bookmark',
+    'border-all',
+    'border-none',
+    'border-style',
+    'bowling-ball',
+    'box',
+    'box-open',
+    'box-tissue',
+    'boxes',
+    'braille',
+    'brain',
+    'bread-slice',
+    'briefcase',
+    'briefcase-medical',
+    'broadcast-tower',
+    'broom',
+    'brush',
+    'bug',
+    'building',
+    'bullhorn',
+    'bullseye',
+    'burn',
+    'bus',
+    'bus-alt',
+    'business-time',
+    'calculator',
+    'calculator-alt',
+    'calendar',
+    'calendar-alt',
+    'calendar-check',
+    'calendar-day',
+    'calendar-minus',
+    'calendar-plus',
+    'calendar-times',
+    'calendar-week',
+    'camera',
+    'camera-retro',
+    'campground',
+    'candy-cane',
+    'cannabis',
+    'capsules',
+    'car',
+    'car-alt',
+    'car-battery',
+    'car-crash',
+    'car-side',
+    'caravan',
+    'caret-down',
+    'caret-left',
+    'caret-right',
+    'caret-square-down',
+    'caret-square-left',
+    'caret-square-right',
+    'caret-square-up',
+    'caret-up',
+    'carrot',
+    'cart-arrow-down',
+    'cart-plus',
+    'cash-register',
+    'cat',
+    'certificate',
+    'chair',
+    'chalkboard',
+    'chalkboard-teacher',
+    'charging-station',
+    'chart-area',
+    'chart-bar',
+    'chart-line',
+    'chart-pie',
+    'check',
+    'check-circle',
+    'check-double',
+    'check-square',
+    'cheese',
+    'chess',
+    'chess-bishop',
+    'chess-board',
+    'chess-king',
+    'chess-knight',
+    'chess-pawn',
+    'chess-queen',
+    'chess-rook',
+    'chevron-circle-down',
+    'chevron-circle-left',
+    'chevron-circle-right',
+    'chevron-circle-up',
+    'chevron-down',
+    'chevron-left',
+    'chevron-right',
+    'chevron-up',
+    'child',
+    'church',
+    'circle',
+    'circle-notch',
+    'city',
+    'clinic-medical',
+    'clipboard',
+    'clipboard-check',
+    'clipboard-list',
+    'clock',
+    'clone',
+    'closed-captioning',
+    'cloud',
+    'cloud-download-alt',
+    'cloud-meatball',
+    'cloud-moon',
+    'cloud-moon-rain',
+    'cloud-rain',
+    'cloud-showers-heavy',
+    'cloud-sun',
+    'cloud-sun-rain',
+    'cloud-upload-alt',
+    'cocktail',
+    'code',
+    'code-branch',
+    'coffee',
+    'cog',
+    'cogs',
+    'coins',
+    'columns',
+    'comment',
+    'comment-alt',
+    'comment-dollar',
+    'comment-dots',
+    'comment-medical',
+    'comment-slash',
+    'comments',
+    'comments-dollar',
+    'compact-disc',
+    'compass',
+    'compress',
+    'compress-alt',
+    'compress-arrows-alt',
+    'concierge-bell',
+    'cookie',
+    'cookie-bite',
+    'copy',
+    'copyright',
+    'couch',
+    'credit-card',
+    'crop',
+    'crop-alt',
+    'cross',
+    'crosshairs',
+    'crow',
+    'crown',
+    'crutch',
+    'cube',
+    'cubes',
+    'cut',
+    'database',
+    'deaf',
+    'democrat',
+    'desktop',
+    'dharmachakra',
+    'diagnoses',
+    'dice',
+    'dice-d20',
+    'dice-d6',
+    'dice-five',
+    'dice-four',
+    'dice-one',
+    'dice-six',
+    'dice-three',
+    'dice-two',
+    'digital-tachograph',
+    'directions',
+    'disease',
+    'divide',
+    'dizzy',
+    'dna',
+    'dog',
+    'dollar-sign',
+    'dolly',
+    'dolly-flatbed',
+    'donate',
+    'door-closed',
+    'door-open',
+    'dot-circle',
+    'dove',
+    'download',
+    'drafting-compass',
+    'dragon',
+    'draw-polygon',
+    'drum',
+    'drum-steelpan',
+    'drumstick-bite',
+    'dumbbell',
+    'dumpster',
+    'dumpster-fire',
+    'dungeon',
+    'edit',
+    'egg',
+    'eject',
+    'ellipsis-h',
+    'ellipsis-v',
+    'empty-set',
+    'envelope',
+    'envelope-open',
+    'envelope-open-text',
+    'envelope-square',
+    'equals',
+    'eraser',
+    'ethernet',
+    'euro-sign',
+    'exchange-alt',
+    'exclamation',
+    'exclamation-circle',
+    'exclamation-triangle',
+    'expand',
+    'expand-alt',
+    'expand-arrows-alt',
+    'external-link-alt',
+    'external-link-square-alt',
+    'eye',
+    'eye-dropper',
+    'eye-slash',
+    'fan',
+    'fast-backward',
+    'fast-forward',
+    'faucet',
+    'fax',
+    'feather',
+    'feather-alt',
+    'female',
+    'fighter-jet',
+    'file',
+    'file-alt',
+    'file-archive',
+    'file-audio',
+    'file-code',
+    'file-contract',
+    'file-csv',
+    'file-download',
+    'file-excel',
+    'file-export',
+    'file-image',
+    'file-import',
+    'file-invoice',
+    'file-invoice-dollar',
+    'file-medical',
+    'file-medical-alt',
+    'file-pdf',
+    'file-powerpoint',
+    'file-prescription',
+    'file-signature',
+    'file-upload',
+    'file-video',
+    'file-word',
+    'fill',
+    'fill-drip',
+    'film',
+    'filter',
+    'fingerprint',
+    'fire',
+    'fire-alt',
+    'fire-extinguisher',
+    'first-aid',
+    'fish',
+    'fist-raised',
+    'flag',
+    'flag-checkered',
+    'flag-usa',
+    'flask',
+    'flushed',
+    'folder',
+    'folder-minus',
+    'folder-open',
+    'folder-plus',
+    'font',
+    'football-ball',
+    'forward',
+    'frog',
+    'frown',
+    'frown-open',
+    'function',
+    'funnel-dollar',
+    'futbol',
+    'gamepad',
+    'gas-pump',
+    'gavel',
+    'gem',
+    'genderless',
+    'ghost',
+    'gift',
+    'gifts',
+    'glass-cheers',
+    'glass-martini',
+    'glass-martini-alt',
+    'glass-whiskey',
+    'glasses',
+    'globe',
+    'globe-africa',
+    'globe-americas',
+    'globe-asia',
+    'globe-europe',
+    'golf-ball',
+    'gopuram',
+    'graduation-cap',
+    'greater-than',
+    'greater-than-equal',
+    'grimace',
+    'grin',
+    'grin-alt',
+    'grin-beam',
+    'grin-beam-sweat',
+    'grin-hearts',
+    'grin-squint',
+    'grin-squint-tears',
+    'grin-stars',
+    'grin-tears',
+    'grin-tongue',
+    'grin-tongue-squint',
+    'grin-tongue-wink',
+    'grin-wink',
+    'grip-horizontal',
+    'grip-lines',
+    'grip-lines-vertical',
+    'grip-vertical',
+    'guitar',
+    'h-square',
+    'hamburger',
+    'hammer',
+    'hamsa',
+    'hand-holding',
+    'hand-holding-heart',
+    'hand-holding-medical',
+    'hand-holding-usd',
+    'hand-holding-water',
+    'hand-lizard',
+    'hand-middle-finger',
+    'hand-paper',
+    'hand-peace',
+    'hand-point-down',
+    'hand-point-left',
+    'hand-point-right',
+    'hand-point-up',
+    'hand-pointer',
+    'hand-rock',
+    'hand-scissors',
+    'hand-sparkles',
+    'hand-spock',
+    'hands',
+    'hands-helping',
+    'hands-wash',
+    'handshake',
+    'handshake-alt-slash',
+    'handshake-slash',
+    'hanukiah',
+    'hard-hat',
+    'hashtag',
+    'hat-cowboy',
+    'hat-cowboy-side',
+    'hat-wizard',
+    'hdd',
+    'head-side-cough',
+    'head-side-cough-slash',
+    'head-side-mask',
+    'head-side-virus',
+    'heading',
+    'headphones',
+    'headphones-alt',
+    'headset',
+    'heart',
+    'heart-broken',
+    'heartbeat',
+    'helicopter',
+    'highlighter',
+    'hiking',
+    'hippo',
+    'history',
+    'hockey-puck',
+    'holly-berry',
+    'home',
+    'horse',
+    'horse-head',
+    'hospital',
+    'hospital-alt',
+    'hospital-symbol',
+    'hospital-user',
+    'hot-tub',
+    'hotdog',
+    'hotel',
+    'hourglass',
+    'hourglass-end',
+    'hourglass-half',
+    'hourglass-start',
+    'house-damage',
+    'house-user',
+    'hryvnia',
+    'i-cursor',
+    'ice-cream',
+    'icicles',
+    'icons',
+    'id-badge',
+    'id-card',
+    'id-card-alt',
+    'igloo',
+    'image',
+    'images',
+    'inbox',
+    'indent',
+    'industry',
+    'infinity',
+    'info',
+    'info-circle',
+    'integral',
+    'intersection',
+    'italic',
+    'jedi',
+    'joint',
+    'journal-whills',
+    'kaaba',
+    'key',
+    'keyboard',
+    'khanda',
+    'kiss',
+    'kiss-beam',
+    'kiss-wink-heart',
+    'kiwi-bird',
+    'lambda',
+    'landmark',
+    'language',
+    'laptop',
+    'laptop-code',
+    'laptop-house',
+    'laptop-medical',
+    'laugh',
+    'laugh-beam',
+    'laugh-squint',
+    'laugh-wink',
+    'layer-group',
+    'leaf',
+    'lemon',
+    'less-than',
+    'less-than-equal',
+    'level-down-alt',
+    'level-up-alt',
+    'life-ring',
+    'lightbulb',
+    'link',
+    'lira-sign',
+    'list',
+    'list-alt',
+    'list-ol',
+    'list-ul',
+    'location-arrow',
+    'lock',
+    'lock-open',
+    'long-arrow-alt-down',
+    'long-arrow-alt-left',
+    'long-arrow-alt-right',
+    'long-arrow-alt-up',
+    'low-vision',
+    'luggage-cart',
+    'lungs',
+    'lungs-virus',
+    'magic',
+    'magnet',
+    'mail-bulk',
+    'male',
+    'map',
+    'map-marked',
+    'map-marked-alt',
+    'map-marker',
+    'map-marker-alt',
+    'map-pin',
+    'map-signs',
+    'marker',
+    'mars',
+    'mars-double',
+    'mars-stroke',
+    'mars-stroke-h',
+    'mars-stroke-v',
+    'mask',
+    'medal',
+    'medkit',
+    'meh',
+    'meh-blank',
+    'meh-rolling-eyes',
+    'memory',
+    'menorah',
+    'mercury',
+    'meteor',
+    'microchip',
+    'microphone',
+    'microphone-alt',
+    'microphone-alt-slash',
+    'microphone-slash',
+    'microscope',
+    'minus',
+    'minus-circle',
+    'minus-square',
+    'mitten',
+    'mobile',
+    'mobile-alt',
+    'money-bill',
+    'money-bill-alt',
+    'money-bill-wave',
+    'money-bill-wave-alt',
+    'money-check',
+    'money-check-alt',
+    'monument',
+    'moon',
+    'mortar-pestle',
+    'mosque',
+    'motorcycle',
+    'mountain',
+    'mouse',
+    'mouse-pointer',
+    'mug-hot',
+    'music',
+    'network-wired',
+    'neuter',
+    'newspaper',
+    'not-equal',
+    'notes-medical',
+    'object-group',
+    'object-ungroup',
+    'oil-can',
+    'om',
+    'omega',
+    'otter',
+    'outdent',
+    'pager',
+    'paint-brush',
+    'paint-roller',
+    'palette',
+    'pallet',
+    'paper-plane',
+    'paperclip',
+    'parachute-box',
+    'paragraph',
+    'parking',
+    'passport',
+    'pastafarianism',
+    'paste',
+    'pause',
+    'pause-circle',
+    'paw',
+    'peace',
+    'pen',
+    'pen-alt',
+    'pen-fancy',
+    'pen-nib',
+    'pen-square',
+    'pencil-alt',
+    'pencil-ruler',
+    'people-arrows',
+    'people-carry',
+    'pepper-hot',
+    'percent',
+    'percentage',
+    'person-booth',
+    'phone',
+    'phone-alt',
+    'phone-slash',
+    'phone-square',
+    'phone-square-alt',
+    'phone-volume',
+    'photo-video',
+    'pi',
+    'piggy-bank',
+    'pills',
+    'pizza-slice',
+    'place-of-worship',
+    'plane',
+    'plane-arrival',
+    'plane-departure',
+    'plane-slash',
+    'play',
+    'play-circle',
+    'plug',
+    'plus',
+    'plus-circle',
+    'plus-square',
+    'podcast',
+    'poll',
+    'poll-h',
+    'poo',
+    'poo-storm',
+    'poop',
+    'portrait',
+    'pound-sign',
+    'power-off',
+    'pray',
+    'praying-hands',
+    'prescription',
+    'prescription-bottle',
+    'prescription-bottle-alt',
+    'print',
+    'procedures',
+    'project-diagram',
+    'pump-medical',
+    'pump-soap',
+    'puzzle-piece',
+    'qrcode',
+    'question',
+    'question-circle',
+    'quidditch',
+    'quote-left',
+    'quote-right',
+    'quran',
+    'radiation',
+    'radiation-alt',
+    'rainbow',
+    'random',
+    'receipt',
+    'record-vinyl',
+    'recycle',
+    'redo',
+    'redo-alt',
+    'registered',
+    'remove-format',
+    'reply',
+    'reply-all',
+    'republican',
+    'restroom',
+    'retweet',
+    'ribbon',
+    'ring',
+    'road',
+    'robot',
+    'rocket',
+    'route',
+    'rss',
+    'rss-square',
+    'ruble-sign',
+    'ruler',
+    'ruler-combined',
+    'ruler-horizontal',
+    'ruler-vertical',
+    'running',
+    'rupee-sign',
+    'sad-cry',
+    'sad-tear',
+    'satellite',
+    'satellite-dish',
+    'save',
+    'school',
+    'screwdriver',
+    'scroll',
+    'sd-card',
+    'search',
+    'search-dollar',
+    'search-location',
+    'search-minus',
+    'search-plus',
+    'seedling',
+    'server',
+    'shapes',
+    'share',
+    'share-alt',
+    'share-alt-square',
+    'share-square',
+    'shekel-sign',
+    'shield-alt',
+    'shield-virus',
+    'ship',
+    'shipping-fast',
+    'shoe-prints',
+    'shopping-bag',
+    'shopping-basket',
+    'shopping-cart',
+    'shower',
+    'shuttle-van',
+    'sigma',
+    'sign',
+    'sign-in-alt',
+    'sign-language',
+    'sign-out-alt',
+    'signal',
+    'signal-alt',
+    'signal-alt-slash',
+    'signal-slash',
+    'signature',
+    'sim-card',
+    'sink',
+    'sitemap',
+    'skating',
+    'skiing',
+    'skiing-nordic',
+    'skull',
+    'skull-crossbones',
+    'slash',
+    'sleigh',
+    'sliders-h',
+    'smile',
+    'smile-beam',
+    'smile-wink',
+    'smog',
+    'smoking',
+    'smoking-ban',
+    'sms',
+    'snowboarding',
+    'snowflake',
+    'snowman',
+    'snowplow',
+    'soap',
+    'socks',
+    'solar-panel',
+    'sort',
+    'sort-alpha-down',
+    'sort-alpha-down-alt',
+    'sort-alpha-up',
+    'sort-alpha-up-alt',
+    'sort-amount-down',
+    'sort-amount-down-alt',
+    'sort-amount-up',
+    'sort-amount-up-alt',
+    'sort-down',
+    'sort-numeric-down',
+    'sort-numeric-down-alt',
+    'sort-numeric-up',
+    'sort-numeric-up-alt',
+    'sort-up',
+    'spa',
+    'space-shuttle',
+    'spell-check',
+    'spider',
+    'spinner',
+    'splotch',
+    'spray-can',
+    'square',
+    'square-full',
+    'square-root',
+    'square-root-alt',
+    'stamp',
+    'star',
+    'star-and-crescent',
+    'star-half',
+    'star-half-alt',
+    'star-of-david',
+    'star-of-life',
+    'step-backward',
+    'step-forward',
+    'stethoscope',
+    'sticky-note',
+    'stop',
+    'stop-circle',
+    'stopwatch',
+    'stopwatch-20',
+    'store',
+    'store-alt',
+    'store-alt-slash',
+    'store-slash',
+    'stream',
+    'street-view',
+    'strikethrough',
+    'stroopwafel',
+    'subscript',
+    'subway',
+    'suitcase',
+    'suitcase-rolling',
+    'sun',
+    'superscript',
+    'surprise',
+    'swatchbook',
+    'swimmer',
+    'swimming-pool',
+    'synagogue',
+    'sync',
+    'sync-alt',
+    'syringe',
+    'table',
+    'table-tennis',
+    'tablet',
+    'tablet-alt',
+    'tablets',
+    'tachometer-alt',
+    'tag',
+    'tags',
+    'tally',
+    'tape',
+    'tasks',
+    'taxi',
+    'teeth',
+    'teeth-open',
+    'temperature-high',
+    'temperature-low',
+    'tenge',
+    'terminal',
+    'text-height',
+    'text-width',
+    'th',
+    'th-large',
+    'th-list',
+    'theater-masks',
+    'thermometer',
+    'thermometer-empty',
+    'thermometer-full',
+    'thermometer-half',
+    'thermometer-quarter',
+    'thermometer-three-quarters',
+    'theta',
+    'thumbs-down',
+    'thumbs-up',
+    'thumbtack',
+    'ticket-alt',
+    'tilde',
+    'times',
+    'times-circle',
+    'tint',
+    'tint-slash',
+    'tired',
+    'toggle-off',
+    'toggle-on',
+    'toilet',
+    'toilet-paper',
+    'toilet-paper-slash',
+    'toolbox',
+    'tools',
+    'tooth',
+    'torah',
+    'torii-gate',
+    'tractor',
+    'trademark',
+    'traffic-light',
+    'trailer',
+    'train',
+    'tram',
+    'transgender',
+    'transgender-alt',
+    'trash',
+    'trash-alt',
+    'trash-restore',
+    'trash-restore-alt',
+    'tree',
+    'trophy',
+    'truck',
+    'truck-loading',
+    'truck-monster',
+    'truck-moving',
+    'truck-pickup',
+    'tshirt',
+    'tty',
+    'tv',
+    'umbrella',
+    'umbrella-beach',
+    'underline',
+    'undo',
+    'undo-alt',
+    'union',
+    'universal-access',
+    'university',
+    'unlink',
+    'unlock',
+    'unlock-alt',
+    'upload',
+    'user',
+    'user-alt',
+    'user-alt-slash',
+    'user-astronaut',
+    'user-check',
+    'user-circle',
+    'user-clock',
+    'user-cog',
+    'user-edit',
+    'user-friends',
+    'user-graduate',
+    'user-injured',
+    'user-lock',
+    'user-md',
+    'user-minus',
+    'user-ninja',
+    'user-nurse',
+    'user-plus',
+    'user-secret',
+    'user-shield',
+    'user-slash',
+    'user-tag',
+    'user-tie',
+    'user-times',
+    'users',
+    'users-cog',
+    'users-slash',
+    'utensil-spoon',
+    'utensils',
+    'value-absolute',
+    'vector-square',
+    'venus',
+    'venus-double',
+    'venus-mars',
+    'vest',
+    'vest-patches',
+    'vial',
+    'vials',
+    'video',
+    'video-slash',
+    'vihara',
+    'virus',
+    'virus-slash',
+    'viruses',
+    'voicemail',
+    'volleyball-ball',
+    'volume',
+    'volume-down',
+    'volume-mute',
+    'volume-off',
+    'volume-slash',
+    'volume-up',
+    'vote-yea',
+    'vr-cardboard',
+    'walking',
+    'wallet',
+    'warehouse',
+    'water',
+    'wave-square',
+    'weight',
+    'weight-hanging',
+    'wheelchair',
+    'wifi',
+    'wifi-slash',
+    'wind',
+    'window-close',
+    'window-maximize',
+    'window-minimize',
+    'window-restore',
+    'wine-bottle',
+    'wine-glass',
+    'wine-glass-alt',
+    'won-sign',
+    'wrench',
+    'x-ray',
+    'yen-sign',
+    'yin-yang'
+  ]
+}
diff --git a/src/hooks/event/useScrollTo.ts b/src/hooks/event/useScrollTo.ts
new file mode 100644
index 0000000..92aec87
--- /dev/null
+++ b/src/hooks/event/useScrollTo.ts
@@ -0,0 +1,60 @@
+export interface ScrollToParams {
+  el: HTMLElement
+  to: number
+  position: string
+  duration?: number
+  callback?: () => void
+}
+
+const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
+  t /= d / 2
+  if (t < 1) {
+    return (c / 2) * t * t + b
+  }
+  t--
+  return (-c / 2) * (t * (t - 2) - 1) + b
+}
+const move = (el: HTMLElement, position: string, amount: number) => {
+  el[position] = amount
+}
+
+export function useScrollTo({
+  el,
+  position = 'scrollLeft',
+  to,
+  duration = 500,
+  callback
+}: ScrollToParams) {
+  const isActiveRef = ref(false)
+  const start = el[position]
+  const change = to - start
+  const increment = 20
+  let currentTime = 0
+
+  function animateScroll() {
+    if (!unref(isActiveRef)) {
+      return
+    }
+    currentTime += increment
+    const val = easeInOutQuad(currentTime, start, change, duration)
+    move(el, position, val)
+    if (currentTime < duration && unref(isActiveRef)) {
+      requestAnimationFrame(animateScroll)
+    } else {
+      if (callback) {
+        callback()
+      }
+    }
+  }
+
+  function run() {
+    isActiveRef.value = true
+    animateScroll()
+  }
+
+  function stop() {
+    isActiveRef.value = false
+  }
+
+  return { start: run, stop }
+}
diff --git a/src/hooks/web/useCache.ts b/src/hooks/web/useCache.ts
new file mode 100644
index 0000000..4f39f30
--- /dev/null
+++ b/src/hooks/web/useCache.ts
@@ -0,0 +1,39 @@
+/**
+ * 配置浏览器本地存储的方式,可直接存储对象数组。
+ */
+
+import WebStorageCache from 'web-storage-cache'
+
+type CacheType = 'localStorage' | 'sessionStorage'
+
+export const CACHE_KEY = {
+  // 用户相关
+  ROLE_ROUTERS: 'roleRouters',
+  USER: 'user',
+  // 系统设置
+  IS_DARK: 'isDark',
+  LANG: 'lang',
+  THEME: 'theme',
+  LAYOUT: 'layout',
+  DICT_CACHE: 'dictCache',
+  // 登录表单
+  LoginForm: 'loginForm',
+  TenantId: 'tenantId'
+}
+
+export const useCache = (type: CacheType = 'localStorage') => {
+  const wsCache: WebStorageCache = new WebStorageCache({
+    storage: type
+  })
+
+  return {
+    wsCache
+  }
+}
+
+export const deleteUserCache = () => {
+  const { wsCache } = useCache()
+  wsCache.delete(CACHE_KEY.USER)
+  wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
+  // 注意,不要清理 LoginForm 登录表单
+}
diff --git a/src/hooks/web/useConfigGlobal.ts b/src/hooks/web/useConfigGlobal.ts
new file mode 100644
index 0000000..afb3db3
--- /dev/null
+++ b/src/hooks/web/useConfigGlobal.ts
@@ -0,0 +1,9 @@
+import { ConfigGlobalTypes } from '@/types/configGlobal'
+
+export const useConfigGlobal = () => {
+  const configGlobal = inject('configGlobal', {}) as ConfigGlobalTypes
+
+  return {
+    configGlobal
+  }
+}
diff --git a/src/hooks/web/useCrudSchemas.ts b/src/hooks/web/useCrudSchemas.ts
new file mode 100644
index 0000000..458b57e
--- /dev/null
+++ b/src/hooks/web/useCrudSchemas.ts
@@ -0,0 +1,326 @@
+import { reactive } from 'vue'
+import { AxiosPromise } from 'axios'
+import { findIndex } from '@/utils'
+import { eachTree, filter, treeMap } from '@/utils/tree'
+import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict'
+
+import { FormSchema } from '@/types/form'
+import { TableColumn } from '@/types/table'
+import { DescriptionsSchema } from '@/types/descriptions'
+import { ComponentOptions, ComponentProps } from '@/types/components'
+import { DictTag } from '@/components/DictTag'
+import { cloneDeep, merge } from 'lodash-es'
+
+export type CrudSchema = Omit<TableColumn, 'children'> & {
+  isSearch?: boolean // 是否在查询显示
+  search?: CrudSearchParams // 查询的详细配置
+  isTable?: boolean // 是否在列表显示
+  table?: CrudTableParams // 列表的详细配置
+  isForm?: boolean // 是否在表单显示
+  form?: CrudFormParams // 表单的详细配置
+  isDetail?: boolean // 是否在详情显示
+  detail?: CrudDescriptionsParams // 详情的详细配置
+  children?: CrudSchema[]
+  dictType?: string // 字典类型
+  dictClass?: 'string' | 'number' | 'boolean' // 字典数据类型 string | number | boolean
+}
+
+type CrudSearchParams = {
+  // 是否显示在查询项
+  show?: boolean
+  // 接口
+  api?: () => Promise<any>
+  // 搜索字段
+  field?: string
+} & Omit<FormSchema, 'field'>
+
+type CrudTableParams = {
+  // 是否显示表头
+  show?: boolean
+  // 列宽配置
+  width?: number | string
+  // 列是否固定在左侧或者右侧
+  fixed?: 'left' | 'right'
+} & Omit<FormSchema, 'field'>
+type CrudFormParams = {
+  // 是否显示表单项
+  show?: boolean
+  // 接口
+  api?: () => Promise<any>
+} & Omit<FormSchema, 'field'>
+
+type CrudDescriptionsParams = {
+  // 是否显示表单项
+  show?: boolean
+} & Omit<DescriptionsSchema, 'field'>
+
+interface AllSchemas {
+  searchSchema: FormSchema[]
+  tableColumns: TableColumn[]
+  formSchema: FormSchema[]
+  detailSchema: DescriptionsSchema[]
+}
+
+const { t } = useI18n()
+
+// 过滤所有结构
+export const useCrudSchemas = (
+  crudSchema: CrudSchema[]
+): {
+  allSchemas: AllSchemas
+} => {
+  // 所有结构数据
+  const allSchemas = reactive<AllSchemas>({
+    searchSchema: [],
+    tableColumns: [],
+    formSchema: [],
+    detailSchema: []
+  })
+
+  const searchSchema = filterSearchSchema(crudSchema, allSchemas)
+  allSchemas.searchSchema = searchSchema || []
+
+  const tableColumns = filterTableSchema(crudSchema)
+  allSchemas.tableColumns = tableColumns || []
+
+  const formSchema = filterFormSchema(crudSchema, allSchemas)
+  allSchemas.formSchema = formSchema
+
+  const detailSchema = filterDescriptionsSchema(crudSchema)
+  allSchemas.detailSchema = detailSchema
+
+  return {
+    allSchemas
+  }
+}
+
+// 过滤 Search 结构
+const filterSearchSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
+  const searchSchema: FormSchema[] = []
+
+  // 获取字典列表队列
+  const searchRequestTask: Array<() => Promise<void>> = []
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isSearch || schemaItem.search?.show) {
+      let component = schemaItem?.search?.component || 'Input'
+      const options: ComponentOptions[] = []
+      let comonentProps: ComponentProps = {}
+      if (schemaItem.dictType) {
+        const allOptions: ComponentOptions = { label: '全部', value: '' }
+        options.push(allOptions)
+        getDictOptions(schemaItem.dictType).forEach((dict) => {
+          options.push(dict)
+        })
+        comonentProps = {
+          options: options
+        }
+        if (!schemaItem.search?.component) component = 'Select'
+      }
+
+      // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题
+      const searchSchemaItem = merge(
+        {
+          // 默认为 input
+          component,
+          ...schemaItem.search,
+          field: schemaItem.field,
+          label: schemaItem.search?.label || schemaItem.label
+        },
+        { componentProps: comonentProps }
+      )
+      if (searchSchemaItem.api) {
+        searchRequestTask.push(async () => {
+          const res = await (searchSchemaItem.api as () => AxiosPromise)()
+          if (res) {
+            const index = findIndex(allSchemas.searchSchema, (v: FormSchema) => {
+              return v.field === searchSchemaItem.field
+            })
+            if (index !== -1) {
+              allSchemas.searchSchema[index]!.componentProps!.options = filterOptions(
+                res,
+                searchSchemaItem.componentProps.optionsAlias?.labelField
+              )
+            }
+          }
+        })
+      }
+      // 删除不必要的字段
+      delete searchSchemaItem.show
+
+      searchSchema.push(searchSchemaItem)
+    }
+  })
+  for (const task of searchRequestTask) {
+    task()
+  }
+  return searchSchema
+}
+
+// 过滤 table 结构
+const filterTableSchema = (crudSchema: CrudSchema[]): TableColumn[] => {
+  const tableColumns = treeMap<CrudSchema>(crudSchema, {
+    conversion: (schema: CrudSchema) => {
+      if (schema?.isTable !== false && schema?.table?.show !== false) {
+        // add by 芋艿:增加对 dict 字典数据的支持
+        if (!schema.formatter && schema.dictType) {
+          schema.formatter = (_: Recordable, __: TableColumn, cellValue: any) => {
+            return h(DictTag, {
+              type: schema.dictType!, // ! 表示一定不为空
+              value: cellValue
+            })
+          }
+        }
+        return {
+          ...schema.table,
+          ...schema
+        }
+      }
+    }
+  })
+
+  // 第一次过滤会有 undefined 所以需要二次过滤
+  return filter<TableColumn>(tableColumns as TableColumn[], (data) => {
+    if (data.children === void 0) {
+      delete data.children
+    }
+    return !!data.field
+  })
+}
+
+// 过滤 form 结构
+const filterFormSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
+  const formSchema: FormSchema[] = []
+
+  // 获取字典列表队列
+  const formRequestTask: Array<() => Promise<void>> = []
+
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isForm !== false && schemaItem?.form?.show !== false) {
+      let component = schemaItem?.form?.component || 'Input'
+      let defaultValue: any = ''
+      if (schemaItem.form?.value) {
+        defaultValue = schemaItem.form?.value
+      } else {
+        if (component === 'InputNumber') {
+          defaultValue = 0
+        }
+      }
+      let comonentProps: ComponentProps = {}
+      if (schemaItem.dictType) {
+        const options: ComponentOptions[] = []
+        if (schemaItem.dictClass && schemaItem.dictClass === 'number') {
+          getIntDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        } else if (schemaItem.dictClass && schemaItem.dictClass === 'boolean') {
+          getBoolDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        } else {
+          getDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        }
+        comonentProps = {
+          options: options
+        }
+        if (!(schemaItem.form && schemaItem.form.component)) component = 'Select'
+      }
+
+      // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题
+      const formSchemaItem = merge(
+        {
+          // 默认为 input
+          component,
+          value: defaultValue,
+          ...schemaItem.form,
+          field: schemaItem.field,
+          label: schemaItem.form?.label || schemaItem.label
+        },
+        { componentProps: comonentProps }
+      )
+
+      if (formSchemaItem.api) {
+        formRequestTask.push(async () => {
+          const res = await (formSchemaItem.api as () => AxiosPromise)()
+          if (res) {
+            const index = findIndex(allSchemas.formSchema, (v: FormSchema) => {
+              return v.field === formSchemaItem.field
+            })
+            if (index !== -1) {
+              allSchemas.formSchema[index]!.componentProps!.options = filterOptions(
+                res,
+                formSchemaItem.componentProps.optionsAlias?.labelField
+              )
+            }
+          }
+        })
+      }
+
+      // 删除不必要的字段
+      delete formSchemaItem.show
+
+      formSchema.push(formSchemaItem)
+    }
+  })
+
+  for (const task of formRequestTask) {
+    task()
+  }
+  return formSchema
+}
+
+// 过滤 descriptions 结构
+const filterDescriptionsSchema = (crudSchema: CrudSchema[]): DescriptionsSchema[] => {
+  const descriptionsSchema: FormSchema[] = []
+
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isDetail !== false && schemaItem.detail?.show !== false) {
+      const descriptionsSchemaItem = {
+        ...schemaItem.detail,
+        field: schemaItem.field,
+        label: schemaItem.detail?.label || schemaItem.label
+      }
+      if (schemaItem.dictType) {
+        descriptionsSchemaItem.dictType = schemaItem.dictType
+      }
+      if (schemaItem.detail?.dateFormat || schemaItem.formatter == 'formatDate') {
+        // 优先使用 detail 下的配置,如果没有默认为 YYYY-MM-DD HH:mm:ss
+        descriptionsSchemaItem.dateFormat = schemaItem?.detail?.dateFormat
+          ? schemaItem?.detail?.dateFormat
+          : 'YYYY-MM-DD HH:mm:ss'
+      }
+
+      // 删除不必要的字段
+      delete descriptionsSchemaItem.show
+
+      descriptionsSchema.push(descriptionsSchemaItem)
+    }
+  })
+
+  return descriptionsSchema
+}
+
+// 给options添加国际化
+const filterOptions = (options: Recordable, labelField?: string) => {
+  return options?.map((v: Recordable) => {
+    if (labelField) {
+      v['labelField'] = t(v.labelField)
+    } else {
+      v['label'] = t(v.label)
+    }
+    return v
+  })
+}
+
+// 将 tableColumns 指定 fields 放到最前面
+export const sortTableColumns = (tableColumns: TableColumn[], field: string) => {
+  const fieldIndex = tableColumns.findIndex((item) => item.field === field)
+  const fieldColumn = cloneDeep(tableColumns[fieldIndex])
+  tableColumns.splice(fieldIndex, 1)
+  // 添加到开头
+  tableColumns.unshift(fieldColumn)
+}
diff --git a/src/hooks/web/useDesign.ts b/src/hooks/web/useDesign.ts
new file mode 100644
index 0000000..5029680
--- /dev/null
+++ b/src/hooks/web/useDesign.ts
@@ -0,0 +1,18 @@
+import variables from '@/assets/styles/global.module.scss'
+
+export const useDesign = () => {
+  const scssVariables = variables
+
+  /**
+   * @param scope 类名
+   * @returns 返回空间名-类名
+   */
+  const getPrefixCls = (scope: string) => {
+    return `${scssVariables.namespace}-${scope}`
+  }
+
+  return {
+    variables: scssVariables,
+    getPrefixCls
+  }
+}
diff --git a/src/hooks/web/useEmitt.ts b/src/hooks/web/useEmitt.ts
new file mode 100644
index 0000000..d4efea7
--- /dev/null
+++ b/src/hooks/web/useEmitt.ts
@@ -0,0 +1,22 @@
+import mitt from 'mitt'
+
+interface Option {
+  name: string // 事件名称
+  callback: Fn // 回调
+}
+
+const emitter = mitt()
+
+export const useEmitt = (option?: Option) => {
+  if (option) {
+    emitter.on(option.name, option.callback)
+
+    onBeforeUnmount(() => {
+      emitter.off(option.name)
+    })
+  }
+
+  return {
+    emitter
+  }
+}
diff --git a/src/hooks/web/useForm.ts b/src/hooks/web/useForm.ts
new file mode 100644
index 0000000..53a8a94
--- /dev/null
+++ b/src/hooks/web/useForm.ts
@@ -0,0 +1,94 @@
+import type { Form, FormExpose } from '@/components/Form'
+import type { ElForm } from 'element-plus'
+import type { FormProps } from '@/components/Form/src/types'
+import { FormSchema, FormSetPropsType } from '@/types/form'
+
+export const useForm = (props?: FormProps) => {
+  // From实例
+  const formRef = ref<typeof Form & FormExpose>()
+
+  // ElForm实例
+  const elFormRef = ref<ComponentRef<typeof ElForm>>()
+
+  /**
+   * @param ref Form实例
+   * @param elRef ElForm实例
+   */
+  const register = (ref: typeof Form & FormExpose, elRef: ComponentRef<typeof ElForm>) => {
+    formRef.value = ref
+    elFormRef.value = elRef
+  }
+
+  const getForm = async () => {
+    await nextTick()
+    const form = unref(formRef)
+    if (!form) {
+      console.error('The form is not registered. Please use the register method to register')
+    }
+    return form
+  }
+
+  // 一些内置的方法
+  const methods: {
+    setProps: (props: Recordable) => void
+    setValues: (data: Recordable) => void
+    getFormData: <T = Recordable | undefined>() => Promise<T>
+    setSchema: (schemaProps: FormSetPropsType[]) => void
+    addSchema: (formSchema: FormSchema, index?: number) => void
+    delSchema: (field: string) => void
+  } = {
+    setProps: async (props: FormProps = {}) => {
+      const form = await getForm()
+      form?.setProps(props)
+      if (props.model) {
+        form?.setValues(props.model)
+      }
+    },
+
+    setValues: async (data: Recordable) => {
+      const form = await getForm()
+      form?.setValues(data)
+    },
+
+    /**
+     * @param schemaProps 需要设置的schemaProps
+     */
+    setSchema: async (schemaProps: FormSetPropsType[]) => {
+      const form = await getForm()
+      form?.setSchema(schemaProps)
+    },
+
+    /**
+     * @param formSchema 需要新增数据
+     * @param index 在哪里新增
+     */
+    addSchema: async (formSchema: FormSchema, index?: number) => {
+      const form = await getForm()
+      form?.addSchema(formSchema, index)
+    },
+
+    /**
+     * @param field 删除哪个数据
+     */
+    delSchema: async (field: string) => {
+      const form = await getForm()
+      form?.delSchema(field)
+    },
+
+    /**
+     * @returns form data
+     */
+    getFormData: async <T = Recordable>(): Promise<T> => {
+      const form = await getForm()
+      return form?.formModel as T
+    }
+  }
+
+  props && methods.setProps(props)
+
+  return {
+    register,
+    elFormRef,
+    methods
+  }
+}
diff --git a/src/hooks/web/useGuide.ts b/src/hooks/web/useGuide.ts
new file mode 100644
index 0000000..7fd2fb0
--- /dev/null
+++ b/src/hooks/web/useGuide.ts
@@ -0,0 +1,49 @@
+import { Config, driver } from 'driver.js'
+import 'driver.js/dist/driver.css'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useI18n } from '@/hooks/web/useI18n'
+
+const { t } = useI18n()
+
+const { variables } = useDesign()
+
+export const useGuide = (options?: Config) => {
+  const driverObj = driver(
+    options || {
+      showProgress: true,
+      nextBtnText: t('common.nextLabel'),
+      prevBtnText: t('common.prevLabel'),
+      doneBtnText: t('common.doneLabel'),
+      steps: [
+        {
+          element: `#${variables.namespace}-menu`,
+          popover: {
+            title: t('common.menu'),
+            description: t('common.menuDes'),
+            side: 'right'
+          }
+        },
+        {
+          element: `#${variables.namespace}-tool-header`,
+          popover: {
+            title: t('common.tool'),
+            description: t('common.toolDes'),
+            side: 'left'
+          }
+        },
+        {
+          element: `#${variables.namespace}-tags-view`,
+          popover: {
+            title: t('common.tagsView'),
+            description: t('common.tagsViewDes'),
+            side: 'bottom'
+          }
+        }
+      ]
+    }
+  )
+
+  return {
+    ...driverObj
+  }
+}
diff --git a/src/hooks/web/useI18n.ts b/src/hooks/web/useI18n.ts
new file mode 100644
index 0000000..d1ab70f
--- /dev/null
+++ b/src/hooks/web/useI18n.ts
@@ -0,0 +1,53 @@
+import { i18n } from '@/plugins/vueI18n'
+
+type I18nGlobalTranslation = {
+  (key: string): string
+  (key: string, locale: string): string
+  (key: string, locale: string, list: unknown[]): string
+  (key: string, locale: string, named: Record<string, unknown>): string
+  (key: string, list: unknown[]): string
+  (key: string, named: Record<string, unknown>): string
+}
+
+type I18nTranslationRestParameters = [string, any]
+
+const getKey = (namespace: string | undefined, key: string) => {
+  if (!namespace) {
+    return key
+  }
+  if (key.startsWith(namespace)) {
+    return key
+  }
+  return `${namespace}.${key}`
+}
+
+export const useI18n = (
+  namespace?: string
+): {
+  t: I18nGlobalTranslation
+} => {
+  const normalFn = {
+    t: (key: string) => {
+      return getKey(namespace, key)
+    }
+  }
+
+  if (!i18n) {
+    return normalFn
+  }
+
+  const { t, ...methods } = i18n.global
+
+  const tFn: I18nGlobalTranslation = (key: string, ...arg: any[]) => {
+    if (!key) return ''
+    if (!key.includes('.') && !namespace) return key
+    //@ts-ignore
+    return t(getKey(namespace, key), ...(arg as I18nTranslationRestParameters))
+  }
+  return {
+    ...methods,
+    t: tFn
+  }
+}
+
+export const t = (key: string) => key
diff --git a/src/hooks/web/useIcon.ts b/src/hooks/web/useIcon.ts
new file mode 100644
index 0000000..3500204
--- /dev/null
+++ b/src/hooks/web/useIcon.ts
@@ -0,0 +1,8 @@
+import { h } from 'vue'
+import type { VNode } from 'vue'
+import { Icon } from '@/components/Icon'
+import { IconTypes } from '@/types/icon'
+
+export const useIcon = (props: IconTypes): VNode => {
+  return h(Icon, props)
+}
diff --git a/src/hooks/web/useLocale.ts b/src/hooks/web/useLocale.ts
new file mode 100644
index 0000000..c65070e
--- /dev/null
+++ b/src/hooks/web/useLocale.ts
@@ -0,0 +1,35 @@
+import { i18n } from '@/plugins/vueI18n'
+import { useLocaleStoreWithOut } from '@/store/modules/locale'
+import { setHtmlPageLang } from '@/plugins/vueI18n/helper'
+
+const setI18nLanguage = (locale: LocaleType) => {
+  const localeStore = useLocaleStoreWithOut()
+
+  if (i18n.mode === 'legacy') {
+    i18n.global.locale = locale
+  } else {
+    ;(i18n.global.locale as any).value = locale
+  }
+  localeStore.setCurrentLocale({
+    lang: locale
+  })
+  setHtmlPageLang(locale)
+}
+
+export const useLocale = () => {
+  // Switching the language will change the locale of useI18n
+  // And submit to configuration modification
+  const changeLocale = async (locale: LocaleType) => {
+    const globalI18n = i18n.global
+
+    const langModule = await import(`../../locales/${locale}.ts`)
+
+    globalI18n.setLocaleMessage(locale, langModule.default)
+
+    setI18nLanguage(locale)
+  }
+
+  return {
+    changeLocale
+  }
+}
diff --git a/src/hooks/web/useMessage.ts b/src/hooks/web/useMessage.ts
new file mode 100644
index 0000000..ac2b552
--- /dev/null
+++ b/src/hooks/web/useMessage.ts
@@ -0,0 +1,95 @@
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
+import { useI18n } from './useI18n'
+export const useMessage = () => {
+  const { t } = useI18n()
+  return {
+    // 消息提示
+    info(content: string) {
+      ElMessage.info(content)
+    },
+    // 错误消息
+    error(content: string) {
+      ElMessage.error(content)
+    },
+    // 成功消息
+    success(content: string) {
+      ElMessage.success(content)
+    },
+    // 警告消息
+    warning(content: string) {
+      ElMessage.warning(content)
+    },
+    // 弹出提示
+    alert(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'))
+    },
+    // 错误提示
+    alertError(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'error' })
+    },
+    // 成功提示
+    alertSuccess(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'success' })
+    },
+    // 警告提示
+    alertWarning(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'warning' })
+    },
+    // 通知提示
+    notify(content: string) {
+      ElNotification.info(content)
+    },
+    // 错误通知
+    notifyError(content: string) {
+      ElNotification.error(content)
+    },
+    // 成功通知
+    notifySuccess(content: string) {
+      ElNotification.success(content)
+    },
+    // 警告通知
+    notifyWarning(content: string) {
+      ElNotification.warning(content)
+    },
+    // 确认窗体
+    confirm(content: string, tip?: string) {
+      return ElMessageBox.confirm(content, tip ? tip : t('common.confirmTitle'), {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+    },
+    // 删除窗体
+    delConfirm(content?: string, tip?: string) {
+      return ElMessageBox.confirm(
+        content ? content : t('common.delMessage'),
+        tip ? tip : t('common.confirmTitle'),
+        {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }
+      )
+    },
+    // 导出窗体
+    exportConfirm(content?: string, tip?: string) {
+      return ElMessageBox.confirm(
+        content ? content : t('common.exportMessage'),
+        tip ? tip : t('common.confirmTitle'),
+        {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }
+      )
+    },
+    // 提交内容
+    prompt(content: string, tip: string) {
+      return ElMessageBox.prompt(content, tip, {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+    }
+  }
+}
diff --git a/src/hooks/web/useNProgress.ts b/src/hooks/web/useNProgress.ts
new file mode 100644
index 0000000..6d8c0b9
--- /dev/null
+++ b/src/hooks/web/useNProgress.ts
@@ -0,0 +1,33 @@
+import { useCssVar } from '@vueuse/core'
+import type { NProgressOptions } from 'nprogress'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+
+const primaryColor = useCssVar('--el-color-primary', document.documentElement)
+
+export const useNProgress = () => {
+  NProgress.configure({ showSpinner: false } as NProgressOptions)
+
+  const initColor = async () => {
+    await nextTick()
+    const bar = document.getElementById('nprogress')?.getElementsByClassName('bar')[0] as ElRef
+    if (bar) {
+      bar.style.background = unref(primaryColor.value)
+    }
+  }
+
+  initColor()
+
+  const start = () => {
+    NProgress.start()
+  }
+
+  const done = () => {
+    NProgress.done()
+  }
+
+  return {
+    start,
+    done
+  }
+}
diff --git a/src/hooks/web/useNetwork.ts b/src/hooks/web/useNetwork.ts
new file mode 100644
index 0000000..66fa446
--- /dev/null
+++ b/src/hooks/web/useNetwork.ts
@@ -0,0 +1,21 @@
+import { ref, onBeforeUnmount } from 'vue'
+
+const useNetwork = () => {
+  const online = ref(true)
+
+  const updateNetwork = () => {
+    online.value = navigator.onLine
+  }
+
+  window.addEventListener('online', updateNetwork)
+  window.addEventListener('offline', updateNetwork)
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('online', updateNetwork)
+    window.removeEventListener('offline', updateNetwork)
+  })
+
+  return { online }
+}
+
+export { useNetwork }
diff --git a/src/hooks/web/useNow.ts b/src/hooks/web/useNow.ts
new file mode 100644
index 0000000..09d3176
--- /dev/null
+++ b/src/hooks/web/useNow.ts
@@ -0,0 +1,60 @@
+import { dateUtil } from '@/utils/dateUtil'
+import { reactive, toRefs } from 'vue'
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core'
+
+export const useNow = (immediate = true) => {
+  let timer: IntervalHandle
+
+  const state = reactive({
+    year: 0,
+    month: 0,
+    week: '',
+    day: 0,
+    hour: '',
+    minute: '',
+    second: 0,
+    meridiem: ''
+  })
+
+  const update = () => {
+    const now = dateUtil()
+
+    const h = now.format('HH')
+    const m = now.format('mm')
+    const s = now.get('s')
+
+    state.year = now.get('y')
+    state.month = now.get('M') + 1
+    state.week = '星期' + ['日', '一', '二', '三', '四', '五', '六'][now.day()]
+    state.day = now.get('date')
+    state.hour = h
+    state.minute = m
+    state.second = s
+
+    state.meridiem = now.format('A')
+  }
+
+  function start() {
+    update()
+    clearInterval(timer)
+    timer = setInterval(() => update(), 1000)
+  }
+
+  function stop() {
+    clearInterval(timer)
+  }
+
+  tryOnMounted(() => {
+    immediate && start()
+  })
+
+  tryOnUnmounted(() => {
+    stop()
+  })
+
+  return {
+    ...toRefs(state),
+    start,
+    stop
+  }
+}
diff --git a/src/hooks/web/usePageLoading.ts b/src/hooks/web/usePageLoading.ts
new file mode 100644
index 0000000..bb89457
--- /dev/null
+++ b/src/hooks/web/usePageLoading.ts
@@ -0,0 +1,18 @@
+import { useAppStoreWithOut } from '@/store/modules/app'
+
+const appStore = useAppStoreWithOut()
+
+export const usePageLoading = () => {
+  const loadStart = () => {
+    appStore.setPageLoading(true)
+  }
+
+  const loadDone = () => {
+    appStore.setPageLoading(false)
+  }
+
+  return {
+    loadStart,
+    loadDone
+  }
+}
diff --git a/src/hooks/web/useTable.ts b/src/hooks/web/useTable.ts
new file mode 100644
index 0000000..361dd67
--- /dev/null
+++ b/src/hooks/web/useTable.ts
@@ -0,0 +1,223 @@
+import download from '@/utils/download'
+import { Table, TableExpose } from '@/components/Table'
+import { ElMessage, ElMessageBox, ElTable } from 'element-plus'
+import { computed, nextTick, reactive, ref, unref, watch } from 'vue'
+import type { TableProps } from '@/components/Table/src/types'
+
+import { TableSetPropsType } from '@/types/table'
+
+const { t } = useI18n()
+interface ResponseType<T = any> {
+  list: T[]
+  total?: number
+}
+
+interface UseTableConfig<T = any> {
+  getListApi: (option: any) => Promise<T>
+  delListApi?: (option: any) => Promise<T>
+  exportListApi?: (option: any) => Promise<T>
+  // 返回数据格式配置
+  response?: ResponseType
+  // 默认传递的参数
+  defaultParams?: Recordable
+  props?: TableProps
+}
+
+interface TableObject<T = any> {
+  pageSize: number
+  currentPage: number
+  total: number
+  tableList: T[]
+  params: any
+  loading: boolean
+  exportLoading: boolean
+  currentRow: Nullable<T>
+}
+
+export const useTable = <T = any>(config?: UseTableConfig<T>) => {
+  const tableObject = reactive<TableObject<T>>({
+    // 页数
+    pageSize: 10,
+    // 当前页
+    currentPage: 1,
+    // 总条数
+    total: 10,
+    // 表格数据
+    tableList: [],
+    // AxiosConfig 配置
+    params: {
+      ...(config?.defaultParams || {})
+    },
+    // 加载中
+    loading: true,
+    // 导出加载中
+    exportLoading: false,
+    // 当前行的数据
+    currentRow: null
+  })
+
+  const paramsObj = computed(() => {
+    return {
+      ...tableObject.params,
+      pageSize: tableObject.pageSize,
+      pageNo: tableObject.currentPage
+    }
+  })
+
+  watch(
+    () => tableObject.currentPage,
+    () => {
+      methods.getList()
+    }
+  )
+
+  watch(
+    () => tableObject.pageSize,
+    () => {
+      // 当前页不为1时,修改页数后会导致多次调用getList方法
+      if (tableObject.currentPage === 1) {
+        methods.getList()
+      } else {
+        tableObject.currentPage = 1
+        methods.getList()
+      }
+    }
+  )
+
+  // Table实例
+  const tableRef = ref<typeof Table & TableExpose>()
+
+  // ElTable实例
+  const elTableRef = ref<ComponentRef<typeof ElTable>>()
+
+  const register = (ref: typeof Table & TableExpose, elRef: ComponentRef<typeof ElTable>) => {
+    tableRef.value = ref
+    elTableRef.value = elRef
+  }
+
+  const getTable = async () => {
+    await nextTick()
+    const table = unref(tableRef)
+    if (!table) {
+      console.error('The table is not registered. Please use the register method to register')
+    }
+    return table
+  }
+
+  const delData = async (ids: string | number | string[] | number[]) => {
+    let idsLength = 1
+    if (ids instanceof Array) {
+      idsLength = ids.length
+      await Promise.all(
+        ids.map(async (id: string | number) => {
+          await (config?.delListApi && config?.delListApi(id))
+        })
+      )
+    } else {
+      await (config?.delListApi && config?.delListApi(ids))
+    }
+    ElMessage.success(t('common.delSuccess'))
+
+    // 计算出临界点
+    tableObject.currentPage =
+      tableObject.total % tableObject.pageSize === idsLength || tableObject.pageSize === 1
+        ? tableObject.currentPage > 1
+          ? tableObject.currentPage - 1
+          : tableObject.currentPage
+        : tableObject.currentPage
+    await methods.getList()
+  }
+
+  const methods = {
+    getList: async () => {
+      tableObject.loading = true
+      const res = await config?.getListApi(unref(paramsObj)).finally(() => {
+        tableObject.loading = false
+      })
+      if (res) {
+        tableObject.tableList = (res as unknown as ResponseType).list
+        tableObject.total = (res as unknown as ResponseType).total ?? 0
+      }
+    },
+    setProps: async (props: TableProps = {}) => {
+      const table = await getTable()
+      table?.setProps(props)
+    },
+    setColumn: async (columnProps: TableSetPropsType[]) => {
+      const table = await getTable()
+      table?.setColumn(columnProps)
+    },
+    getSelections: async () => {
+      const table = await getTable()
+      return (table?.selections || []) as T[]
+    },
+    // 与Search组件结合
+    setSearchParams: (data: Recordable) => {
+      tableObject.params = Object.assign(tableObject.params, {
+        pageSize: tableObject.pageSize,
+        pageNo: 1,
+        ...data
+      })
+      // 页码不等于1时更新页码重新获取数据,页码等于1时重新获取数据
+      if (tableObject.currentPage !== 1) {
+        tableObject.currentPage = 1
+      } else {
+        methods.getList()
+      }
+    },
+    // 删除数据
+    delList: async (
+      ids: string | number | string[] | number[],
+      multiple: boolean,
+      message = true
+    ) => {
+      const tableRef = await getTable()
+      if (multiple) {
+        if (!tableRef?.selections.length) {
+          ElMessage.warning(t('common.delNoData'))
+          return
+        }
+      }
+      if (message) {
+        ElMessageBox.confirm(t('common.delMessage'), t('common.confirmTitle'), {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }).then(async () => {
+          await delData(ids)
+        })
+      } else {
+        await delData(ids)
+      }
+    },
+    // 导出列表
+    exportList: async (fileName: string) => {
+      tableObject.exportLoading = true
+      ElMessageBox.confirm(t('common.exportMessage'), t('common.confirmTitle'), {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+        .then(async () => {
+          const res = await config?.exportListApi?.(unref(paramsObj) as unknown as T)
+          if (res) {
+            download.excel(res as unknown as Blob, fileName)
+          }
+        })
+        .finally(() => {
+          tableObject.exportLoading = false
+        })
+    }
+  }
+
+  config?.props && methods.setProps(config.props)
+
+  return {
+    register,
+    elTableRef,
+    tableObject,
+    methods,
+    // add by 芋艿:返回 tableMethods 属性,和 tableObject 更统一
+    tableMethods: methods
+  }
+}
diff --git a/src/hooks/web/useTagsView.ts b/src/hooks/web/useTagsView.ts
new file mode 100644
index 0000000..31eadb0
--- /dev/null
+++ b/src/hooks/web/useTagsView.ts
@@ -0,0 +1,63 @@
+import { useTagsViewStoreWithOut } from '@/store/modules/tagsView'
+import { RouteLocationNormalizedLoaded, useRouter } from 'vue-router'
+import { computed, nextTick, unref } from 'vue'
+
+export const useTagsView = () => {
+  const tagsViewStore = useTagsViewStoreWithOut()
+
+  const { replace, currentRoute } = useRouter()
+
+  const selectedTag = computed(() => tagsViewStore.getSelectedTag)
+
+  const closeAll = (callback?: Fn) => {
+    tagsViewStore.delAllViews()
+    callback?.()
+  }
+
+  const closeLeft = (callback?: Fn) => {
+    tagsViewStore.delLeftViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeRight = (callback?: Fn) => {
+    tagsViewStore.delRightViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeOther = (callback?: Fn) => {
+    tagsViewStore.delOthersViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeCurrent = (view?: RouteLocationNormalizedLoaded, callback?: Fn) => {
+    if (view?.meta?.affix) return
+    tagsViewStore.delView(view || unref(currentRoute))
+
+    callback?.()
+  }
+
+  const refreshPage = async (view?: RouteLocationNormalizedLoaded, callback?: Fn) => {
+    tagsViewStore.delCachedView()
+    const { path, query } = view || unref(currentRoute)
+    await nextTick()
+    replace({
+      path: '/redirect' + path,
+      query: query
+    })
+    callback?.()
+  }
+
+  const setTitle = (title: string, path?: string) => {
+    tagsViewStore.setTitle(title, path)
+  }
+
+  return {
+    closeAll,
+    closeLeft,
+    closeRight,
+    closeOther,
+    closeCurrent,
+    refreshPage,
+    setTitle
+  }
+}
diff --git a/src/hooks/web/useTimeAgo.ts b/src/hooks/web/useTimeAgo.ts
new file mode 100644
index 0000000..a6da281
--- /dev/null
+++ b/src/hooks/web/useTimeAgo.ts
@@ -0,0 +1,49 @@
+import { useTimeAgo as useTimeAgoCore, UseTimeAgoMessages } from '@vueuse/core'
+import { useLocaleStoreWithOut } from '@/store/modules/locale'
+
+const TIME_AGO_MESSAGE_MAP: {
+  'zh-CN': UseTimeAgoMessages
+  en: UseTimeAgoMessages
+} = {
+  // @ts-ignore
+  'zh-CN': {
+    justNow: '刚刚',
+    past: (n) => (n.match(/\d/) ? `${n}前` : n),
+    future: (n) => (n.match(/\d/) ? `${n}后` : n),
+    month: (n, past) => (n === 1 ? (past ? '上个月' : '下个月') : `${n} 个月`),
+    year: (n, past) => (n === 1 ? (past ? '去年' : '明年') : `${n} 年`),
+    day: (n, past) => (n === 1 ? (past ? '昨天' : '明天') : `${n} 天`),
+    week: (n, past) => (n === 1 ? (past ? '上周' : '下周') : `${n} 周`),
+    hour: (n) => `${n} 小时`,
+    minute: (n) => `${n} 分钟`,
+    second: (n) => `${n} 秒`
+  },
+  // @ts-ignore
+  en: {
+    justNow: 'just now',
+    past: (n) => (n.match(/\d/) ? `${n} ago` : n),
+    future: (n) => (n.match(/\d/) ? `in ${n}` : n),
+    month: (n, past) =>
+      n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`,
+    year: (n, past) =>
+      n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`,
+    day: (n, past) => (n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`),
+    week: (n, past) =>
+      n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`,
+    hour: (n) => `${n} hour${n > 1 ? 's' : ''}`,
+    minute: (n) => `${n} minute${n > 1 ? 's' : ''}`,
+    second: (n) => `${n} second${n > 1 ? 's' : ''}`
+  }
+}
+
+export const useTimeAgo = (time: Date | number | string) => {
+  const localeStore = useLocaleStoreWithOut()
+
+  const currentLocale = computed(() => localeStore.getCurrentLocale)
+
+  const timeAgo = useTimeAgoCore(time, {
+    messages: TIME_AGO_MESSAGE_MAP[unref(currentLocale).lang]
+  })
+
+  return timeAgo
+}
diff --git a/src/hooks/web/useTitle.ts b/src/hooks/web/useTitle.ts
new file mode 100644
index 0000000..020a9b7
--- /dev/null
+++ b/src/hooks/web/useTitle.ts
@@ -0,0 +1,24 @@
+import { watch, ref } from 'vue'
+import { isString } from '@/utils/is'
+import { useAppStoreWithOut } from '@/store/modules/app'
+
+const appStore = useAppStoreWithOut()
+
+export const useTitle = (newTitle?: string) => {
+  const { t } = useI18n()
+  const title = ref(
+    newTitle ? `${appStore.getTitle} - ${t(newTitle as string)}` : appStore.getTitle
+  )
+
+  watch(
+    title,
+    (n, o) => {
+      if (isString(n) && n !== o && document) {
+        document.title = n
+      }
+    },
+    { immediate: true }
+  )
+
+  return title
+}
diff --git a/src/hooks/web/useValidator.ts b/src/hooks/web/useValidator.ts
new file mode 100644
index 0000000..151e35b
--- /dev/null
+++ b/src/hooks/web/useValidator.ts
@@ -0,0 +1,60 @@
+import { useI18n } from '@/hooks/web/useI18n'
+import { FormItemRule } from 'element-plus'
+
+const { t } = useI18n()
+
+interface LengthRange {
+  min: number
+  max: number
+  message?: string
+}
+
+export const useValidator = () => {
+  const required = (message?: string): FormItemRule => {
+    return {
+      required: true,
+      message: message || t('common.required')
+    }
+  }
+
+  const lengthRange = (options: LengthRange): FormItemRule => {
+    const { min, max, message } = options
+
+    return {
+      min,
+      max,
+      message: message || t('common.lengthRange', { min, max })
+    }
+  }
+
+  const notSpace = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (val?.indexOf(' ') !== -1) {
+          callback(new Error(message || t('common.notSpace')))
+        } else {
+          callback()
+        }
+      }
+    }
+  }
+
+  const notSpecialCharacters = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (/[`~!@#$%^&*()_+<>?:"{},.\/;'[\]]/gi.test(val)) {
+          callback(new Error(message || t('common.notSpecialCharacters')))
+        } else {
+          callback()
+        }
+      }
+    }
+  }
+
+  return {
+    required,
+    lengthRange,
+    notSpace,
+    notSpecialCharacters
+  }
+}
diff --git a/src/hooks/web/useWatermark.ts b/src/hooks/web/useWatermark.ts
new file mode 100644
index 0000000..4a31359
--- /dev/null
+++ b/src/hooks/web/useWatermark.ts
@@ -0,0 +1,55 @@
+const domSymbol = Symbol('watermark-dom')
+
+export function useWatermark(appendEl: HTMLElement | null = document.body) {
+  let func: Fn = () => {}
+  const id = domSymbol.toString()
+  const clear = () => {
+    const domId = document.getElementById(id)
+    if (domId) {
+      const el = appendEl
+      el && el.removeChild(domId)
+    }
+    window.removeEventListener('resize', func)
+  }
+  const createWatermark = (str: string) => {
+    clear()
+
+    const can = document.createElement('canvas')
+    can.width = 300
+    can.height = 240
+
+    const cans = can.getContext('2d')
+    if (cans) {
+      cans.rotate((-20 * Math.PI) / 120)
+      cans.font = '15px Vedana'
+      cans.fillStyle = 'rgba(0, 0, 0, 0.15)'
+      cans.textAlign = 'left'
+      cans.textBaseline = 'middle'
+      cans.fillText(str, can.width / 20, can.height)
+    }
+
+    const div = document.createElement('div')
+    div.id = id
+    div.style.pointerEvents = 'none'
+    div.style.top = '0px'
+    div.style.left = '0px'
+    div.style.position = 'absolute'
+    div.style.zIndex = '100000000'
+    div.style.width = document.documentElement.clientWidth + 'px'
+    div.style.height = document.documentElement.clientHeight + 'px'
+    div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat'
+    const el = appendEl
+    el && el.appendChild(div)
+    return id
+  }
+
+  function setWatermark(str: string) {
+    createWatermark(str)
+    func = () => {
+      createWatermark(str)
+    }
+    window.addEventListener('resize', func)
+  }
+
+  return { setWatermark, clear }
+}
diff --git a/src/main.ts b/src/main.ts
index 0ad939a..c0297f2 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -21,6 +21,7 @@
 
 // svg图标
 import 'virtual:svg-icons-register';
+import '@purge-icons/generated'
 import ElementIcons from '@/plugins/svgicon';
 
 // permission control
diff --git a/src/utils/is.ts b/src/utils/is.ts
new file mode 100644
index 0000000..eec86a9
--- /dev/null
+++ b/src/utils/is.ts
@@ -0,0 +1,117 @@
+// copy to vben-admin
+
+const toString = Object.prototype.toString
+
+export const is = (val: unknown, type: string) => {
+  return toString.call(val) === `[object ${type}]`
+}
+
+export const isDef = <T = unknown>(val?: T): val is T => {
+  return typeof val !== 'undefined'
+}
+
+export const isUnDef = <T = unknown>(val?: T): val is T => {
+  return !isDef(val)
+}
+
+export const isObject = (val: any): val is Record<any, any> => {
+  return val !== null && is(val, 'Object')
+}
+
+export const isEmpty = <T = unknown>(val: T): val is T => {
+  if (val === null) {
+    return true
+  }
+  if (isArray(val) || isString(val)) {
+    return val.length === 0
+  }
+
+  if (val instanceof Map || val instanceof Set) {
+    return val.size === 0
+  }
+
+  if (isObject(val)) {
+    return Object.keys(val).length === 0
+  }
+
+  return false
+}
+
+export const isDate = (val: unknown): val is Date => {
+  return is(val, 'Date')
+}
+
+export const isNull = (val: unknown): val is null => {
+  return val === null
+}
+
+export const isNullAndUnDef = (val: unknown): val is null | undefined => {
+  return isUnDef(val) && isNull(val)
+}
+
+export const isNullOrUnDef = (val: unknown): val is null | undefined => {
+  return isUnDef(val) || isNull(val)
+}
+
+export const isNumber = (val: unknown): val is number => {
+  return is(val, 'Number')
+}
+
+export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
+  return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
+}
+
+export const isString = (val: unknown): val is string => {
+  return is(val, 'String')
+}
+
+export const isFunction = (val: unknown): val is Function => {
+  return typeof val === 'function'
+}
+
+export const isBoolean = (val: unknown): val is boolean => {
+  return is(val, 'Boolean')
+}
+
+export const isRegExp = (val: unknown): val is RegExp => {
+  return is(val, 'RegExp')
+}
+
+export const isArray = (val: any): val is Array<any> => {
+  return val && Array.isArray(val)
+}
+
+export const isWindow = (val: any): val is Window => {
+  return typeof window !== 'undefined' && is(val, 'Window')
+}
+
+export const isElement = (val: unknown): val is Element => {
+  return isObject(val) && !!val.tagName
+}
+
+export const isMap = (val: unknown): val is Map<any, any> => {
+  return is(val, 'Map')
+}
+
+export const isServer = typeof window === 'undefined'
+
+export const isClient = !isServer
+
+export const isUrl = (path: string): boolean => {
+  const reg =
+    /(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/
+  return reg.test(path)
+}
+
+export const isDark = (): boolean => {
+  return window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+// 是否是图片链接
+export const isImgPath = (path: string): boolean => {
+  return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path)
+}
+
+export const isEmptyVal = (val: any): boolean => {
+  return val === '' || val === null || val === undefined
+}
diff --git a/src/views/basis/sysParam/config/DetailForm.vue b/src/views/basis/sysParam/config/DetailForm.vue
index f1346c6..f10580d 100644
--- a/src/views/basis/sysParam/config/DetailForm.vue
+++ b/src/views/basis/sysParam/config/DetailForm.vue
@@ -1,6 +1,6 @@
 <template>
   <div class="p-2">
-    <el-dialog :title="dialog.title" v-model="dialog.visible" width="500px">
+    <Dialog :title="dialog.title" v-model="dialog.visible" width="600px" :draggable="draggable">
       <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px" v-loading="formLoading">
         <el-form-item label="参数名称" prop="configName">
           <el-input v-model="formData.configName" placeholder="请输入参数名称"/>
@@ -20,13 +20,11 @@
           <el-input v-model="formData.remark" type="textarea" placeholder="请输入内容"/>
         </el-form-item>
       </el-form>
-      <div class="dialog-footer">
-        <slot name="footer">
-          <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
-          <el-button @click="dialog.visible = false">取 消</el-button>
-        </slot>
-      </div>
-    </el-dialog>
+      <template #footer>
+        <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="dialog.visible = false">取 消</el-button>
+      </template>
+    </Dialog>
   </div>
 </template>
 
@@ -51,6 +49,9 @@
   visible: false,
   title: ''
 });
+const resizable = ref(true)
+const draggable = ref(true)
+const isFullscreen = ref(false)
 // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formLoading = ref(false)
 // 表单的类型:create - 新增;update - 修改
diff --git a/src/views/basis/sysParam/config/index.vue b/src/views/basis/sysParam/config/index.vue
index 193550f..eab1c24 100644
--- a/src/views/basis/sysParam/config/index.vue
+++ b/src/views/basis/sysParam/config/index.vue
@@ -86,7 +86,7 @@
       <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
     </el-card>
     <!--    弹出的详细页面,增加和修改-->
-    <detail-form ref="detailFormRef" @success="getList"/>
+    <detail-form ref="detailFormRef" @success="getList" apped-to-body/>
   </div>
 </template>
 

--
Gitblit v1.8.0