chenjun 1 год назад
Родитель
Сommit
d38f964feb
100 измененных файлов с 7001 добавлено и 0 удалено
  1. 0 0
      client/403.svg
  2. 8 0
      client/403.vue
  3. 0 0
      client/404.svg
  4. 7 0
      client/404.vue
  5. 0 0
      client/500.svg
  6. 7 0
      client/500.vue
  7. 57 0
      client/App.vue
  8. 61 0
      client/AppView.vue
  9. 87 0
      client/Index.vue
  10. 319 0
      client/Index2.vue
  11. 21 0
      client/LICENSE
  12. 78 0
      client/Layout.vue
  13. 100 0
      client/Logger.ts
  14. 104 0
      client/Login.vue
  15. 235 0
      client/README.md
  16. 28 0
      client/Redirect.vue
  17. 90 0
      client/ToolHeader.vue
  18. 274 0
      client/app.ts
  19. 92 0
      client/auth.ts
  20. BIN
      client/avatar.gif
  21. BIN
      client/avatar.jpg
  22. 61 0
      client/brand.ts
  23. 60 0
      client/category.ts
  24. 153 0
      client/color.ts
  25. 49 0
      client/comment.ts
  26. 8 0
      client/components.d.ts
  27. 28 0
      client/config.ts
  28. 4 0
      client/configGlobal.d.ts
  29. 360 0
      client/constants.ts
  30. 7 0
      client/contextMenu.d.ts
  31. 27 0
      client/custom-types.d.ts
  32. 13 0
      client/descriptions.d.ts
  33. 184 0
      client/dict.ts
  34. 289 0
      client/domUtils.ts
  35. 38 0
      client/download.ts
  36. 308 0
      client/echarts-data.ts
  37. 3 0
      client/elementPlus.d.ts
  38. 447 0
      client/en.ts
  39. 32 0
      client/env.d.ts
  40. 6 0
      client/errorCode.ts
  41. 19 0
      client/extensions.json
  42. BIN
      client/favicon.ico
  43. 157 0
      client/filt.ts
  44. 44 0
      client/form.d.ts
  45. 54 0
      client/formCreate.ts
  46. 7 0
      client/formRules.ts
  47. 223 0
      client/formatTime.ts
  48. 12 0
      client/formatter.ts
  49. 50 0
      client/global.d.ts
  50. 6 0
      client/global.module.scss
  51. 27 0
      client/hasPermi.ts
  52. 27 0
      client/hasRole.ts
  53. 3 0
      client/helper.ts
  54. BIN
      client/home.png
  55. 5 0
      client/icon.d.ts
  56. 1 0
      client/icon.svg
  57. 151 0
      client/index.html
  58. 35 0
      client/index.scss
  59. 33 0
      client/index.ts
  60. 4 0
      client/infoTip.d.ts
  61. 105 0
      client/is.ts
  62. 31 0
      client/jsencrypt.ts
  63. 16 0
      client/launch.json
  64. 1 0
      client/layout.d.ts
  65. 59 0
      client/locale.ts
  66. 10 0
      client/localeDropdown.d.ts
  67. 0 0
      client/login-bg.svg
  68. 0 0
      client/login-box-bg.svg
  69. BIN
      client/logo.gif
  70. BIN
      client/logo.png
  71. 72 0
      client/main.ts
  72. 1 0
      client/member_balance.svg
  73. 0 0
      client/member_expenditure_balance.svg
  74. 1 0
      client/member_level.svg
  75. 0 0
      client/member_point.svg
  76. 0 0
      client/member_recharge_balance.svg
  77. 1 0
      client/message.svg
  78. 1 0
      client/money.svg
  79. 112 0
      client/optimize.ts
  80. 146 0
      client/package.json
  81. 1 0
      client/peoples.svg
  82. 70 0
      client/permission.ts
  83. 5 0
      client/postcss.config.js
  84. 22 0
      client/prettier.config.js
  85. BIN
      client/profile.jpg
  86. 28 0
      client/propTypes.ts
  87. 103 0
      client/property.ts
  88. 9 0
      client/qrcode.d.ts
  89. 455 0
      client/remaining.ts
  90. 81 0
      client/router.d.ts
  91. 238 0
      client/routerHelper.ts
  92. 239 0
      client/service.ts
  93. 144 0
      client/settings.json
  94. 0 0
      client/shopping.svg
  95. 108 0
      client/spu.ts
  96. 233 0
      client/stylelint.config.js
  97. 44 0
      client/table.d.ts
  98. 140 0
      client/tagsView.ts
  99. 16 0
      client/theme.d.ts
  100. 6 0
      client/theme.scss

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/403.svg


+ 8 - 0
client/403.vue

@@ -0,0 +1,8 @@
+<template>
+  <Error type="403" @error-click="push('/')" />
+</template>
+<script lang="ts" setup>
+defineOptions({ name: 'Error403' })
+
+const { push } = useRouter()
+</script>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/404.svg


+ 7 - 0
client/404.vue

@@ -0,0 +1,7 @@
+<template>
+  <Error @error-click="push('/')" />
+</template>
+<script lang="ts" setup>
+defineOptions({ name: 'Error404' })
+const { push } = useRouter()
+</script>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/500.svg


+ 7 - 0
client/500.vue

@@ -0,0 +1,7 @@
+<template>
+  <Error type="500" @error-click="push('/')" />
+</template>
+<script lang="ts" setup>
+defineOptions({ name: 'Error500' })
+const { push } = useRouter()
+</script>

+ 57 - 0
client/App.vue

@@ -0,0 +1,57 @@
+<script lang="ts" setup>
+import { isDark } from '@/utils/is'
+import { useAppStore } from '@/store/modules/app'
+import { useDesign } from '@/hooks/web/useDesign'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import routerSearch from '@/components/RouterSearch/index.vue'
+
+defineOptions({ name: 'APP' })
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('app')
+const appStore = useAppStore()
+const currentSize = computed(() => appStore.getCurrentSize)
+const greyMode = computed(() => appStore.getGreyMode)
+const { wsCache } = useCache()
+
+// 根据浏览器当前主题设置系统主题色
+const setDefaultTheme = () => {
+  let isDarkTheme = wsCache.get(CACHE_KEY.IS_DARK)
+  if (isDarkTheme === null) {
+    isDarkTheme = isDark()
+  }
+  appStore.setIsDark(isDarkTheme)
+}
+setDefaultTheme()
+</script>
+<template>
+  <ConfigGlobal :size="currentSize">
+    <RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
+    <routerSearch />
+  </ConfigGlobal>
+</template>
+<style lang="scss">
+$prefix-cls: #{$namespace}-app;
+
+.size {
+  width: 100%;
+  height: 100%;
+}
+
+html,
+body {
+  @extend .size;
+
+  padding: 0 !important;
+  margin: 0;
+  overflow: hidden;
+
+  #app {
+    @extend .size;
+  }
+}
+
+.#{$prefix-cls}-grey-mode {
+  filter: grayscale(100%);
+}
+</style>

+ 61 - 0
client/AppView.vue

@@ -0,0 +1,61 @@
+<script lang="ts" setup>
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useAppStore } from '@/store/modules/app'
+import { Footer } from '@/layout/components/Footer'
+
+defineOptions({ name: 'AppView' })
+
+const appStore = useAppStore()
+
+const layout = computed(() => appStore.getLayout)
+
+const fixedHeader = computed(() => appStore.getFixedHeader)
+
+const footer = computed(() => appStore.getFooter)
+
+const tagsViewStore = useTagsViewStore()
+
+const getCaches = computed((): string[] => {
+  return tagsViewStore.getCachedViews
+})
+
+const tagsView = computed(() => appStore.getTagsView)
+</script>
+
+<template>
+  <section
+    :class="[
+      'p-[var(--app-content-padding)] w-[calc(100%-var(--app-content-padding)-var(--app-content-padding))] bg-[var(--app-content-bg-color)] dark:bg-[var(--el-bg-color)]',
+      {
+        '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
+          (fixedHeader &&
+            (layout === 'classic' || layout === 'topLeft' || layout === 'top') &&
+            footer) ||
+          (!tagsView && layout === 'top' && footer),
+        '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height)-var(--tags-view-height))]':
+          tagsView && layout === 'top' && footer,
+
+        '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--top-tool-height)-var(--app-footer-height))]':
+          !fixedHeader && layout === 'classic' && footer,
+
+        '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
+          !fixedHeader && layout === 'topLeft' && footer,
+
+        '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding))]':
+          fixedHeader && layout === 'cutMenu' && footer,
+
+        '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding)-var(--tags-view-height))]':
+          !fixedHeader && layout === 'cutMenu' && footer
+      }
+    ]"
+  >
+    <router-view>
+      <template #default="{ Component, route }">
+        <keep-alive :include="getCaches">
+          <component :is="Component" :key="route.fullPath" />
+        </keep-alive>
+      </template>
+    </router-view>
+  </section>
+  <Footer v-if="footer" />
+</template>

+ 87 - 0
client/Index.vue

@@ -0,0 +1,87 @@
+<!-- 基于 ruoyi-vue3 的 Pagination 重构,核心是简化无用的属性,并使用 ts 重写 -->
+<template>
+  <el-pagination
+    v-show="total > 0"
+    v-model:current-page="currentPage"
+    v-model:page-size="pageSize"
+    :background="true"
+    :page-sizes="[10, 20, 30, 50, 100]"
+    :pager-count="pagerCount"
+    :total="total"
+    :small="isSmall"
+    class="float-right mb-15px mt-15px"
+    layout="total, sizes, prev, pager, next, jumper"
+    @size-change="handleSizeChange"
+    @current-change="handleCurrentChange"
+  />
+</template>
+<script lang="ts" setup>
+import { computed, watchEffect } from 'vue'
+import { useAppStore } from '@/store/modules/app'
+
+defineOptions({ name: 'Pagination' })
+
+// 此处解决了当全局size为small的时候分页组件样式太大的问题
+const appStore = useAppStore()
+const layoutCurrentSize = computed(() => appStore.currentSize)
+const isSmall = ref<boolean>(layoutCurrentSize.value === 'small')
+watchEffect(() => {
+  isSmall.value = layoutCurrentSize.value === 'small'
+})
+
+const props = defineProps({
+  // 总条目数
+  total: {
+    required: true,
+    type: Number
+  },
+  // 当前页数:pageNo
+  page: {
+    type: Number,
+    default: 1
+  },
+  // 每页显示条目个数:pageSize
+  limit: {
+    type: Number,
+    default: 20
+  },
+  // 设置最大页码按钮数。 页码按钮的数量,当总页数超过该值时会折叠
+  // 移动端页码按钮的数量端默认值 5
+  pagerCount: {
+    type: Number,
+    default: document.body.clientWidth < 992 ? 5 : 7
+  }
+})
+
+const emit = defineEmits(['update:page', 'update:limit', 'pagination', 'pagination'])
+const currentPage = computed({
+  get() {
+    return props.page
+  },
+  set(val) {
+    // 触发 update:page 事件,更新 limit 属性,从而更新 pageNo
+    emit('update:page', val)
+  }
+})
+const pageSize = computed({
+  get() {
+    return props.limit
+  },
+  set(val) {
+    // 触发 update:limit 事件,更新 limit 属性,从而更新 pageSize
+    emit('update:limit', val)
+  }
+})
+const handleSizeChange = (val) => {
+  // 如果修改后超过最大页面,强制跳转到第 1 页
+  if (currentPage.value * val > props.total) {
+    currentPage.value = 1
+  }
+  // 触发 pagination 事件,重新加载列表
+  emit('pagination', { page: currentPage.value, limit: val })
+}
+const handleCurrentChange = (val) => {
+  // 触发 pagination 事件,重新加载列表
+  emit('pagination', { page: val, limit: pageSize.value })
+}
+</script>

+ 319 - 0
client/Index2.vue

@@ -0,0 +1,319 @@
+<template>
+  <el-row :class="prefixCls" :gutter="20" justify="space-between">
+    <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" :rows="2" animated>
+          <template #default>
+            <div :class="`${prefixCls}__item flex justify-between`">
+              <div>
+                <div
+                  :class="`${prefixCls}__item--icon ${prefixCls}__item--peoples p-16px inline-block rounded-6px`"
+                >
+                  <Icon :size="40" icon="svg-icon:peoples" />
+                </div>
+              </div>
+              <div class="flex flex-col justify-between">
+                <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`"
+                  >{{ t('analysis.newUser') }}
+                </div>
+                <CountTo
+                  :duration="2600"
+                  :end-val="102400"
+                  :start-val="0"
+                  class="text-right text-20px font-700"
+                />
+              </div>
+            </div>
+          </template>
+        </el-skeleton>
+      </el-card>
+    </el-col>
+
+    <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" :rows="2" animated>
+          <template #default>
+            <div :class="`${prefixCls}__item flex justify-between`">
+              <div>
+                <div
+                  :class="`${prefixCls}__item--icon ${prefixCls}__item--message p-16px inline-block rounded-6px`"
+                >
+                  <Icon :size="40" icon="svg-icon:message" />
+                </div>
+              </div>
+              <div class="flex flex-col justify-between">
+                <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`"
+                  >{{ t('analysis.unreadInformation') }}
+                </div>
+                <CountTo
+                  :duration="2600"
+                  :end-val="81212"
+                  :start-val="0"
+                  class="text-right text-20px font-700"
+                />
+              </div>
+            </div>
+          </template>
+        </el-skeleton>
+      </el-card>
+    </el-col>
+
+    <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" :rows="2" animated>
+          <template #default>
+            <div :class="`${prefixCls}__item flex justify-between`">
+              <div>
+                <div
+                  :class="`${prefixCls}__item--icon ${prefixCls}__item--money p-16px inline-block rounded-6px`"
+                >
+                  <Icon :size="40" icon="svg-icon:money" />
+                </div>
+              </div>
+              <div class="flex flex-col justify-between">
+                <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`"
+                  >{{ t('analysis.transactionAmount') }}
+                </div>
+                <CountTo
+                  :duration="2600"
+                  :end-val="9280"
+                  :start-val="0"
+                  class="text-right text-20px font-700"
+                />
+              </div>
+            </div>
+          </template>
+        </el-skeleton>
+      </el-card>
+    </el-col>
+
+    <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" :rows="2" animated>
+          <template #default>
+            <div :class="`${prefixCls}__item flex justify-between`">
+              <div>
+                <div
+                  :class="`${prefixCls}__item--icon ${prefixCls}__item--shopping p-16px inline-block rounded-6px`"
+                >
+                  <Icon :size="40" icon="svg-icon:shopping" />
+                </div>
+              </div>
+              <div class="flex flex-col justify-between">
+                <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`"
+                  >{{ t('analysis.totalShopping') }}
+                </div>
+                <CountTo
+                  :duration="2600"
+                  :end-val="13600"
+                  :start-val="0"
+                  class="text-right text-20px font-700"
+                />
+              </div>
+            </div>
+          </template>
+        </el-skeleton>
+      </el-card>
+    </el-col>
+  </el-row>
+  <el-row :gutter="20" justify="space-between">
+    <el-col :lg="10" :md="24" :sm="24" :xl="10" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="300" :options="pieOptionsData" />
+        </el-skeleton>
+      </el-card>
+    </el-col>
+    <el-col :lg="14" :md="24" :sm="24" :xl="14" :xs="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="300" :options="barOptionsData" />
+        </el-skeleton>
+      </el-card>
+    </el-col>
+    <el-col :span="24">
+      <el-card class="mb-20px" shadow="hover">
+        <el-skeleton :loading="loading" :rows="4" animated>
+          <Echart :height="350" :options="lineOptionsData" />
+        </el-skeleton>
+      </el-card>
+    </el-col>
+  </el-row>
+</template>
+<script lang="ts" setup>
+import { set } from 'lodash-es'
+import { EChartsOption } from 'echarts'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import type { AnalysisTotalTypes } from './types'
+import { barOptions, lineOptions, pieOptions } from './echarts-data'
+
+defineOptions({ name: 'Home2' })
+
+const { t } = useI18n()
+const loading = ref(true)
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('panel')
+const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption
+
+let totalState = reactive<AnalysisTotalTypes>({
+  users: 0,
+  messages: 0,
+  moneys: 0,
+  shoppings: 0
+})
+
+const getCount = async () => {
+  const data = {
+    users: 102400,
+    messages: 81212,
+    moneys: 9280,
+    shoppings: 13600
+  }
+  totalState = Object.assign(totalState, data)
+}
+
+// 用户来源
+const getUserAccessSource = async () => {
+  const data = [
+    { value: 335, name: 'analysis.directAccess' },
+    { value: 310, name: 'analysis.mailMarketing' },
+    { value: 234, name: 'analysis.allianceAdvertising' },
+    { value: 135, name: 'analysis.videoAdvertising' },
+    { value: 1548, name: 'analysis.searchEngines' }
+  ]
+  set(
+    pieOptionsData,
+    'legend.data',
+    data.map((v) => t(v.name))
+  )
+  set(pieOptionsData, 'series.data', data)
+}
+const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption
+
+// 周活跃量
+const getWeeklyUserActivity = async () => {
+  const data = [
+    { value: 13253, name: 'analysis.monday' },
+    { value: 34235, name: 'analysis.tuesday' },
+    { value: 26321, name: 'analysis.wednesday' },
+    { value: 12340, name: 'analysis.thursday' },
+    { value: 24643, name: 'analysis.friday' },
+    { value: 1322, name: 'analysis.saturday' },
+    { value: 1324, name: 'analysis.sunday' }
+  ]
+  set(
+    barOptionsData,
+    'xAxis.data',
+    data.map((v) => t(v.name))
+  )
+  set(barOptionsData, 'series', [
+    {
+      name: t('analysis.activeQuantity'),
+      data: data.map((v) => v.value),
+      type: 'bar'
+    }
+  ])
+}
+
+const lineOptionsData = reactive<EChartsOption>(lineOptions) as EChartsOption
+
+// 每月销售总额
+const getMonthlySales = async () => {
+  const data = [
+    { estimate: 100, actual: 120, name: 'analysis.january' },
+    { estimate: 120, actual: 82, name: 'analysis.february' },
+    { estimate: 161, actual: 91, name: 'analysis.march' },
+    { estimate: 134, actual: 154, name: 'analysis.april' },
+    { estimate: 105, actual: 162, name: 'analysis.may' },
+    { estimate: 160, actual: 140, name: 'analysis.june' },
+    { estimate: 165, actual: 145, name: 'analysis.july' },
+    { estimate: 114, actual: 250, name: 'analysis.august' },
+    { estimate: 163, actual: 134, name: 'analysis.september' },
+    { estimate: 185, actual: 56, name: 'analysis.october' },
+    { estimate: 118, actual: 99, name: 'analysis.november' },
+    { estimate: 123, actual: 123, name: 'analysis.december' }
+  ]
+  set(
+    lineOptionsData,
+    'xAxis.data',
+    data.map((v) => t(v.name))
+  )
+  set(lineOptionsData, 'series', [
+    {
+      name: t('analysis.estimate'),
+      smooth: true,
+      type: 'line',
+      data: data.map((v) => v.estimate),
+      animationDuration: 2800,
+      animationEasing: 'cubicInOut'
+    },
+    {
+      name: t('analysis.actual'),
+      smooth: true,
+      type: 'line',
+      itemStyle: {},
+      data: data.map((v) => v.actual),
+      animationDuration: 2800,
+      animationEasing: 'quadraticOut'
+    }
+  ])
+}
+
+const getAllApi = async () => {
+  await Promise.all([getCount(), getUserAccessSource(), getWeeklyUserActivity(), getMonthlySales()])
+  loading.value = false
+}
+
+getAllApi()
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-panel;
+
+.#{$prefix-cls} {
+  &__item {
+    &--peoples {
+      color: #40c9c6;
+    }
+
+    &--message {
+      color: #36a3f7;
+    }
+
+    &--money {
+      color: #f4516c;
+    }
+
+    &--shopping {
+      color: #34bfa3;
+    }
+
+    &:hover {
+      :deep(.#{$namespace}-icon) {
+        color: #fff !important;
+      }
+
+      .#{$prefix-cls}__item--icon {
+        transition: all 0.38s ease-out;
+      }
+
+      .#{$prefix-cls}__item--peoples {
+        background: #40c9c6;
+      }
+
+      .#{$prefix-cls}__item--message {
+        background: #36a3f7;
+      }
+
+      .#{$prefix-cls}__item--money {
+        background: #f4516c;
+      }
+
+      .#{$prefix-cls}__item--shopping {
+        background: #34bfa3;
+      }
+    }
+  }
+}
+</style>

+ 21 - 0
client/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021-present Archer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 78 - 0
client/Layout.vue

@@ -0,0 +1,78 @@
+<script lang="tsx">
+import { computed, defineComponent, unref } from 'vue'
+import { useAppStore } from '@/store/modules/app'
+import { Backtop } from '@/components/Backtop'
+import { Setting } from '@/layout/components/Setting'
+import { useRenderLayout } from './components/useRenderLayout'
+import { useDesign } from '@/hooks/web/useDesign'
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('layout')
+
+const appStore = useAppStore()
+
+// 是否是移动端
+const mobile = computed(() => appStore.getMobile)
+
+// 菜单折叠
+const collapse = computed(() => appStore.getCollapse)
+
+const layout = computed(() => appStore.getLayout)
+
+const handleClickOutside = () => {
+  appStore.setCollapse(true)
+}
+
+const renderLayout = () => {
+  switch (unref(layout)) {
+    case 'classic':
+      const { renderClassic } = useRenderLayout()
+      return renderClassic()
+    case 'topLeft':
+      const { renderTopLeft } = useRenderLayout()
+      return renderTopLeft()
+    case 'top':
+      const { renderTop } = useRenderLayout()
+      return renderTop()
+    case 'cutMenu':
+      const { renderCutMenu } = useRenderLayout()
+      return renderCutMenu()
+    default:
+      break
+  }
+}
+
+export default defineComponent({
+  name: 'Layout',
+  setup() {
+    return () => (
+      <section class={[prefixCls, `${prefixCls}__${layout.value}`, 'w-[100%] h-[100%] relative']}>
+        {mobile.value && !collapse.value ? (
+          <div
+            class="absolute left-0 top-0 z-99 h-full w-full bg-[var(--el-color-black)] opacity-30"
+            onClick={handleClickOutside}
+          ></div>
+        ) : undefined}
+
+        {renderLayout()}
+
+        <Backtop></Backtop>
+
+        <Setting></Setting>
+      </section>
+    )
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-layout;
+
+.#{$prefix-cls} {
+  background-color: var(--app-content-bg-color);
+  :deep(.#{$elNamespace}-scrollbar__view) {
+    height: 100% !important;
+  }
+}
+</style>

+ 100 - 0
client/Logger.ts

@@ -0,0 +1,100 @@
+const isArray = function (obj: any): boolean {
+  return Object.prototype.toString.call(obj) === '[object Array]'
+}
+
+const Logger = () => {}
+
+Logger.typeColor = function (type: string) {
+  let color = ''
+  switch (type) {
+    case 'primary':
+      color = '#2d8cf0'
+      break
+    case 'success':
+      color = '#19be6b'
+      break
+    case 'info':
+      color = '#909399'
+      break
+    case 'warn':
+      color = '#ff9900'
+      break
+    case 'error':
+      color = '#f03f14'
+      break
+    default:
+      color = '#35495E'
+      break
+  }
+  return color
+}
+
+Logger.print = function (type = 'default', text: any, back = false) {
+  if (typeof text === 'object') {
+    // 如果是對象則調用打印對象方式
+    isArray(text) ? console.table(text) : console.dir(text)
+    return
+  }
+  if (back) {
+    // 如果是打印帶背景圖的
+    console.log(
+      `%c ${text} `,
+      `background:${Logger.typeColor(type)}; padding: 2px; border-radius: 4px; color: #fff;`
+    )
+  } else {
+    console.log(
+      `%c ${text} `,
+      `border: 1px solid ${Logger.typeColor(type)};
+        padding: 2px; border-radius: 4px;
+        color: ${Logger.typeColor(type)};`
+    )
+  }
+}
+
+Logger.printBack = function (type = 'primary', text) {
+  this.print(type, text, true)
+}
+
+Logger.pretty = function (type = 'primary', title, text) {
+  if (typeof text === 'object') {
+    console.group('Console Group', title)
+    console.log(
+      `%c ${title}`,
+      `background:${Logger.typeColor(type)};border:1px solid ${Logger.typeColor(type)};
+        padding: 1px; border-radius: 4px; color: #fff;`
+    )
+    isArray(text) ? console.table(text) : console.dir(text)
+    console.groupEnd()
+    return
+  }
+  console.log(
+    `%c ${title} %c ${text} %c`,
+    `background:${Logger.typeColor(type)};border:1px solid ${Logger.typeColor(type)};
+      padding: 1px; border-radius: 4px 0 0 4px; color: #fff;`,
+    `border:1px solid ${Logger.typeColor(type)};
+      padding: 1px; border-radius: 0 4px 4px 0; color: ${Logger.typeColor(type)};`,
+    'background:transparent'
+  )
+}
+
+Logger.prettyPrimary = function (title, ...text) {
+  text.forEach((t) => this.pretty('primary', title, t))
+}
+
+Logger.prettySuccess = function (title, ...text) {
+  text.forEach((t) => this.pretty('success', title, t))
+}
+
+Logger.prettyWarn = function (title, ...text) {
+  text.forEach((t) => this.pretty('warn', title, t))
+}
+
+Logger.prettyError = function (title, ...text) {
+  text.forEach((t) => this.pretty('error', title, t))
+}
+
+Logger.prettyInfo = function (title, ...text) {
+  text.forEach((t) => this.pretty('info', title, t))
+}
+
+export default Logger

+ 104 - 0
client/Login.vue

@@ -0,0 +1,104 @@
+<template>
+  <div
+    :class="prefixCls"
+    class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px"
+  >
+    <div class="relative mx-auto h-full flex">
+      <div
+        :class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`"
+      >
+        <!-- 左上角的 logo + 系统标题 -->
+        <div class="relative flex items-center text-white">
+          <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+          <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+        </div>
+        <!-- 左边的背景图 + 欢迎语 -->
+        <div class="h-[calc(100%-60px)] flex items-center justify-center">
+          <TransitionGroup
+            appear
+            enter-active-class="animate__animated animate__bounceInLeft"
+            tag="div"
+          >
+            <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" />
+            <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
+            <div key="3" class="mt-5 text-14px font-normal text-white">
+              {{ t('login.message') }}
+            </div>
+          </TransitionGroup>
+        </div>
+      </div>
+      <div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px">
+        <!-- 右上角的主题、语言选择 -->
+        <div
+          class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end"
+        >
+          <div class="flex items-center at-2xl:hidden at-xl:hidden">
+            <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+            <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+          </div>
+          <div class="flex items-center justify-end space-x-10px">
+            <ThemeSwitch />
+            <LocaleDropdown class="dark:text-white lt-xl:text-white" />
+          </div>
+        </div>
+        <!-- 右边的登录界面 -->
+        <Transition appear enter-active-class="animate__animated animate__bounceInRight">
+          <div
+            class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
+          >
+            <!-- 账号登录 -->
+            <LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 手机登录 -->
+            <MobileForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 二维码登录 -->
+            <QrCodeForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 注册 -->
+            <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 三方登录 -->
+            <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+          </div>
+        </Transition>
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { underlineToHump } from '@/utils'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import { useAppStore } from '@/store/modules/app'
+import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
+import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
+
+import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
+
+defineOptions({ name: 'Login' })
+
+const { t } = useI18n()
+const appStore = useAppStore()
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('login')
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-login;
+
+.#{$prefix-cls} {
+  overflow: auto;
+
+  &__left {
+    &::before {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: -1;
+      width: 100%;
+      height: 100%;
+      background-image: url('@/assets/svgs/login-bg.svg');
+      background-position: center;
+      background-repeat: no-repeat;
+      content: '';
+    }
+  }
+}
+</style>

+ 235 - 0
client/README.md

@@ -0,0 +1,235 @@
+**严肃声明:现在、未来都不会有商业版本,所有代码全部开源!!**
+
+**「我喜欢写代码,乐此不疲」**  
+**「我喜欢做开源,以此为乐」**
+
+我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。
+
+如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。
+
+## 🐶 新手必读
+
+* nodejs > 16.18.0 && pnpm > 8.6.0 (强制使用pnpm)
+* 演示地址【Vue3 + element-plus】:<http://dashboard-vue3.yudao.iocoder.cn>
+* 演示地址【Vue3 + vben(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn>
+* 演示地址【Vue2 + element-ui】:<http://dashboard.yudao.iocoder.cn>
+* 启动文档:<https://doc.iocoder.cn/quick-start/>
+* 视频教程:<https://doc.iocoder.cn/video/>
+
+## 🐯 平台简介
+
+**芋道**,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。
+
+* 采用 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin) 实现
+* 改换 saas,自动引入等功能
+* 使用 Element Plus 免费开源的中后台模版,具备如下特性:
+
+![首页](public/home.png)
+
+* **最新技术栈**:使用 Vue3、Vite4 等前端前沿技术开发
+* **TypeScript**: 应用程序级 JavaScript 的语言
+* **主题**: 可配置的主题
+* **国际化**:内置完善的国际化方案
+* **权限**:内置完善的动态路由权限生成方案
+* **组件**:二次封装了多个常用的组件
+* **示例**:内置丰富的示例
+
+## 技术栈
+
+| 框架                                                                   | 说明               | 版本     |
+|----------------------------------------------------------------------|------------------|--------|
+| [Vue](https://staging-cn.vuejs.org/)                                 | Vue 框架           | 3.3.4 |
+| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.4.9  |
+| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.3.14 |
+| [TypeScript](https://www.typescriptlang.org/docs/)                   | JavaScript 的超集   | 5.2.2  |
+| [pinia](https://pinia.vuejs.org/)                                    | Vue 存储库 替代 vuex5 | 2.1.6 |
+| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.4.1 |
+| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.4.1  |
+| [vue-router](https://router.vuejs.org/)                              | Vue 路由           | 4.2.5  |
+| [unocss](https://uno.antfu.me/)                                      | 原子 css          | 0.56.1  |
+| [iconify](https://icon-sets.iconify.design/)                         | 在线图标库            | 3.1.1  |
+| [wangeditor](https://www.wangeditor.com/)                            | 富文本编辑器           | 5.1.23 |
+
+## 开发工具
+
+推荐 VS Code 开发,配合插件如下:
+
+| 插件名                           | 功能                       |
+|-------------------------------|--------------------------|
+| TypeScript Vue Plugin (Volar) | 用于 TypeScript 的 Vue 插件  |
+| Vue Language Features (Volar) | Vue3.0 语法支持              |
+| unocss                        | unocss for vscode           |
+| Iconify IntelliSense          | Iconify 预览和搜索           |
+| i18n Ally                     | 国际化智能提示               |
+| Stylelint                     | Css    格式化               |
+| Prettier                      | 代码格式化                   |
+| ESLint                        | 脚本代码检查                  |
+| DotENV                        | env 文件高亮                 |
+
+## 内置功能
+
+系统内置多种多种业务功能,可以用于快速你的业务系统:
+
+* 系统功能
+* 基础设施
+* 工作流程
+* 支付系统
+* 会员中心
+* 数据报表
+* 商城系统
+* 微信公众号
+
+### 系统功能
+
+|     | 功能    | 描述                              |
+|-----|-------|---------------------------------|
+|     | 用户管理  | 用户是系统操作者,该功能主要完成系统用户配置          |
+| ⭐️  | 在线用户  | 当前系统中活跃用户状态监控,支持手动踢下线           |
+|     | 角色管理  | 角色菜单权限分配、设置角色按机构进行数据范围权限划分      |
+|     | 菜单管理  | 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能    |
+|     | 部门管理  | 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限  |
+|     | 岗位管理  | 配置系统用户所属担任职务                    |
+| 🚀  | 租户管理  | 配置系统租户,支持 SaaS 场景下的多租户功能        |
+| 🚀  | 租户套餐  | 配置租户套餐,自定每个租户的菜单、操作、按钮的权限       |
+|     | 字典管理  | 对系统中经常使用的一些较为固定的数据进行维护          |
+| 🚀  | 短信管理  | 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 |
+| 🚀  | 邮件管理  | 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台       |
+| 🚀  | 站内信   | 系统内的消息通知,提供站内信模版、站内信消息          |
+| 🚀  | 操作日志  | 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 |
+| ⭐️  | 登录日志  | 系统登录日志记录查询,包含登录异常               |
+| 🚀  | 错误码管理 | 系统所有错误码的管理,可在线修改错误提示,无需重启服务     |
+|     | 通知公告  | 系统通知公告信息发布维护                    |
+| 🚀  | 敏感词   | 配置系统敏感词,支持标签分组                  |
+| 🚀  | 应用管理  | 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 |
+| 🚀  | 地区管理  | 展示省份、城市、区镇等城市信息,支持 IP 对应城市      |
+
+### 工作流程
+
+|     | 功能    | 描述                                     |
+|-----|-------|----------------------------------------|
+| 🚀  | 流程模型  | 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 |
+| 🚀  | 流程表单  | 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 |
+| 🚀  | 用户分组  | 自定义用户分组,可用于工作流的审批分组                    |
+| 🚀  | 我的流程  | 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线    |
+| 🚀  | 待办任务  | 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作    |
+| 🚀  | 已办任务  | 查看自己【已】审批的工作任务,未来会支持回退操作               |
+| 🚀  | OA 请假 | 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 |
+
+### 支付系统
+
+|     | 功能   | 描述                        |
+|-----|------|---------------------------|
+| 🚀  | 商户信息 | 管理商户信息,支持 Saas 场景下的多商户功能  |
+| 🚀  | 应用信息 | 配置商户的应用信息,对接支付宝、微信等多个支付渠道 |
+| 🚀  | 支付订单 | 查看用户发起的支付宝、微信等的【支付】订单     |
+| 🚀  | 退款订单 | 查看用户发起的支付宝、微信等的【退款】订单     |
+
+ps:核心功能已经实现,正在对接微信小程序中...
+
+### 基础设施
+
+|     | 功能       | 描述                                           |
+|-----|----------|----------------------------------------------|
+| 🚀  | 代码生成     | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载       |
+| 🚀  | 系统接口     | 基于 Swagger 自动生成相关的 RESTful API 接口文档          |
+| 🚀  | 数据库文档    | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式      |
+|     | 表单构建     | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件         |
+| 🚀  | 配置管理     | 对系统动态配置常用参数,支持 SpringBoot 加载                 |
+| ⭐️  | 定时任务     | 在线(添加、修改、删除)任务调度包含执行结果日志                     |
+| 🚀  | 文件服务     | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等   |
+| 🚀  | API 日志   | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题   |
+|     | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈              |
+|     | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理           |
+| 🚀  | 消息队列     | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 |
+| 🚀  | Java 监控  | 基于 Spring Boot Admin 实现 Java 应用的监控           |
+| 🚀  | 链路追踪     | 接入 SkyWalking 组件,实现链路追踪                      |
+| 🚀  | 日志中心     | 接入 SkyWalking 组件,实现日志中心                      |
+| 🚀  | 分布式锁     | 基于 Redis 实现分布式锁,满足并发场景                       |
+| 🚀  | 幂等组件     | 基于 Redis 实现幂等组件,解决重复请求问题                     |
+| 🚀  | 服务保障     | 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能          |
+| 🚀  | 日志服务     | 轻量级日志中心,查看远程服务器的日志                           |
+| 🚀  | 单元测试     | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等    |
+
+### 数据报表
+
+|     | 功能    | 描述                 |
+|-----|-------|--------------------|
+| 🚀  | 报表设计器 | 支持数据报表、图形报表、打印设计等  |
+| 🚀  | 大屏设计器 | 拖拽生成数据大屏,内置几十种图表组件 |
+
+### 微信公众号
+
+|     | 功能     | 描述                            |
+|-----|--------|-------------------------------|
+| 🚀  | 账号管理   | 配置接入的微信公众号,可支持多个公众号           |
+| 🚀  | 数据统计   | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据  |
+| 🚀  | 粉丝管理   | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
+| 🚀  | 消息管理   | 查看粉丝发送的消息列表,可主动回复粉丝消息         |
+| 🚀  | 自动回复   | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
+| 🚀  | 标签管理   | 对公众号的标签进行创建、查询、修改、删除等操作       |
+| 🚀  | 菜单管理   | 自定义公众号的菜单,也可以从公众号同步菜单         |
+| 🚀  | 素材管理   | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
+| 🚀  | 图文草稿箱  | 新增常用的图文素材到草稿箱,可发布到公众号         |
+| 🚀  | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作           |
+
+### 商城系统
+
+建设中...
+
+![功能图](http://static.iocoder.cn/mall%20%E5%8A%9F%E8%83%BD%E5%9B%BE-min.png)
+
+![GIF 图-耐心等待](https://raw.githubusercontent.com/YunaiV/Blog/master/Mall/onemall-admin-min.gif)
+
+![GIF 图-耐心等待](https://raw.githubusercontent.com/YunaiV/Blog/master/Mall/onemall-h5-min.gif)
+
+## 🐷 演示图
+
+### 系统功能
+
+| 模块       | biu                                                                | biu                                                              | biu                                                              |
+|------------|--------------------------------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------|
+| 登录 & 首页  | ![登录](https://static.iocoder.cn/images/ruoyi-vue-pro/登录.jpg?imageView2/2/format/webp/w/1280)       | ![首页](https://static.iocoder.cn/images/ruoyi-vue-pro/首页.jpg?imageView2/2/format/webp/w/1280)     | ![个人中心](https://static.iocoder.cn/images/ruoyi-vue-pro/个人中心.jpg?imageView2/2/format/webp/w/1280) |
+| 用户 & 应用  | ![用户管理](https://static.iocoder.cn/images/ruoyi-vue-pro/用户管理.jpg?imageView2/2/format/webp/w/1280)   | ![令牌管理](https://static.iocoder.cn/images/ruoyi-vue-pro/令牌管理.jpg?imageView2/2/format/webp/w/1280) | ![应用管理](https://static.iocoder.cn/images/ruoyi-vue-pro/应用管理.jpg?imageView2/2/format/webp/w/1280)                                                                |
+| 租户 & 套餐  | ![租户管理](https://static.iocoder.cn/images/ruoyi-vue-pro/租户管理.jpg?imageView2/2/format/webp/w/1280)   | ![租户套餐](https://static.iocoder.cn/images/ruoyi-vue-pro/租户套餐.png) | -                                                                |
+| 部门 & 岗位  | ![部门管理](https://static.iocoder.cn/images/ruoyi-vue-pro/部门管理.jpg?imageView2/2/format/webp/w/1280)   | ![岗位管理](https://static.iocoder.cn/images/ruoyi-vue-pro/岗位管理.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+| 菜单 & 角色  | ![菜单管理](https://static.iocoder.cn/images/ruoyi-vue-pro/菜单管理.jpg?imageView2/2/format/webp/w/1280)   | ![角色管理](https://static.iocoder.cn/images/ruoyi-vue-pro/角色管理.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+| 审计日志     | ![操作日志](https://static.iocoder.cn/images/ruoyi-vue-pro/操作日志.jpg?imageView2/2/format/webp/w/1280)   | ![登录日志](https://static.iocoder.cn/images/ruoyi-vue-pro/登录日志.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+| 短信       | ![短信渠道](https://static.iocoder.cn/images/ruoyi-vue-pro/短信渠道.jpg?imageView2/2/format/webp/w/1280)   | ![短信模板](https://static.iocoder.cn/images/ruoyi-vue-pro/短信模板.jpg?imageView2/2/format/webp/w/1280) | ![短信日志](https://static.iocoder.cn/images/ruoyi-vue-pro/短信日志.jpg?imageView2/2/format/webp/w/1280) |
+| 字典 & 敏感词 | ![字典类型](https://static.iocoder.cn/images/ruoyi-vue-pro/字典类型.jpg?imageView2/2/format/webp/w/1280)   | ![字典数据](https://static.iocoder.cn/images/ruoyi-vue-pro/字典数据.jpg?imageView2/2/format/webp/w/1280) | ![敏感词](https://static.iocoder.cn/images/ruoyi-vue-pro/敏感词.jpg?imageView2/2/format/webp/w/1280)                                                                |
+| 错误码 & 通知 | ![错误码管理](https://static.iocoder.cn/images/ruoyi-vue-pro/错误码管理.jpg?imageView2/2/format/webp/w/1280) | ![通知公告](https://static.iocoder.cn/images/ruoyi-vue-pro/通知公告.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+
+### 工作流程
+
+| 模块      | biu                                                                    | biu                                                                    | biu                                                                    |
+|---------|------------------------------------------------------------------------|------------------------------------------------------------------------|------------------------------------------------------------------------|
+| 流程模型    | ![流程模型-列表](https://static.iocoder.cn/images/ruoyi-vue-pro/流程模型-列表.jpg?imageView2/2/format/webp/w/1280) | ![流程模型-设计](https://static.iocoder.cn/images/ruoyi-vue-pro/流程模型-设计.jpg?imageView2/2/format/webp/w/1280) | ![流程模型-定义](https://static.iocoder.cn/images/ruoyi-vue-pro/流程模型-定义.jpg?imageView2/2/format/webp/w/1280) |
+| 表单 & 分组 | ![流程表单](https://static.iocoder.cn/images/ruoyi-vue-pro/流程表单.jpg?imageView2/2/format/webp/w/1280)       | ![用户分组](https://static.iocoder.cn/images/ruoyi-vue-pro/用户分组.jpg?imageView2/2/format/webp/w/1280)       | -                                                                      |
+| 我的流程    | ![我的流程-列表](https://static.iocoder.cn/images/ruoyi-vue-pro/我的流程-列表.jpg?imageView2/2/format/webp/w/1280) | ![我的流程-发起](https://static.iocoder.cn/images/ruoyi-vue-pro/我的流程-发起.jpg?imageView2/2/format/webp/w/1280) | ![我的流程-详情](https://static.iocoder.cn/images/ruoyi-vue-pro/我的流程-详情.jpg?imageView2/2/format/webp/w/1280) |
+| 待办 & 已办 | ![任务列表-审批](https://static.iocoder.cn/images/ruoyi-vue-pro/任务列表-审批.jpg?imageView2/2/format/webp/w/1280) | ![任务列表-待办](https://static.iocoder.cn/images/ruoyi-vue-pro/任务列表-待办.jpg?imageView2/2/format/webp/w/1280) | ![任务列表-已办](https://static.iocoder.cn/images/ruoyi-vue-pro/任务列表-已办.jpg?imageView2/2/format/webp/w/1280) |
+| OA 请假   | ![OA请假-列表](https://static.iocoder.cn/images/ruoyi-vue-pro/OA请假-列表.jpg?imageView2/2/format/webp/w/1280) | ![OA请假-发起](https://static.iocoder.cn/images/ruoyi-vue-pro/OA请假-发起.jpg?imageView2/2/format/webp/w/1280) | ![OA请假-详情](https://static.iocoder.cn/images/ruoyi-vue-pro/OA请假-详情.jpg?imageView2/2/format/webp/w/1280) |
+
+### 基础设施
+
+| 模块            | biu                                                                  | biu                                                                | biu                                                              |
+|---------------|----------------------------------------------------------------------|--------------------------------------------------------------------|------------------------------------------------------------------|
+| 代码生成          | ![代码生成](https://static.iocoder.cn/images/ruoyi-vue-pro/代码生成.jpg?imageView2/2/format/webp/w/1280)     | ![生成效果](https://static.iocoder.cn/images/ruoyi-vue-pro/生成效果.jpg?imageView2/2/format/webp/w/1280)   | -                                                                |
+| 文档            | ![系统接口](https://static.iocoder.cn/images/ruoyi-vue-pro/系统接口.jpg?imageView2/2/format/webp/w/1280)     | ![数据库文档](https://static.iocoder.cn/images/ruoyi-vue-pro/数据库文档.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+| 文件 & 配置       | ![文件配置](https://static.iocoder.cn/images/ruoyi-vue-pro/文件配置.jpg?imageView2/2/format/webp/w/1280) | ![文件管理](https://static.iocoder.cn/images/ruoyi-vue-pro/文件管理2.jpg?imageView2/2/format/webp/w/1280)     | ![配置管理](https://static.iocoder.cn/images/ruoyi-vue-pro/配置管理.jpg?imageView2/2/format/webp/w/1280)   |
+| 定时任务          | ![定时任务](https://static.iocoder.cn/images/ruoyi-vue-pro/定时任务.jpg?imageView2/2/format/webp/w/1280)     | ![任务日志](https://static.iocoder.cn/images/ruoyi-vue-pro/任务日志.jpg?imageView2/2/format/webp/w/1280)   | -                                                                |
+| API 日志        | ![访问日志](https://static.iocoder.cn/images/ruoyi-vue-pro/访问日志.jpg?imageView2/2/format/webp/w/1280)     | ![错误日志](https://static.iocoder.cn/images/ruoyi-vue-pro/错误日志.jpg?imageView2/2/format/webp/w/1280)   | -                                                                |
+| MySQL & Redis | ![MySQL](https://static.iocoder.cn/images/ruoyi-vue-pro/MySQL.jpg?imageView2/2/format/webp/w/1280)   | ![Redis](https://static.iocoder.cn/images/ruoyi-vue-pro/Redis.jpg?imageView2/2/format/webp/w/1280) | -                                                                |
+| 监控平台          | ![Java监控](https://static.iocoder.cn/images/ruoyi-vue-pro/Java监控.jpg?imageView2/2/format/webp/w/1280) | ![链路追踪](https://static.iocoder.cn/images/ruoyi-vue-pro/链路追踪.jpg?imageView2/2/format/webp/w/1280)   | ![日志中心](https://static.iocoder.cn/images/ruoyi-vue-pro/日志中心.jpg?imageView2/2/format/webp/w/1280) |
+
+### 支付系统
+
+| 模块      | biu                                                              | biu                                                                    | biu                                                                    |
+|---------|------------------------------------------------------------------|------------------------------------------------------------------------|------------------------------------------------------------------------|
+| 商家 & 应用 | ![商户信息](https://static.iocoder.cn/images/ruoyi-vue-pro/商户信息.jpg?imageView2/2/format/webp/w/1280) | ![应用信息-列表](https://static.iocoder.cn/images/ruoyi-vue-pro/应用信息-列表.jpg?imageView2/2/format/webp/w/1280) | ![应用信息-编辑](https://static.iocoder.cn/images/ruoyi-vue-pro/应用信息-编辑.jpg?imageView2/2/format/webp/w/1280) |
+| 支付 & 退款 | ![支付订单](https://static.iocoder.cn/images/ruoyi-vue-pro/支付订单.jpg?imageView2/2/format/webp/w/1280) | ![退款订单](https://static.iocoder.cn/images/ruoyi-vue-pro/退款订单.jpg?imageView2/2/format/webp/w/1280)       | ---                                                                    |
+
+### 数据报表
+
+| 模块    | biu                                                                                                    | biu                                                                                                    | biu                                                                                                          |
+|-------|--------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
+| 报表设计器 | ![数据报表](https://static.iocoder.cn/images/ruoyi-vue-pro/报表设计器-数据报表.jpg?imageView2/2/format/webp/w/1280) | ![图形报表](https://static.iocoder.cn/images/ruoyi-vue-pro/报表设计器-图形报表.jpg?imageView2/2/format/webp/w/1280) | ![报表设计器-打印设计](https://static.iocoder.cn/images/ruoyi-vue-pro/报表设计器-打印设计.jpg?imageView2/2/format/webp/w/1280) |
+| 大屏设计器 | ![大屏列表](https://static.iocoder.cn/images/ruoyi-vue-pro/大屏设计器-列表.jpg?imageView2/2/format/webp/w/1280)   | ![大屏预览](https://static.iocoder.cn/images/ruoyi-vue-pro/大屏设计器-预览.jpg?imageView2/2/format/webp/w/1280)   | ![大屏编辑](https://static.iocoder.cn/images/ruoyi-vue-pro/大屏设计器-编辑.jpg?imageView2/2/format/webp/w/1280)         |

+ 28 - 0
client/Redirect.vue

@@ -0,0 +1,28 @@
+<template>
+  <div></div>
+</template>
+<script lang="ts" setup>
+defineOptions({ name: 'Redirect' })
+
+const { currentRoute, replace } = useRouter()
+const { params, query } = unref(currentRoute)
+const { path, _redirect_type = 'path' } = params
+
+Reflect.deleteProperty(params, '_redirect_type')
+Reflect.deleteProperty(params, 'path')
+
+const _path = Array.isArray(path) ? path.join('/') : path
+
+if (_redirect_type === 'name') {
+  replace({
+    name: _path,
+    query,
+    params
+  })
+} else {
+  replace({
+    path: _path.startsWith('/') ? _path : '/' + _path,
+    query
+  })
+}
+</script>

+ 90 - 0
client/ToolHeader.vue

@@ -0,0 +1,90 @@
+<script lang="tsx">
+import { defineComponent, computed } from 'vue'
+import { Message } from '@/layout/components//Message'
+import { Collapse } from '@/layout/components/Collapse'
+import { UserInfo } from '@/layout/components/UserInfo'
+import { Screenfull } from '@/layout/components/Screenfull'
+import { Breadcrumb } from '@/layout/components/Breadcrumb'
+import { SizeDropdown } from '@/layout/components/SizeDropdown'
+import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
+import { useAppStore } from '@/store/modules/app'
+import { useDesign } from '@/hooks/web/useDesign'
+
+const { getPrefixCls, variables } = useDesign()
+
+const prefixCls = getPrefixCls('tool-header')
+
+const appStore = useAppStore()
+
+// 面包屑
+const breadcrumb = computed(() => appStore.getBreadcrumb)
+
+// 折叠图标
+const hamburger = computed(() => appStore.getHamburger)
+
+// 全屏图标
+const screenfull = computed(() => appStore.getScreenfull)
+
+// 尺寸图标
+const size = computed(() => appStore.getSize)
+
+// 布局
+const layout = computed(() => appStore.getLayout)
+
+// 多语言图标
+const locale = computed(() => appStore.getLocale)
+
+// 消息图标
+const message = computed(() => appStore.getMessage)
+
+export default defineComponent({
+  name: 'ToolHeader',
+  setup() {
+    return () => (
+      <div
+        id={`${variables.namespace}-tool-header`}
+        class={[
+          prefixCls,
+          'h-[var(--top-tool-height)] relative px-[var(--top-tool-p-x)] flex items-center justify-between',
+          'dark:bg-[var(--el-bg-color)]'
+        ]}
+      >
+        {layout.value !== 'top' ? (
+          <div class="h-full flex items-center">
+            {hamburger.value && layout.value !== 'cutMenu' ? (
+              <Collapse class="custom-hover" color="var(--top-header-text-color)"></Collapse>
+            ) : undefined}
+            {breadcrumb.value ? <Breadcrumb class="lt-md:hidden"></Breadcrumb> : undefined}
+          </div>
+        ) : undefined}
+        <div class="h-full flex items-center">
+          {screenfull.value ? (
+            <Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
+          ) : undefined}
+          {size.value ? (
+            <SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown>
+          ) : undefined}
+          {locale.value ? (
+            <LocaleDropdown
+              class="custom-hover"
+              color="var(--top-header-text-color)"
+            ></LocaleDropdown>
+          ) : undefined}
+          {message.value ? (
+            <Message class="custom-hover" color="var(--top-header-text-color)"></Message>
+          ) : undefined}
+          <UserInfo></UserInfo>
+        </div>
+      </div>
+    )
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-tool-header;
+
+.#{$prefix-cls} {
+  transition: left var(--transition-time-02);
+}
+</style>

+ 274 - 0
client/app.ts

@@ -0,0 +1,274 @@
+import { defineStore } from 'pinia'
+import { store } from '../index'
+import { setCssVar, humpToUnderline } from '@/utils'
+import { ElMessage } from 'element-plus'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { ElementPlusSize } from '@/types/elementPlus'
+import { LayoutType } from '@/types/layout'
+import { ThemeTypes } from '@/types/theme'
+
+const { wsCache } = useCache()
+
+interface AppState {
+  breadcrumb: boolean
+  breadcrumbIcon: boolean
+  collapse: boolean
+  uniqueOpened: boolean
+  hamburger: boolean
+  screenfull: boolean
+  size: boolean
+  locale: boolean
+  message: boolean
+  tagsView: boolean
+  tagsViewIcon: boolean
+  logo: boolean
+  fixedHeader: boolean
+  greyMode: boolean
+  pageLoading: boolean
+  layout: LayoutType
+  title: string
+  userInfo: string
+  isDark: boolean
+  currentSize: ElementPlusSize
+  sizeMap: ElementPlusSize[]
+  mobile: boolean
+  footer: boolean
+  theme: ThemeTypes
+  fixedMenu: boolean
+}
+
+export const useAppStore = defineStore('app', {
+  state: (): AppState => {
+    return {
+      userInfo: 'userInfo', // 登录信息存储字段-建议每个项目换一个字段,避免与其他项目冲突
+      sizeMap: ['default', 'large', 'small'],
+      mobile: false, // 是否是移动端
+      title: import.meta.env.VITE_APP_TITLE, // 标题
+      pageLoading: false, // 路由跳转loading
+
+      breadcrumb: true, // 面包屑
+      breadcrumbIcon: true, // 面包屑图标
+      collapse: false, // 折叠菜单
+      uniqueOpened: true, // 是否只保持一个子菜单的展开
+      hamburger: true, // 折叠图标
+      screenfull: true, // 全屏图标
+      size: true, // 尺寸图标
+      locale: true, // 多语言图标
+      message: true, // 消息图标
+      tagsView: true, // 标签页
+      tagsViewIcon: true, // 是否显示标签图标
+      logo: true, // logo
+      fixedHeader: true, // 固定toolheader
+      footer: true, // 显示页脚
+      greyMode: false, // 是否开始灰色模式,用于特殊悼念日
+      fixedMenu: wsCache.get('fixedMenu') || false, // 是否固定菜单
+
+      layout: wsCache.get(CACHE_KEY.LAYOUT) || 'classic', // layout布局
+      isDark: wsCache.get(CACHE_KEY.IS_DARK) || false, // 是否是暗黑模式
+      currentSize: wsCache.get('default') || 'default', // 组件尺寸
+      theme: wsCache.get(CACHE_KEY.THEME) || {
+        // 主题色
+        elColorPrimary: '#409eff',
+        // 左侧菜单边框颜色
+        leftMenuBorderColor: 'inherit',
+        // 左侧菜单背景颜色
+        leftMenuBgColor: '#001529',
+        // 左侧菜单浅色背景颜色
+        leftMenuBgLightColor: '#0f2438',
+        // 左侧菜单选中背景颜色
+        leftMenuBgActiveColor: 'var(--el-color-primary)',
+        // 左侧菜单收起选中背景颜色
+        leftMenuCollapseBgActiveColor: 'var(--el-color-primary)',
+        // 左侧菜单字体颜色
+        leftMenuTextColor: '#bfcbd9',
+        // 左侧菜单选中字体颜色
+        leftMenuTextActiveColor: '#fff',
+        // logo字体颜色
+        logoTitleTextColor: '#fff',
+        // logo边框颜色
+        logoBorderColor: 'inherit',
+        // 头部背景颜色
+        topHeaderBgColor: '#fff',
+        // 头部字体颜色
+        topHeaderTextColor: 'inherit',
+        // 头部悬停颜色
+        topHeaderHoverColor: '#f6f6f6',
+        // 头部边框颜色
+        topToolBorderColor: '#eee'
+      }
+    }
+  },
+  getters: {
+    getBreadcrumb(): boolean {
+      return this.breadcrumb
+    },
+    getBreadcrumbIcon(): boolean {
+      return this.breadcrumbIcon
+    },
+    getCollapse(): boolean {
+      return this.collapse
+    },
+    getUniqueOpened(): boolean {
+      return this.uniqueOpened
+    },
+    getHamburger(): boolean {
+      return this.hamburger
+    },
+    getScreenfull(): boolean {
+      return this.screenfull
+    },
+    getSize(): boolean {
+      return this.size
+    },
+    getLocale(): boolean {
+      return this.locale
+    },
+    getMessage(): boolean {
+      return this.message
+    },
+    getTagsView(): boolean {
+      return this.tagsView
+    },
+    getTagsViewIcon(): boolean {
+      return this.tagsViewIcon
+    },
+    getLogo(): boolean {
+      return this.logo
+    },
+    getFixedHeader(): boolean {
+      return this.fixedHeader
+    },
+    getGreyMode(): boolean {
+      return this.greyMode
+    },
+    getFixedMenu(): boolean {
+      return this.fixedMenu
+    },
+    getPageLoading(): boolean {
+      return this.pageLoading
+    },
+    getLayout(): LayoutType {
+      return this.layout
+    },
+    getTitle(): string {
+      return this.title
+    },
+    getUserInfo(): string {
+      return this.userInfo
+    },
+    getIsDark(): boolean {
+      return this.isDark
+    },
+    getCurrentSize(): ElementPlusSize {
+      return this.currentSize
+    },
+    getSizeMap(): ElementPlusSize[] {
+      return this.sizeMap
+    },
+    getMobile(): boolean {
+      return this.mobile
+    },
+    getTheme(): ThemeTypes {
+      return this.theme
+    },
+    getFooter(): boolean {
+      return this.footer
+    }
+  },
+  actions: {
+    setBreadcrumb(breadcrumb: boolean) {
+      this.breadcrumb = breadcrumb
+    },
+    setBreadcrumbIcon(breadcrumbIcon: boolean) {
+      this.breadcrumbIcon = breadcrumbIcon
+    },
+    setCollapse(collapse: boolean) {
+      this.collapse = collapse
+    },
+    setUniqueOpened(uniqueOpened: boolean) {
+      this.uniqueOpened = uniqueOpened
+    },
+    setHamburger(hamburger: boolean) {
+      this.hamburger = hamburger
+    },
+    setScreenfull(screenfull: boolean) {
+      this.screenfull = screenfull
+    },
+    setSize(size: boolean) {
+      this.size = size
+    },
+    setLocale(locale: boolean) {
+      this.locale = locale
+    },
+    setMessage(message: boolean) {
+      this.message = message
+    },
+    setTagsView(tagsView: boolean) {
+      this.tagsView = tagsView
+    },
+    setTagsViewIcon(tagsViewIcon: boolean) {
+      this.tagsViewIcon = tagsViewIcon
+    },
+    setLogo(logo: boolean) {
+      this.logo = logo
+    },
+    setFixedHeader(fixedHeader: boolean) {
+      this.fixedHeader = fixedHeader
+    },
+    setGreyMode(greyMode: boolean) {
+      this.greyMode = greyMode
+    },
+    setFixedMenu(fixedMenu: boolean) {
+      wsCache.set('fixedMenu', fixedMenu)
+      this.fixedMenu = fixedMenu
+    },
+    setPageLoading(pageLoading: boolean) {
+      this.pageLoading = pageLoading
+    },
+    setLayout(layout: LayoutType) {
+      if (this.mobile && layout !== 'classic') {
+        ElMessage.warning('移动端模式下不支持切换其他布局')
+        return
+      }
+      this.layout = layout
+      wsCache.set(CACHE_KEY.LAYOUT, this.layout)
+    },
+    setTitle(title: string) {
+      this.title = title
+    },
+    setIsDark(isDark: boolean) {
+      this.isDark = isDark
+      if (this.isDark) {
+        document.documentElement.classList.add('dark')
+        document.documentElement.classList.remove('light')
+      } else {
+        document.documentElement.classList.add('light')
+        document.documentElement.classList.remove('dark')
+      }
+      wsCache.set(CACHE_KEY.IS_DARK, this.isDark)
+    },
+    setCurrentSize(currentSize: ElementPlusSize) {
+      this.currentSize = currentSize
+      wsCache.set('currentSize', this.currentSize)
+    },
+    setMobile(mobile: boolean) {
+      this.mobile = mobile
+    },
+    setTheme(theme: ThemeTypes) {
+      this.theme = Object.assign(this.theme, theme)
+      wsCache.set(CACHE_KEY.THEME, this.theme)
+    },
+    setCssVarTheme() {
+      for (const key in this.theme) {
+        setCssVar(`--${humpToUnderline(key)}`, this.theme[key])
+      }
+    },
+    setFooter(footer: boolean) {
+      this.footer = footer
+    }
+  }
+})
+
+export const useAppStoreWithOut = () => {
+  return useAppStore(store)
+}

+ 92 - 0
client/auth.ts

@@ -0,0 +1,92 @@
+import { useCache } from '@/hooks/web/useCache'
+import { TokenType } from '@/api/login/types'
+import { decrypt, encrypt } from '@/utils/jsencrypt'
+
+const { wsCache } = useCache()
+
+const AccessTokenKey = 'ACCESS_TOKEN'
+const RefreshTokenKey = 'REFRESH_TOKEN'
+
+// 获取token
+export const getAccessToken = () => {
+  // 此处与TokenKey相同,此写法解决初始化时Cookies中不存在TokenKey报错
+  return wsCache.get(AccessTokenKey) ? wsCache.get(AccessTokenKey) : wsCache.get('ACCESS_TOKEN')
+}
+
+// 刷新token
+export const getRefreshToken = () => {
+  return wsCache.get(RefreshTokenKey)
+}
+
+// 设置token
+export const setToken = (token: TokenType) => {
+  wsCache.set(RefreshTokenKey, token.refreshToken)
+  wsCache.set(AccessTokenKey, token.accessToken)
+}
+
+// 删除token
+export const removeToken = () => {
+  wsCache.delete(AccessTokenKey)
+  wsCache.delete(RefreshTokenKey)
+}
+
+/** 格式化token(jwt格式) */
+export const formatToken = (token: string): string => {
+  return 'Bearer ' + token
+}
+// ========== 账号相关 ==========
+
+const LoginFormKey = 'LOGINFORM'
+
+export type LoginFormType = {
+  tenantName: string
+  username: string
+  password: string
+  rememberMe: boolean
+}
+
+export const getLoginForm = () => {
+  const loginForm: LoginFormType = wsCache.get(LoginFormKey)
+  if (loginForm) {
+    loginForm.password = decrypt(loginForm.password) as string
+  }
+  return loginForm
+}
+
+export const setLoginForm = (loginForm: LoginFormType) => {
+  loginForm.password = encrypt(loginForm.password) as string
+  wsCache.set(LoginFormKey, loginForm, { exp: 30 * 24 * 60 * 60 })
+}
+
+export const removeLoginForm = () => {
+  wsCache.delete(LoginFormKey)
+}
+
+// ========== 租户相关 ==========
+
+const TenantIdKey = 'TENANT_ID'
+const TenantNameKey = 'TENANT_NAME'
+
+export const getTenantName = () => {
+  return wsCache.get(TenantNameKey)
+}
+
+export const setTenantName = (username: string) => {
+  wsCache.set(TenantNameKey, username, { exp: 30 * 24 * 60 * 60 })
+}
+
+export const removeTenantName = () => {
+  wsCache.delete(TenantNameKey)
+}
+
+export const getTenantId = () => {
+  return wsCache.get(TenantIdKey)
+}
+
+export const setTenantId = (username: string) => {
+  wsCache.set(TenantIdKey, username)
+}
+
+export const removeTenantId = () => {
+  wsCache.delete(TenantIdKey)
+}

BIN
client/avatar.gif


BIN
client/avatar.jpg


+ 61 - 0
client/brand.ts

@@ -0,0 +1,61 @@
+import request from '@/config/axios'
+
+/**
+ * 商品品牌
+ */
+export interface BrandVO {
+  /**
+   * 品牌编号
+   */
+  id?: number
+  /**
+   * 品牌名称
+   */
+  name: string
+  /**
+   * 品牌图片
+   */
+  picUrl: string
+  /**
+   * 品牌排序
+   */
+  sort?: number
+  /**
+   * 品牌描述
+   */
+  description?: string
+  /**
+   * 开启状态
+   */
+  status: number
+}
+
+// 创建商品品牌
+export const createBrand = (data: BrandVO) => {
+  return request.post({ url: '/product/brand/create', data })
+}
+
+// 更新商品品牌
+export const updateBrand = (data: BrandVO) => {
+  return request.put({ url: '/product/brand/update', data })
+}
+
+// 删除商品品牌
+export const deleteBrand = (id: number) => {
+  return request.delete({ url: `/product/brand/delete?id=${id}` })
+}
+
+// 获得商品品牌
+export const getBrand = (id: number) => {
+  return request.get({ url: `/product/brand/get?id=${id}` })
+}
+
+// 获得商品品牌列表
+export const getBrandParam = (params: PageParam) => {
+  return request.get({ url: '/product/brand/page', params })
+}
+
+// 获得商品品牌精简信息列表
+export const getSimpleBrandList = () => {
+  return request.get({ url: '/product/brand/list-all-simple' })
+}

+ 60 - 0
client/category.ts

@@ -0,0 +1,60 @@
+import request from '@/config/axios'
+
+/**
+ * 产品分类
+ */
+export interface CategoryVO {
+  /**
+   * 分类编号
+   */
+  id?: number
+  /**
+   * 父分类编号
+   */
+  parentId?: number
+  /**
+   * 分类名称
+   */
+  name: string
+  /**
+   * 移动端分类图
+   */
+  picUrl: string
+  /**
+   * PC 端分类图
+   */
+  bigPicUrl?: string
+  /**
+   * 分类排序
+   */
+  sort: number
+  /**
+   * 开启状态
+   */
+  status: number
+}
+
+// 创建商品分类
+export const createCategory = (data: CategoryVO) => {
+  return request.post({ url: '/product/category/create', data })
+}
+
+// 更新商品分类
+export const updateCategory = (data: CategoryVO) => {
+  return request.put({ url: '/product/category/update', data })
+}
+
+// 删除商品分类
+export const deleteCategory = (id: number) => {
+  return request.delete({ url: `/product/category/delete?id=${id}` })
+}
+
+// 获得商品分类
+export const getCategory = (id: number) => {
+  return request.get({ url: `/product/category/get?id=${id}` })
+}
+
+// 获得商品分类列表
+export const getCategoryList = (params: any) => {
+  return request.get({ url: '/product/category/list', params })
+}

+ 153 - 0
client/color.ts

@@ -0,0 +1,153 @@
+/**
+ * 判断是否 十六进制颜色值.
+ * 输入形式可为 #fff000 #f00
+ *
+ * @param   String  color   十六进制颜色值
+ * @return  Boolean
+ */
+export const isHexColor = (color: string) => {
+  const reg = /^#([0-9a-fA-F]{3}|[0-9a-fA-f]{6})$/
+  return reg.test(color)
+}
+
+/**
+ * RGB 颜色值转换为 十六进制颜色值.
+ * r, g, 和 b 需要在 [0, 255] 范围内
+ *
+ * @return  String          类似#ff00ff
+ * @param r
+ * @param g
+ * @param b
+ */
+export const rgbToHex = (r: number, g: number, b: number) => {
+  // tslint:disable-next-line:no-bitwise
+  const hex = ((r << 16) | (g << 8) | b).toString(16)
+  return '#' + new Array(Math.abs(hex.length - 7)).join('0') + hex
+}
+
+/**
+ * Transform a HEX color to its RGB representation
+ * @param {string} hex The color to transform
+ * @returns The RGB representation of the passed color
+ */
+export const hexToRGB = (hex: string, opacity?: number) => {
+  let sHex = hex.toLowerCase()
+  if (isHexColor(hex)) {
+    if (sHex.length === 4) {
+      let sColorNew = '#'
+      for (let i = 1; i < 4; i += 1) {
+        sColorNew += sHex.slice(i, i + 1).concat(sHex.slice(i, i + 1))
+      }
+      sHex = sColorNew
+    }
+    const sColorChange: number[] = []
+    for (let i = 1; i < 7; i += 2) {
+      sColorChange.push(parseInt('0x' + sHex.slice(i, i + 2)))
+    }
+    return opacity
+      ? 'RGBA(' + sColorChange.join(',') + ',' + opacity + ')'
+      : 'RGB(' + sColorChange.join(',') + ')'
+  }
+  return sHex
+}
+
+export const colorIsDark = (color: string) => {
+  if (!isHexColor(color)) return
+  const [r, g, b] = hexToRGB(color)
+    .replace(/(?:\(|\)|rgb|RGB)*/g, '')
+    .split(',')
+    .map((item) => Number(item))
+  return r * 0.299 + g * 0.578 + b * 0.114 < 192
+}
+
+/**
+ * Darkens a HEX color given the passed percentage
+ * @param {string} color The color to process
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The HEX representation of the processed color
+ */
+export const darken = (color: string, amount: number) => {
+  color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color
+  amount = Math.trunc((255 * amount) / 100)
+  return `#${subtractLight(color.substring(0, 2), amount)}${subtractLight(
+    color.substring(2, 4),
+    amount
+  )}${subtractLight(color.substring(4, 6), amount)}`
+}
+
+/**
+ * Lightens a 6 char HEX color according to the passed percentage
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed color represented as HEX
+ */
+export const lighten = (color: string, amount: number) => {
+  color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color
+  amount = Math.trunc((255 * amount) / 100)
+  return `#${addLight(color.substring(0, 2), amount)}${addLight(
+    color.substring(2, 4),
+    amount
+  )}${addLight(color.substring(4, 6), amount)}`
+}
+
+/* Suma el porcentaje indicado a un color (RR, GG o BB) hexadecimal para aclararlo */
+/**
+ * Sums the passed percentage to the R, G or B of a HEX color
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed part of the color
+ */
+const addLight = (color: string, amount: number) => {
+  const cc = parseInt(color, 16) + amount
+  const c = cc > 255 ? 255 : cc
+  return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
+}
+
+/**
+ * Calculates luminance of an rgb color
+ * @param {number} r red
+ * @param {number} g green
+ * @param {number} b blue
+ */
+const luminanace = (r: number, g: number, b: number) => {
+  const a = [r, g, b].map((v) => {
+    v /= 255
+    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
+  })
+  return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722
+}
+
+/**
+ * Calculates contrast between two rgb colors
+ * @param {string} rgb1 rgb color 1
+ * @param {string} rgb2 rgb color 2
+ */
+const contrast = (rgb1: string[], rgb2: number[]) => {
+  return (
+    (luminanace(~~rgb1[0], ~~rgb1[1], ~~rgb1[2]) + 0.05) /
+    (luminanace(rgb2[0], rgb2[1], rgb2[2]) + 0.05)
+  )
+}
+
+/**
+ * Determines what the best text color is (black or white) based con the contrast with the background
+ * @param hexColor - Last selected color by the user
+ */
+export const calculateBestTextColor = (hexColor: string) => {
+  const rgbColor = hexToRGB(hexColor.substring(1))
+  const contrastWithBlack = contrast(rgbColor.split(','), [0, 0, 0])
+
+  return contrastWithBlack >= 12 ? '#000000' : '#FFFFFF'
+}
+
+/**
+ * Subtracts the indicated percentage to the R, G or B of a HEX color
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed part of the color
+ */
+const subtractLight = (color: string, amount: number) => {
+  const cc = parseInt(color, 16) - amount
+  const c = cc < 0 ? 0 : cc
+  return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
+}

+ 49 - 0
client/comment.ts

@@ -0,0 +1,49 @@
+import request from '@/config/axios'
+
+export interface CommentVO {
+  id: number
+  userId: number
+  userNickname: string
+  userAvatar: string
+  anonymous: boolean
+  orderId: number
+  orderItemId: number
+  spuId: number
+  spuName: string
+  skuId: number
+  visible: boolean
+  scores: number
+  descriptionScores: number
+  benefitScores: number
+  content: string
+  picUrls: string
+  replyStatus: boolean
+  replyUserId: number
+  replyContent: string
+  replyTime: Date
+}
+
+// 查询商品评论列表
+export const getCommentPage = async (params) => {
+  return await request.get({ url: `/product/comment/page`, params })
+}
+
+// 查询商品评论详情
+export const getComment = async (id: number) => {
+  return await request.get({ url: `/product/comment/get?id=` + id })
+}
+
+// 添加自评
+export const createComment = async (data: CommentVO) => {
+  return await request.post({ url: `/product/comment/create`, data })
+}
+
+// 显示 / 隐藏评论
+export const updateCommentVisible = async (data: any) => {
+  return await request.put({ url: `/product/comment/update-visible`, data })
+}
+
+// 商家回复
+export const replyComment = async (data: any) => {
+  return await request.put({ url: `/product/comment/reply`, data })
+}

+ 8 - 0
client/components.d.ts

@@ -0,0 +1,8 @@
+declare module 'vue' {
+  export interface GlobalComponents {
+    Icon: typeof import('@/components/Icon')['Icon']
+    DictTag: typeof import('@/components/DictTag')['DictTag']
+  }
+}
+
+export {}

+ 28 - 0
client/config.ts

@@ -0,0 +1,28 @@
+const config: {
+  base_url: string
+  result_code: number | string
+  default_headers: AxiosHeaders
+  request_timeout: number
+} = {
+  /**
+   * api请求基础路径
+   */
+  base_url: import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL,
+  /**
+   * 接口成功返回状态码
+   */
+  result_code: 200,
+
+  /**
+   * 接口请求超时时间
+   */
+  request_timeout: 30000,
+
+  /**
+   * 默认接口请求类型
+   * 可选值:application/x-www-form-urlencoded multipart/form-data
+   */
+  default_headers: 'application/json'
+}
+
+export { config }

+ 4 - 0
client/configGlobal.d.ts

@@ -0,0 +1,4 @@
+import { ElementPlusSize } from './elementPlus'
+export interface ConfigGlobalTypes {
+  size?: ElementPlusSize
+}

+ 360 - 0
client/constants.ts

@@ -0,0 +1,360 @@
+/**
+ * Created by 芋道源码
+ *
+ * 枚举类
+ */
+
+// 全局通用状态枚举
+export const CommonStatusEnum = {
+  ENABLE: 0, // 开启
+  DISABLE: 1 // 禁用
+}
+
+/**
+ * 菜单的类型枚举
+ */
+export const SystemMenuTypeEnum = {
+  DIR: 1, // 目录
+  MENU: 2, // 菜单
+  BUTTON: 3 // 按钮
+}
+
+/**
+ * 角色的类型枚举
+ */
+export const SystemRoleTypeEnum = {
+  SYSTEM: 1, // 内置角色
+  CUSTOM: 2 // 自定义角色
+}
+
+/**
+ * 数据权限的范围枚举
+ */
+export const SystemDataScopeEnum = {
+  ALL: 1, // 全部数据权限
+  DEPT_CUSTOM: 2, // 指定部门数据权限
+  DEPT_ONLY: 3, // 部门数据权限
+  DEPT_AND_CHILD: 4, // 部门及以下数据权限
+  DEPT_SELF: 5 // 仅本人数据权限
+}
+
+/**
+ * 代码生成模板类型
+ */
+export const InfraCodegenTemplateTypeEnum = {
+  CRUD: 1, // 基础 CRUD
+  TREE: 2, // 树形 CRUD
+  SUB: 3 // 主子表 CRUD
+}
+
+/**
+ * 任务状态的枚举
+ */
+export const InfraJobStatusEnum = {
+  INIT: 0, // 初始化中
+  NORMAL: 1, // 运行中
+  STOP: 2 // 暂停运行
+}
+
+/**
+ * API 异常数据的处理状态
+ */
+export const InfraApiErrorLogProcessStatusEnum = {
+  INIT: 0, // 未处理
+  DONE: 1, // 已处理
+  IGNORE: 2 // 已忽略
+}
+
+/**
+ * 用户的社交平台的类型枚举
+ */
+export const SystemUserSocialTypeEnum = {
+  DINGTALK: {
+    title: '钉钉',
+    type: 20,
+    source: 'dingtalk',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMDRs.png'
+  },
+  WECHAT_ENTERPRISE: {
+    title: '企业微信',
+    type: 30,
+    source: 'wechat_enterprise',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMrzn.png'
+  }
+}
+
+/**
+ * 支付渠道枚举
+ */
+export const PayChannelEnum = {
+  WX_PUB: {
+    code: 'wx_pub',
+    name: '微信 JSAPI 支付'
+  },
+  WX_LITE: {
+    code: 'wx_lite',
+    name: '微信小程序支付'
+  },
+  WX_APP: {
+    code: 'wx_app',
+    name: '微信 APP 支付'
+  },
+  WX_BAR: {
+    code: 'wx_bar',
+    name: '微信条码支付'
+  },
+  ALIPAY_PC: {
+    code: 'alipay_pc',
+    name: '支付宝 PC 网站支付'
+  },
+  ALIPAY_WAP: {
+    code: 'alipay_wap',
+    name: '支付宝 WAP 网站支付'
+  },
+  ALIPAY_APP: {
+    code: 'alipay_app',
+    name: '支付宝 APP 支付'
+  },
+  ALIPAY_QR: {
+    code: 'alipay_qr',
+    name: '支付宝扫码支付'
+  },
+  ALIPAY_BAR: {
+    code: 'alipay_bar',
+    name: '支付宝条码支付'
+  },
+  MOCK: {
+    code: 'mock',
+    name: '模拟支付'
+  }
+}
+
+/**
+ * 支付的展示模式每局
+ */
+export const PayDisplayModeEnum = {
+  URL: {
+    mode: 'url'
+  },
+  IFRAME: {
+    mode: 'iframe'
+  },
+  FORM: {
+    mode: 'form'
+  },
+  QR_CODE: {
+    mode: 'qr_code'
+  },
+  APP: {
+    mode: 'app'
+  }
+}
+
+/**
+ * 支付类型枚举
+ */
+export const PayType = {
+  WECHAT: 'WECHAT',
+  ALIPAY: 'ALIPAY',
+  MOCK: 'MOCK'
+}
+
+/**
+ * 支付订单状态枚举
+ */
+export const PayOrderStatusEnum = {
+  WAITING: {
+    status: 0,
+    name: '未支付'
+  },
+  SUCCESS: {
+    status: 10,
+    name: '已支付'
+  },
+  CLOSED: {
+    status: 20,
+    name: '未支付'
+  }
+}
+
+/**
+ * 商品 SPU 状态
+ */
+export const ProductSpuStatusEnum = {
+  RECYCLE: {
+    status: -1,
+    name: '回收站'
+  },
+  DISABLE: {
+    status: 0,
+    name: '下架'
+  },
+  ENABLE: {
+    status: 1,
+    name: '上架'
+  }
+}
+
+/**
+ * 优惠劵模板的有限期类型的枚举
+ */
+export const CouponTemplateValidityTypeEnum = {
+  DATE: {
+    type: 1,
+    name: '固定日期可用'
+  },
+  TERM: {
+    type: 2,
+    name: '领取之后可用'
+  }
+}
+
+/**
+ * 优惠劵模板的领取方式的枚举
+ */
+export const CouponTemplateTakeTypeEnum = {
+  USER: {
+    type: 1,
+    name: '直接领取'
+  },
+  ADMIN: {
+    type: 2,
+    name: '指定发放'
+  },
+  REGISTER: {
+    type: 3,
+    name: '新人券'
+  }
+}
+
+/**
+ * 营销的商品范围枚举
+ */
+export const PromotionProductScopeEnum = {
+  ALL: {
+    scope: 1,
+    name: '通用劵'
+  },
+  SPU: {
+    scope: 2,
+    name: '商品劵'
+  },
+  CATEGORY: {
+    scope: 3,
+    name: '品类劵'
+  }
+}
+
+/**
+ * 营销的条件类型枚举
+ */
+export const PromotionConditionTypeEnum = {
+  PRICE: {
+    type: 10,
+    name: '满 N 元'
+  },
+  COUNT: {
+    type: 20,
+    name: '满 N 件'
+  }
+}
+
+/**
+ * 优惠类型枚举
+ */
+export const PromotionDiscountTypeEnum = {
+  PRICE: {
+    type: 1,
+    name: '满减'
+  },
+  PERCENT: {
+    type: 2,
+    name: '折扣'
+  }
+}
+
+/**
+ * 分销关系绑定模式枚举
+ */
+export const BrokerageBindModeEnum = {
+  ANYTIME: {
+    mode: 0,
+    name: '没有推广人'
+  },
+  REGISTER: {
+    mode: 1,
+    name: '新用户'
+  }
+}
+/**
+ * 分佣模式枚举
+ */
+export const BrokerageEnabledConditionEnum = {
+  ALL: {
+    condition: 0,
+    name: '人人分销'
+  },
+  ADMIN: {
+    condition: 1,
+    name: '指定分销'
+  }
+}
+/**
+ * 佣金记录业务类型枚举
+ */
+export const BrokerageRecordBizTypeEnum = {
+  ORDER: {
+    type: 1,
+    name: '获得推广佣金'
+  },
+  WITHDRAW: {
+    type: 2,
+    name: '提现申请'
+  }
+}
+/**
+ * 佣金提现状态枚举
+ */
+export const BrokerageWithdrawStatusEnum = {
+  AUDITING: {
+    status: 0,
+    name: '审核中'
+  },
+  AUDIT_SUCCESS: {
+    status: 10,
+    name: '审核通过'
+  },
+  AUDIT_FAIL: {
+    status: 20,
+    name: '审核不通过'
+  },
+  WITHDRAW_SUCCESS: {
+    status: 11,
+    name: '提现成功'
+  },
+  WITHDRAW_FAIL: {
+    status: 21,
+    name: '提现失败'
+  }
+}
+/**
+ * 佣金提现类型枚举
+ */
+export const BrokerageWithdrawTypeEnum = {
+  WALLET: {
+    type: 1,
+    name: '钱包'
+  },
+  BANK: {
+    type: 2,
+    name: '银行卡'
+  },
+  WECHAT: {
+    type: 3,
+    name: '微信'
+  },
+  ALIPAY: {
+    type: 4,
+    name: '支付宝'
+  }
+}

+ 7 - 0
client/contextMenu.d.ts

@@ -0,0 +1,7 @@
+export type contextMenuSchema = {
+  disabled?: boolean
+  divided?: boolean
+  icon?: string
+  label: string
+  command?: (item: contextMenuSchema) => void
+}

+ 27 - 0
client/custom-types.d.ts

@@ -0,0 +1,27 @@
+import { SlateDescendant } from '@wangeditor/editor'
+
+declare module 'slate' {
+  interface CustomTypes {
+    // 扩展 text
+    Text: {
+      text: string
+      bold?: boolean
+      italic?: boolean
+      code?: boolean
+      through?: boolean
+      underline?: boolean
+      sup?: boolean
+      sub?: boolean
+      color?: string
+      bgColor?: string
+      fontSize?: string
+      fontFamily?: string
+    }
+
+    // 扩展 Element 的 type 属性
+    Element: {
+      type: string
+      children: SlateDescendant[]
+    }
+  }
+}

+ 13 - 0
client/descriptions.d.ts

@@ -0,0 +1,13 @@
+export interface DescriptionsSchema {
+  span?: number // 占多少分
+  field: string // 字段名
+  label?: string // label名
+  width?: string | number
+  minWidth?: string | number
+  align?: 'left' | 'center' | 'right'
+  labelAlign?: 'left' | 'center' | 'right'
+  className?: string
+  labelClassName?: string
+  dateFormat?: string // add by 星语:支持时间的格式化
+  dictType?: string // add by 星语:支持 dict 字典数据
+}

+ 184 - 0
client/dict.ts

@@ -0,0 +1,184 @@
+/**
+ * 数据字典工具类
+ */
+import { useDictStoreWithOut } from '@/store/modules/dict'
+import { ElementPlusInfoType } from '@/types/elementPlus'
+
+const dictStore = useDictStoreWithOut()
+
+/**
+ * 获取 dictType 对应的数据字典数组
+ *
+ * @param dictType 数据类型
+ * @returns {*|Array} 数据字典数组
+ */
+export interface DictDataType {
+  dictType: string
+  label: string
+  value: string | number | boolean
+  colorType: ElementPlusInfoType | ''
+  cssClass: string
+}
+
+export const getDictOptions = (dictType: string) => {
+  return dictStore.getDictByType(dictType) || []
+}
+
+export const getIntDictOptions = (dictType: string) => {
+  const dictOption: DictDataType[] = []
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: parseInt(dict.value + '')
+    })
+  })
+  return dictOption
+}
+
+export const getStrDictOptions = (dictType: string) => {
+  const dictOption: DictDataType[] = []
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: dict.value + ''
+    })
+  })
+  return dictOption
+}
+
+export const getBoolDictOptions = (dictType: string) => {
+  const dictOption: DictDataType[] = []
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  dictOptions.forEach((dict: DictDataType) => {
+    dictOption.push({
+      ...dict,
+      value: dict.value + '' === 'true'
+    })
+  })
+  return dictOption
+}
+
+/**
+ * 获取指定字典类型的指定值对应的字典对象
+ * @param dictType 字典类型
+ * @param value 字典值
+ * @return DictDataType 字典对象
+ */
+export const getDictObj = (dictType: string, value: any): DictDataType | undefined => {
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  for (const dict of dictOptions) {
+    if (dict.value === value + '') {
+      return dict
+    }
+  }
+}
+
+/**
+ * 获得字典数据的文本展示
+ *
+ * @param dictType 字典类型
+ * @param value 字典数据的值
+ * @return 字典名称
+ */
+export const getDictLabel = (dictType: string, value: any): string => {
+  const dictOptions: DictDataType[] = getDictOptions(dictType)
+  const dictLabel = ref('')
+  dictOptions.forEach((dict: DictDataType) => {
+    if (dict.value === value + '') {
+      dictLabel.value = dict.label
+    }
+  })
+  return dictLabel.value
+}
+
+export enum DICT_TYPE {
+  USER_TYPE = 'user_type',
+  COMMON_STATUS = 'common_status',
+  SYSTEM_TENANT_PACKAGE_ID = 'system_tenant_package_id',
+  TERMINAL = 'terminal', // 终端
+
+  // ========== SYSTEM 模块 ==========
+  SYSTEM_USER_SEX = 'system_user_sex',
+  SYSTEM_MENU_TYPE = 'system_menu_type',
+  SYSTEM_ROLE_TYPE = 'system_role_type',
+  SYSTEM_DATA_SCOPE = 'system_data_scope',
+  SYSTEM_NOTICE_TYPE = 'system_notice_type',
+  SYSTEM_OPERATE_TYPE = 'system_operate_type',
+  SYSTEM_LOGIN_TYPE = 'system_login_type',
+  SYSTEM_LOGIN_RESULT = 'system_login_result',
+  SYSTEM_SMS_CHANNEL_CODE = 'system_sms_channel_code',
+  SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type',
+  SYSTEM_SMS_SEND_STATUS = 'system_sms_send_status',
+  SYSTEM_SMS_RECEIVE_STATUS = 'system_sms_receive_status',
+  SYSTEM_ERROR_CODE_TYPE = 'system_error_code_type',
+  SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
+  SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
+  SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
+
+  // ========== INFRA 模块 ==========
+  INFRA_BOOLEAN_STRING = 'infra_boolean_string',
+  INFRA_JOB_STATUS = 'infra_job_status',
+  INFRA_JOB_LOG_STATUS = 'infra_job_log_status',
+  INFRA_API_ERROR_LOG_PROCESS_STATUS = 'infra_api_error_log_process_status',
+  INFRA_CONFIG_TYPE = 'infra_config_type',
+  INFRA_CODEGEN_TEMPLATE_TYPE = 'infra_codegen_template_type',
+  INFRA_CODEGEN_FRONT_TYPE = 'infra_codegen_front_type',
+  INFRA_CODEGEN_SCENE = 'infra_codegen_scene',
+  INFRA_FILE_STORAGE = 'infra_file_storage',
+
+  // ========== BPM 模块 ==========
+  BPM_MODEL_CATEGORY = 'bpm_model_category',
+  BPM_MODEL_FORM_TYPE = 'bpm_model_form_type',
+  BPM_TASK_ASSIGN_RULE_TYPE = 'bpm_task_assign_rule_type',
+  BPM_PROCESS_INSTANCE_STATUS = 'bpm_process_instance_status',
+  BPM_PROCESS_INSTANCE_RESULT = 'bpm_process_instance_result',
+  BPM_TASK_ASSIGN_SCRIPT = 'bpm_task_assign_script',
+  BPM_OA_LEAVE_TYPE = 'bpm_oa_leave_type',
+
+  // ========== PAY 模块 ==========
+  PAY_CHANNEL_CODE = 'pay_channel_code', // 支付渠道编码类型
+  PAY_ORDER_STATUS = 'pay_order_status', // 商户支付订单状态
+  PAY_REFUND_STATUS = 'pay_refund_status', // 退款订单状态
+  PAY_NOTIFY_STATUS = 'pay_notify_status', // 商户支付回调状态
+  PAY_NOTIFY_TYPE = 'pay_notify_type', // 商户支付回调状态
+
+  // ========== MP 模块 ==========
+  MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
+  MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
+
+  // ========== MALL - 会员模块 ==========
+  MEMBER_POINT_BIZ_TYPE = 'member_point_biz_type', // 积分的业务类型
+  MEMBER_EXPERIENCE_BIZ_TYPE = 'member_experience_biz_type', // 会员经验业务类型
+
+  // ========== MALL - 商品模块 ==========
+  PRODUCT_UNIT = 'product_unit', // 商品单位
+  PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态
+
+  // ========== MALL - 交易模块 ==========
+  EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode', //快递的计费方式
+  TRADE_AFTER_SALE_STATUS = 'trade_after_sale_status', // 售后 - 状态
+  TRADE_AFTER_SALE_WAY = 'trade_after_sale_way', // 售后 - 方式
+  TRADE_AFTER_SALE_TYPE = 'trade_after_sale_type', // 售后 - 类型
+  TRADE_ORDER_TYPE = 'trade_order_type', // 订单 - 类型
+  TRADE_ORDER_STATUS = 'trade_order_status', // 订单 - 状态
+  TRADE_ORDER_ITEM_AFTER_SALE_STATUS = 'trade_order_item_after_sale_status', // 订单项 - 售后状态
+  TRADE_DELIVERY_TYPE = 'trade_delivery_type', // 配送方式
+  BROKERAGE_ENABLED_CONDITION = 'brokerage_enabled_condition', // 分佣模式
+  BROKERAGE_BIND_MODE = 'brokerage_bind_mode', // 分销关系绑定模式
+  BROKERAGE_BANK_NAME = 'brokerage_bank_name', // 佣金提现银行
+  BROKERAGE_WITHDRAW_TYPE = 'brokerage_withdraw_type', // 佣金提现类型
+  BROKERAGE_RECORD_BIZ_TYPE = 'brokerage_record_biz_type', // 佣金业务类型
+  BROKERAGE_RECORD_STATUS = 'brokerage_record_status', // 佣金状态
+  BROKERAGE_WITHDRAW_STATUS = 'brokerage_withdraw_status', // 佣金提现状态
+
+  // ========== MALL - 营销模块 ==========
+  PROMOTION_DISCOUNT_TYPE = 'promotion_discount_type', // 优惠类型
+  PROMOTION_PRODUCT_SCOPE = 'promotion_product_scope', // 营销的商品范围
+  PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型
+  PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态
+  PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式
+  PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态
+  PROMOTION_CONDITION_TYPE = 'promotion_condition_type' // 营销的条件类型枚举
+}

+ 289 - 0
client/domUtils.ts

@@ -0,0 +1,289 @@
+import { isServer } from './is'
+const ieVersion = isServer ? 0 : Number((document as any).documentMode)
+const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g
+const MOZ_HACK_REGEXP = /^moz([A-Z])/
+
+export interface ViewportOffsetResult {
+  left: number
+  top: number
+  right: number
+  bottom: number
+  rightIncludeBody: number
+  bottomIncludeBody: number
+}
+
+/* istanbul ignore next */
+const trim = function (string: string) {
+  return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
+}
+
+/* istanbul ignore next */
+const camelCase = function (name: string) {
+  return name
+    .replace(SPECIAL_CHARS_REGEXP, function (_, __, letter, offset) {
+      return offset ? letter.toUpperCase() : letter
+    })
+    .replace(MOZ_HACK_REGEXP, 'Moz$1')
+}
+
+/* istanbul ignore next */
+export function hasClass(el: Element, cls: string) {
+  if (!el || !cls) return false
+  if (cls.indexOf(' ') !== -1) {
+    throw new Error('className should not contain space.')
+  }
+  if (el.classList) {
+    return el.classList.contains(cls)
+  } else {
+    return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1
+  }
+}
+
+/* istanbul ignore next */
+export function addClass(el: Element, cls: string) {
+  if (!el) return
+  let curClass = el.className
+  const classes = (cls || '').split(' ')
+
+  for (let i = 0, j = classes.length; i < j; i++) {
+    const clsName = classes[i]
+    if (!clsName) continue
+
+    if (el.classList) {
+      el.classList.add(clsName)
+    } else if (!hasClass(el, clsName)) {
+      curClass += ' ' + clsName
+    }
+  }
+  if (!el.classList) {
+    el.className = curClass
+  }
+}
+
+/* istanbul ignore next */
+export function removeClass(el: Element, cls: string) {
+  if (!el || !cls) return
+  const classes = cls.split(' ')
+  let curClass = ' ' + el.className + ' '
+
+  for (let i = 0, j = classes.length; i < j; i++) {
+    const clsName = classes[i]
+    if (!clsName) continue
+
+    if (el.classList) {
+      el.classList.remove(clsName)
+    } else if (hasClass(el, clsName)) {
+      curClass = curClass.replace(' ' + clsName + ' ', ' ')
+    }
+  }
+  if (!el.classList) {
+    el.className = trim(curClass)
+  }
+}
+
+export function getBoundingClientRect(element: Element): DOMRect | number {
+  if (!element || !element.getBoundingClientRect) {
+    return 0
+  }
+  return element.getBoundingClientRect()
+}
+
+/**
+ * 获取当前元素的left、top偏移
+ *   left:元素最左侧距离文档左侧的距离
+ *   top:元素最顶端距离文档顶端的距离
+ *   right:元素最右侧距离文档右侧的距离
+ *   bottom:元素最底端距离文档底端的距离
+ *   rightIncludeBody:元素最左侧距离文档右侧的距离
+ *   bottomIncludeBody:元素最底端距离文档最底部的距离
+ *
+ * @description:
+ */
+export function getViewportOffset(element: Element): ViewportOffsetResult {
+  const doc = document.documentElement
+
+  const docScrollLeft = doc.scrollLeft
+  const docScrollTop = doc.scrollTop
+  const docClientLeft = doc.clientLeft
+  const docClientTop = doc.clientTop
+
+  const pageXOffset = window.pageXOffset
+  const pageYOffset = window.pageYOffset
+
+  const box = getBoundingClientRect(element)
+
+  const { left: retLeft, top: rectTop, width: rectWidth, height: rectHeight } = box as DOMRect
+
+  const scrollLeft = (pageXOffset || docScrollLeft) - (docClientLeft || 0)
+  const scrollTop = (pageYOffset || docScrollTop) - (docClientTop || 0)
+  const offsetLeft = retLeft + pageXOffset
+  const offsetTop = rectTop + pageYOffset
+
+  const left = offsetLeft - scrollLeft
+  const top = offsetTop - scrollTop
+
+  const clientWidth = window.document.documentElement.clientWidth
+  const clientHeight = window.document.documentElement.clientHeight
+  return {
+    left: left,
+    top: top,
+    right: clientWidth - rectWidth - left,
+    bottom: clientHeight - rectHeight - top,
+    rightIncludeBody: clientWidth - left,
+    bottomIncludeBody: clientHeight - top
+  }
+}
+
+/* istanbul ignore next */
+export const on = function (
+  element: HTMLElement | Document | Window,
+  event: string,
+  handler: EventListenerOrEventListenerObject
+): void {
+  if (element && event && handler) {
+    element.addEventListener(event, handler, false)
+  }
+}
+
+/* istanbul ignore next */
+export const off = function (
+  element: HTMLElement | Document | Window,
+  event: string,
+  handler: any
+): void {
+  if (element && event && handler) {
+    element.removeEventListener(event, handler, false)
+  }
+}
+
+/* istanbul ignore next */
+export const once = function (el: HTMLElement, event: string, fn: EventListener): void {
+  const listener = function (this: any, ...args: unknown[]) {
+    if (fn) {
+      // @ts-ignore
+      fn.apply(this, args)
+    }
+    off(el, event, listener)
+  }
+  on(el, event, listener)
+}
+
+/* istanbul ignore next */
+export const getStyle =
+  ieVersion < 9
+    ? function (element: Element | any, styleName: string) {
+        if (isServer) return
+        if (!element || !styleName) return null
+        styleName = camelCase(styleName)
+        if (styleName === 'float') {
+          styleName = 'styleFloat'
+        }
+        try {
+          switch (styleName) {
+            case 'opacity':
+              try {
+                return element.filters.item('alpha').opacity / 100
+              } catch (e) {
+                return 1.0
+              }
+            default:
+              return element.style[styleName] || element.currentStyle
+                ? element.currentStyle[styleName]
+                : null
+          }
+        } catch (e) {
+          return element.style[styleName]
+        }
+      }
+    : function (element: Element | any, styleName: string) {
+        if (isServer) return
+        if (!element || !styleName) return null
+        styleName = camelCase(styleName)
+        if (styleName === 'float') {
+          styleName = 'cssFloat'
+        }
+        try {
+          const computed = (document as any).defaultView.getComputedStyle(element, '')
+          return element.style[styleName] || computed ? computed[styleName] : null
+        } catch (e) {
+          return element.style[styleName]
+        }
+      }
+
+/* istanbul ignore next */
+export function setStyle(element: Element | any, styleName: any, value: any) {
+  if (!element || !styleName) return
+
+  if (typeof styleName === 'object') {
+    for (const prop in styleName) {
+      if (Object.prototype.hasOwnProperty.call(styleName, prop)) {
+        setStyle(element, prop, styleName[prop])
+      }
+    }
+  } else {
+    styleName = camelCase(styleName)
+    if (styleName === 'opacity' && ieVersion < 9) {
+      element.style.filter = isNaN(value) ? '' : 'alpha(opacity=' + value * 100 + ')'
+    } else {
+      element.style[styleName] = value
+    }
+  }
+}
+
+/* istanbul ignore next */
+export const isScroll = (el: Element, vertical: any) => {
+  if (isServer) return
+
+  const determinedDirection = vertical !== null || vertical !== undefined
+  const overflow = determinedDirection
+    ? vertical
+      ? getStyle(el, 'overflow-y')
+      : getStyle(el, 'overflow-x')
+    : getStyle(el, 'overflow')
+
+  return overflow.match(/(scroll|auto)/)
+}
+
+/* istanbul ignore next */
+export const getScrollContainer = (el: Element, vertical?: any) => {
+  if (isServer) return
+
+  let parent: any = el
+  while (parent) {
+    if ([window, document, document.documentElement].includes(parent)) {
+      return window
+    }
+    if (isScroll(parent, vertical)) {
+      return parent
+    }
+    parent = parent.parentNode
+  }
+
+  return parent
+}
+
+/* istanbul ignore next */
+export const isInContainer = (el: Element, container: any) => {
+  if (isServer || !el || !container) return false
+
+  const elRect = el.getBoundingClientRect()
+  let containerRect
+
+  if ([window, document, document.documentElement, null, undefined].includes(container)) {
+    containerRect = {
+      top: 0,
+      right: window.innerWidth,
+      bottom: window.innerHeight,
+      left: 0
+    }
+  } else {
+    containerRect = container.getBoundingClientRect()
+  }
+
+  return (
+    elRect.top < containerRect.bottom &&
+    elRect.bottom > containerRect.top &&
+    elRect.right > containerRect.left &&
+    elRect.left < containerRect.right
+  )
+}

+ 38 - 0
client/download.ts

@@ -0,0 +1,38 @@
+const download0 = (data: Blob, fileName: string, mineType: string) => {
+  // 创建 blob
+  const blob = new Blob([data], { type: mineType })
+  // 创建 href 超链接,点击进行下载
+  window.URL = window.URL || window.webkitURL
+  const href = URL.createObjectURL(blob)
+  const downA = document.createElement('a')
+  downA.href = href
+  downA.download = fileName
+  downA.click()
+  // 销毁超连接
+  window.URL.revokeObjectURL(href)
+}
+
+const download = {
+  // 下载 Excel 方法
+  excel: (data: Blob, fileName: string) => {
+    download0(data, fileName, 'application/vnd.ms-excel')
+  },
+  // 下载 Word 方法
+  word: (data: Blob, fileName: string) => {
+    download0(data, fileName, 'application/msword')
+  },
+  // 下载 Zip 方法
+  zip: (data: Blob, fileName: string) => {
+    download0(data, fileName, 'application/zip')
+  },
+  // 下载 Html 方法
+  html: (data: Blob, fileName: string) => {
+    download0(data, fileName, 'text/html')
+  },
+  // 下载 Markdown 方法
+  markdown: (data: Blob, fileName: string) => {
+    download0(data, fileName, 'text/markdown')
+  }
+}
+
+export default download

+ 308 - 0
client/echarts-data.ts

@@ -0,0 +1,308 @@
+import { EChartsOption } from 'echarts'
+
+const { t } = useI18n()
+
+export const lineOptions: EChartsOption = {
+  title: {
+    text: t('analysis.monthlySales'),
+    left: 'center'
+  },
+  xAxis: {
+    data: [
+      t('analysis.january'),
+      t('analysis.february'),
+      t('analysis.march'),
+      t('analysis.april'),
+      t('analysis.may'),
+      t('analysis.june'),
+      t('analysis.july'),
+      t('analysis.august'),
+      t('analysis.september'),
+      t('analysis.october'),
+      t('analysis.november'),
+      t('analysis.december')
+    ],
+    boundaryGap: false,
+    axisTick: {
+      show: false
+    }
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    top: 80,
+    containLabel: true
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'cross'
+    },
+    padding: [5, 10]
+  },
+  yAxis: {
+    axisTick: {
+      show: false
+    }
+  },
+  legend: {
+    data: [t('analysis.estimate'), t('analysis.actual')],
+    top: 50
+  },
+  series: [
+    {
+      name: t('analysis.estimate'),
+      smooth: true,
+      type: 'line',
+      data: [100, 120, 161, 134, 105, 160, 165, 114, 163, 185, 118, 123],
+      animationDuration: 2800,
+      animationEasing: 'cubicInOut'
+    },
+    {
+      name: t('analysis.actual'),
+      smooth: true,
+      type: 'line',
+      itemStyle: {},
+      data: [120, 82, 91, 154, 162, 140, 145, 250, 134, 56, 99, 123],
+      animationDuration: 2800,
+      animationEasing: 'quadraticOut'
+    }
+  ]
+}
+
+export const pieOptions: EChartsOption = {
+  title: {
+    text: t('analysis.userAccessSource'),
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item',
+    formatter: '{a} <br/>{b} : {c} ({d}%)'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left',
+    data: [
+      t('analysis.directAccess'),
+      t('analysis.mailMarketing'),
+      t('analysis.allianceAdvertising'),
+      t('analysis.videoAdvertising'),
+      t('analysis.searchEngines')
+    ]
+  },
+  series: [
+    {
+      name: t('analysis.userAccessSource'),
+      type: 'pie',
+      radius: '55%',
+      center: ['50%', '60%'],
+      data: [
+        { value: 335, name: t('analysis.directAccess') },
+        { value: 310, name: t('analysis.mailMarketing') },
+        { value: 234, name: t('analysis.allianceAdvertising') },
+        { value: 135, name: t('analysis.videoAdvertising') },
+        { value: 1548, name: t('analysis.searchEngines') }
+      ]
+    }
+  ]
+}
+
+export const barOptions: EChartsOption = {
+  title: {
+    text: t('analysis.weeklyUserActivity'),
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  grid: {
+    left: 50,
+    right: 20,
+    bottom: 20
+  },
+  xAxis: {
+    type: 'category',
+    data: [
+      t('analysis.monday'),
+      t('analysis.tuesday'),
+      t('analysis.wednesday'),
+      t('analysis.thursday'),
+      t('analysis.friday'),
+      t('analysis.saturday'),
+      t('analysis.sunday')
+    ],
+    axisTick: {
+      alignWithLabel: true
+    }
+  },
+  yAxis: {
+    type: 'value'
+  },
+  series: [
+    {
+      name: t('analysis.activeQuantity'),
+      data: [13253, 34235, 26321, 12340, 24643, 1322, 1324],
+      type: 'bar'
+    }
+  ]
+}
+
+export const radarOption: EChartsOption = {
+  legend: {
+    data: [t('workplace.personal'), t('workplace.team')]
+  },
+  radar: {
+    // shape: 'circle',
+    indicator: [
+      { name: t('workplace.quote'), max: 65 },
+      { name: t('workplace.contribution'), max: 160 },
+      { name: t('workplace.hot'), max: 300 },
+      { name: t('workplace.yield'), max: 130 },
+      { name: t('workplace.follow'), max: 100 }
+    ]
+  },
+  series: [
+    {
+      name: `xxx${t('workplace.index')}`,
+      type: 'radar',
+      data: [
+        {
+          value: [42, 30, 20, 35, 80],
+          name: t('workplace.personal')
+        },
+        {
+          value: [50, 140, 290, 100, 90],
+          name: t('workplace.team')
+        }
+      ]
+    }
+  ]
+}
+
+export const wordOptions = {
+  series: [
+    {
+      type: 'wordCloud',
+      gridSize: 2,
+      sizeRange: [12, 50],
+      rotationRange: [-90, 90],
+      shape: 'pentagon',
+      width: 600,
+      height: 400,
+      drawOutOfBound: true,
+      textStyle: {
+        color: function () {
+          return (
+            'rgb(' +
+            [
+              Math.round(Math.random() * 160),
+              Math.round(Math.random() * 160),
+              Math.round(Math.random() * 160)
+            ].join(',') +
+            ')'
+          )
+        }
+      },
+      emphasis: {
+        textStyle: {
+          shadowBlur: 10,
+          shadowColor: '#333'
+        }
+      },
+      data: [
+        {
+          name: 'Sam S Club',
+          value: 10000,
+          textStyle: {
+            color: 'black'
+          },
+          emphasis: {
+            textStyle: {
+              color: 'red'
+            }
+          }
+        },
+        {
+          name: 'Macys',
+          value: 6181
+        },
+        {
+          name: 'Amy Schumer',
+          value: 4386
+        },
+        {
+          name: 'Jurassic World',
+          value: 4055
+        },
+        {
+          name: 'Charter Communications',
+          value: 2467
+        },
+        {
+          name: 'Chick Fil A',
+          value: 2244
+        },
+        {
+          name: 'Planet Fitness',
+          value: 1898
+        },
+        {
+          name: 'Pitch Perfect',
+          value: 1484
+        },
+        {
+          name: 'Express',
+          value: 1112
+        },
+        {
+          name: 'Home',
+          value: 965
+        },
+        {
+          name: 'Johnny Depp',
+          value: 847
+        },
+        {
+          name: 'Lena Dunham',
+          value: 582
+        },
+        {
+          name: 'Lewis Hamilton',
+          value: 555
+        },
+        {
+          name: 'KXAN',
+          value: 550
+        },
+        {
+          name: 'Mary Ellen Mark',
+          value: 462
+        },
+        {
+          name: 'Farrah Abraham',
+          value: 366
+        },
+        {
+          name: 'Rita Ora',
+          value: 360
+        },
+        {
+          name: 'Serena Williams',
+          value: 282
+        },
+        {
+          name: 'NCAA baseball tournament',
+          value: 273
+        },
+        {
+          name: 'Point Break',
+          value: 265
+        }
+      ]
+    }
+  ]
+}

+ 3 - 0
client/elementPlus.d.ts

@@ -0,0 +1,3 @@
+export type ElementPlusSize = 'default' | 'small' | 'large'
+
+export type ElementPlusInfoType = 'success' | 'info' | 'warning' | 'danger'

+ 447 - 0
client/en.ts

@@ -0,0 +1,447 @@
+export default {
+  common: {
+    inputText: 'Please input',
+    selectText: 'Please select',
+    startTimeText: 'Start time',
+    endTimeText: 'End time',
+    login: 'Login',
+    required: 'This is required',
+    loginOut: 'Login out',
+    document: 'Document',
+    profile: 'User Center',
+    reminder: 'Reminder',
+    loginOutMessage: 'Exit the system?',
+    back: 'Back',
+    ok: 'OK',
+    save: 'Save',
+    cancel: 'Cancel',
+    close: 'Close',
+    reload: 'Reload current',
+    success: 'Success',
+    closeTab: 'Close current',
+    closeTheLeftTab: 'Close left',
+    closeTheRightTab: 'Close right',
+    closeOther: 'Close other',
+    closeAll: 'Close all',
+    prevLabel: 'Prev',
+    nextLabel: 'Next',
+    skipLabel: 'Jump',
+    doneLabel: 'End',
+    menu: 'Menu',
+    menuDes: 'Menu bar rendered in routed structure',
+    collapse: 'Collapse',
+    collapseDes: 'Expand and zoom the menu bar',
+    tagsView: 'Tags view',
+    tagsViewDes: 'Used to record routing history',
+    tool: 'Tool',
+    toolDes: 'Used to set up custom systems',
+    query: 'Query',
+    reset: 'Reset',
+    shrink: 'Put away',
+    expand: 'Expand',
+    confirmTitle: 'System Hint',
+    exportMessage: 'Whether to confirm export data item?',
+    importMessage: 'Whether to confirm import data item?',
+    createSuccess: 'Create Success',
+    updateSuccess: 'Update Success',
+    delMessage: 'Delete the selected data?',
+    delDataMessage: 'Delete the data?',
+    delNoData: 'Please select the data to delete',
+    delSuccess: 'Deleted successfully',
+    index: 'Index',
+    status: 'Status',
+    createTime: 'Create Time',
+    updateTime: 'Update Time',
+    copy: 'Copy',
+    copySuccess: 'Copy Success',
+    copyError: 'Copy Error'
+  },
+  error: {
+    noPermission: `Sorry, you don't have permission to access this page.`,
+    pageError: 'Sorry, the page you visited does not exist.',
+    networkError: 'Sorry, the server reported an error.',
+    returnToHome: 'Return to home'
+  },
+  permission: {
+    hasPermission: `Please set the operation permission label value`,
+    hasRole: `Please set the role permission tag value`
+  },
+  setting: {
+    projectSetting: 'Project setting',
+    theme: 'Theme',
+    layout: 'Layout',
+    systemTheme: 'System theme',
+    menuTheme: 'Menu theme',
+    interfaceDisplay: 'Interface display',
+    breadcrumb: 'Breadcrumb',
+    breadcrumbIcon: 'Breadcrumb icon',
+    collapseMenu: 'Collapse menu',
+    hamburgerIcon: 'Hamburger icon',
+    screenfullIcon: 'Screenfull icon',
+    sizeIcon: 'Size icon',
+    localeIcon: 'Locale icon',
+    messageIcon: 'Message icon',
+    tagsView: 'Tags view',
+    logo: 'Logo',
+    greyMode: 'Grey mode',
+    fixedHeader: 'Fixed header',
+    headerTheme: 'Header theme',
+    cutMenu: 'Cut Menu',
+    copy: 'Copy',
+    clearAndReset: 'Clear cache and reset',
+    copySuccess: 'Copy success',
+    copyFailed: 'Copy failed',
+    footer: 'Footer',
+    uniqueOpened: 'Unique opened',
+    tagsViewIcon: 'Tags view icon',
+    reExperienced: 'Please exit the login experience again',
+    fixedMenu: 'Fixed menu'
+  },
+  size: {
+    default: 'Default',
+    large: 'Large',
+    small: 'Small'
+  },
+  login: {
+    welcome: 'Welcome to the system',
+    message: 'Backstage management system',
+    tenantname: 'TenantName',
+    username: 'Username',
+    password: 'Password',
+    code: 'verification code',
+    login: 'Sign in',
+    relogin: 'Sign in again',
+    otherLogin: 'Sign in with',
+    register: 'Register',
+    checkPassword: 'Confirm password',
+    remember: 'Remember me',
+    hasUser: 'Existing account? Go to login',
+    forgetPassword: 'Forget password?',
+    tenantNamePlaceholder: 'Please Enter Tenant Name',
+    usernamePlaceholder: 'Please Enter Username',
+    passwordPlaceholder: 'Please Enter Password',
+    codePlaceholder: 'Please Enter Verification Code',
+    mobileTitle: 'Mobile sign in',
+    mobileNumber: 'Mobile Number',
+    mobileNumberPlaceholder: 'Plaease Enter Mobile Number',
+    backLogin: 'back',
+    getSmsCode: 'Get SMS Code',
+    btnMobile: 'Mobile sign in',
+    btnQRCode: 'QR code sign in',
+    qrcode: 'Scan the QR code to log in',
+    btnRegister: 'Sign up',
+    SmsSendMsg: 'code has been sent'
+  },
+  captcha: {
+    verification: 'Please complete security verification',
+    slide: 'Swipe right to complete verification',
+    point: 'Please click',
+    success: 'Verification succeeded',
+    fail: 'verification failed'
+  },
+  router: {
+    login: 'Login',
+    home: 'Home',
+    analysis: 'Analysis',
+    workplace: 'Workplace'
+  },
+  analysis: {
+    newUser: 'New user',
+    unreadInformation: 'Unread information',
+    transactionAmount: 'Transaction amount',
+    totalShopping: 'Total Shopping',
+    monthlySales: 'Monthly sales',
+    userAccessSource: 'User access source',
+    january: 'January',
+    february: 'February',
+    march: 'March',
+    april: 'April',
+    may: 'May',
+    june: 'June',
+    july: 'July',
+    august: 'August',
+    september: 'September',
+    october: 'October',
+    november: 'November',
+    december: 'December',
+    estimate: 'Estimate',
+    actual: 'Actual',
+    directAccess: 'Airect access',
+    mailMarketing: 'Mail marketing',
+    allianceAdvertising: 'Alliance advertising',
+    videoAdvertising: 'Video advertising',
+    searchEngines: 'Search engines',
+    weeklyUserActivity: 'Weekly user activity',
+    activeQuantity: 'Active quantity',
+    monday: 'Monday',
+    tuesday: 'Tuesday',
+    wednesday: 'Wednesday',
+    thursday: 'Thursday',
+    friday: 'Friday',
+    saturday: 'Saturday',
+    sunday: 'Sunday'
+  },
+  workplace: {
+    welcome: 'Hello',
+    happyDay: 'Wish you happy every day!',
+    toady: `It's sunny today`,
+    notice: 'Announcement',
+    project: 'Project',
+    access: 'Project access',
+    toDo: 'To do',
+    introduction: 'A serious introduction',
+    shortcutOperation: 'Quick entry',
+    operation: 'Operation',
+    index: 'Index',
+    personal: 'Personal',
+    team: 'Team',
+    quote: 'Quote',
+    contribution: 'Contribution',
+    hot: 'Hot',
+    yield: 'Yield',
+    dynamic: 'Dynamic',
+    push: 'push',
+    follow: 'Follow'
+  },
+  form: {
+    input: 'Input',
+    inputNumber: 'InputNumber',
+    default: 'Default',
+    icon: 'Icon',
+    mixed: 'Mixed',
+    textarea: 'Textarea',
+    slot: 'Slot',
+    position: 'Position',
+    autocomplete: 'Autocomplete',
+    select: 'Select',
+    selectGroup: 'Select Group',
+    selectV2: 'SelectV2',
+    cascader: 'Cascader',
+    switch: 'Switch',
+    rate: 'Rate',
+    colorPicker: 'Color Picker',
+    transfer: 'Transfer',
+    render: 'Render',
+    radio: 'Radio',
+    button: 'Button',
+    checkbox: 'Checkbox',
+    slider: 'Slider',
+    datePicker: 'Date Picker',
+    shortcuts: 'Shortcuts',
+    today: 'Today',
+    yesterday: 'Yesterday',
+    aWeekAgo: 'A week ago',
+    week: 'Week',
+    year: 'Year',
+    month: 'Month',
+    dates: 'Dates',
+    daterange: 'Date Range',
+    monthrange: 'Month Range',
+    dateTimePicker: 'DateTimePicker',
+    dateTimerange: 'Datetime Range',
+    timePicker: 'Time Picker',
+    timeSelect: 'Time Select',
+    inputPassword: 'input Password',
+    passwordStrength: 'Password Strength',
+    operate: 'operate',
+    change: 'Change',
+    restore: 'Restore',
+    disabled: 'Disabled',
+    disablement: 'Disablement',
+    delete: 'Delete',
+    add: 'Add',
+    setValue: 'Set value',
+    resetValue: 'Reset value',
+    set: 'Set',
+    subitem: 'Subitem',
+    formValidation: 'Form validation',
+    verifyReset: 'Verify reset',
+    remark: 'Remark'
+  },
+  watermark: {
+    watermark: 'Watermark'
+  },
+  table: {
+    table: 'Table',
+    index: 'Index',
+    title: 'Title',
+    author: 'Author',
+    createTime: 'Create time',
+    action: 'Action',
+    pagination: 'pagination',
+    reserveIndex: 'Reserve index',
+    restoreIndex: 'Restore index',
+    showSelections: 'Show selections',
+    hiddenSelections: 'Restore selections',
+    showExpandedRows: 'Show expanded rows',
+    hiddenExpandedRows: 'Hidden expanded rows',
+    header: 'Header'
+  },
+  action: {
+    create: 'Create',
+    add: 'Add',
+    del: 'Delete',
+    delete: 'Delete',
+    edit: 'Edit',
+    update: 'Update',
+    preview: 'Preview',
+    more: 'More',
+    sync: 'Sync',
+    save: 'Save',
+    detail: 'Detail',
+    export: 'Export',
+    import: 'Import',
+    generate: 'Generate',
+    logout: 'Login Out',
+    test: 'Test',
+    typeCreate: 'Dict Type Create',
+    typeUpdate: 'Dict Type Eidt',
+    dataCreate: 'Dict Data Create',
+    dataUpdate: 'Dict Data Eidt',
+    fileUpload: 'File Upload'
+  },
+  dialog: {
+    dialog: 'Dialog',
+    open: 'Open',
+    close: 'Close'
+  },
+  sys: {
+    api: {
+      operationFailed: 'Operation failed',
+      errorTip: 'Error Tip',
+      errorMessage: 'The operation failed, the system is abnormal!',
+      timeoutMessage: 'Login timed out, please log in again!',
+      apiTimeoutMessage: 'The interface request timed out, please refresh the page and try again!',
+      apiRequestFailed: 'The interface request failed, please try again later!',
+      networkException: 'network anomaly',
+      networkExceptionMsg:
+        'Please check if your network connection is normal! The network is abnormal',
+
+      errMsg401: 'The user does not have permission (token, user name, password error)!',
+      errMsg403: 'The user is authorized, but access is forbidden!',
+      errMsg404: 'Network request error, the resource was not found!',
+      errMsg405: 'Network request error, request method not allowed!',
+      errMsg408: 'Network request timed out!',
+      errMsg500: 'Server error, please contact the administrator!',
+      errMsg501: 'The network is not implemented!',
+      errMsg502: 'Network Error!',
+      errMsg503: 'The service is unavailable, the server is temporarily overloaded or maintained!',
+      errMsg504: 'Network timeout!',
+      errMsg505: 'The http version does not support the request!',
+      errMsg901: 'Demo mode, no write operations are possible!'
+    },
+    app: {
+      logoutTip: 'Reminder',
+      logoutMessage: 'Confirm to exit the system?',
+      menuLoading: 'Menu loading...'
+    },
+    exception: {
+      backLogin: 'Back Login',
+      backHome: 'Back Home',
+      subTitle403: "Sorry, you don't have access to this page.",
+      subTitle404: 'Sorry, the page you visited does not exist.',
+      subTitle500: 'Sorry, the server is reporting an error.',
+      noDataTitle: 'No data on the current page.',
+      networkErrorTitle: 'Network Error',
+      networkErrorSubTitle:
+        'Sorry, Your network connection has been disconnected, please check your network!'
+    },
+    lock: {
+      unlock: 'Click to unlock',
+      alert: 'Lock screen password error',
+      backToLogin: 'Back to login',
+      entry: 'Enter the system',
+      placeholder: 'Please enter the lock screen password or user password'
+    },
+    login: {
+      backSignIn: 'Back sign in',
+      mobileSignInFormTitle: 'Mobile sign in',
+      qrSignInFormTitle: 'Qr code sign in',
+      signInFormTitle: 'Sign in',
+      signUpFormTitle: 'Sign up',
+      forgetFormTitle: 'Reset password',
+
+      signInTitle: 'Backstage management system',
+      signInDesc: 'Enter your personal details and get started!',
+      policy: 'I agree to the xxx Privacy Policy',
+      scanSign: `scanning the code to complete the login`,
+
+      loginButton: 'Sign in',
+      registerButton: 'Sign up',
+      rememberMe: 'Remember me',
+      forgetPassword: 'Forget Password?',
+      otherSignIn: 'Sign in with',
+
+      // notify
+      loginSuccessTitle: 'Login successful',
+      loginSuccessDesc: 'Welcome back',
+
+      // placeholder
+      accountPlaceholder: 'Please input username',
+      passwordPlaceholder: 'Please input password',
+      smsPlaceholder: 'Please input sms code',
+      mobilePlaceholder: 'Please input mobile',
+      policyPlaceholder: 'Register after checking',
+      diffPwd: 'The two passwords are inconsistent',
+
+      userName: 'Username',
+      password: 'Password',
+      confirmPassword: 'Confirm Password',
+      email: 'Email',
+      smsCode: 'SMS code',
+      mobile: 'Mobile'
+    }
+  },
+  profile: {
+    user: {
+      title: 'Personal Information',
+      username: 'User Name',
+      nickname: 'Nick Name',
+      mobile: 'Phone Number',
+      email: 'User Mail',
+      dept: 'Department',
+      posts: 'Position',
+      roles: 'Own Role',
+      sex: 'Sex',
+      man: 'Man',
+      woman: 'Woman',
+      createTime: 'Created Date'
+    },
+    info: {
+      title: 'Basic Information',
+      basicInfo: 'Basic Information',
+      resetPwd: 'Reset Password',
+      userSocial: 'Social Information'
+    },
+    rules: {
+      nickname: 'Please Enter User Nickname',
+      mail: 'Please Input The Email Address',
+      truemail: 'Please Input The Correct Email Address',
+      phone: 'Please Enter The Phone Number',
+      truephone: 'Please Enter The Correct Phone Number'
+    },
+    password: {
+      oldPassword: 'Old PassWord',
+      newPassword: 'New Password',
+      confirmPassword: 'Confirm Password',
+      oldPwdMsg: 'Please Enter Old Password',
+      newPwdMsg: 'Please Enter New Password',
+      cfPwdMsg: 'Please Enter Confirm Password',
+      diffPwd: 'The Passwords Entered Twice No Match'
+    }
+  },
+  cropper: {
+    selectImage: 'Select Image',
+    uploadSuccess: 'Uploaded success!',
+    modalTitle: 'Avatar upload',
+    okText: 'Confirm and upload',
+    btn_reset: 'Reset',
+    btn_rotate_left: 'Counterclockwise rotation',
+    btn_rotate_right: 'Clockwise rotation',
+    btn_scale_x: 'Flip horizontal',
+    btn_scale_y: 'Flip vertical',
+    btn_zoom_in: 'Zoom in',
+    btn_zoom_out: 'Zoom out',
+    preview: 'Preivew'
+  }
+}

+ 32 - 0
client/env.d.ts

@@ -0,0 +1,32 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import { DefineComponent } from 'vue'
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}
+
+interface ImportMetaEnv {
+  readonly VITE_APP_TITLE: string
+  readonly VITE_PORT: number
+  readonly VITE_OPEN: string
+  readonly VITE_DEV: string
+  readonly VITE_APP_CAPTCHA_ENABLE: string
+  readonly VITE_APP_TENANT_ENABLE: string
+  readonly VITE_BASE_URL: string
+  readonly VITE_UPLOAD_URL: string
+  readonly VITE_API_BASEPATH: string
+  readonly VITE_API_URL: string
+  readonly VITE_BASE_PATH: string
+  readonly VITE_DROP_DEBUGGER: string
+  readonly VITE_DROP_CONSOLE: string
+  readonly VITE_SOURCEMAP: string
+  readonly VITE_OUT_DIR: string
+}
+
+declare global {
+  interface ImportMeta {
+    readonly env: ImportMetaEnv
+  }
+}

+ 6 - 0
client/errorCode.ts

@@ -0,0 +1,6 @@
+export default {
+  '401': '认证失败,无法访问系统资源',
+  '403': '当前操作没有权限',
+  '404': '访问资源不存在',
+  default: '系统未知错误,请反馈给管理员'
+}

+ 19 - 0
client/extensions.json

@@ -0,0 +1,19 @@
+{
+  "recommendations": [
+    "christian-kohler.path-intellisense",
+    "vscode-icons-team.vscode-icons",
+    "davidanson.vscode-markdownlint",
+    "stylelint.vscode-stylelint",
+    "dbaeumer.vscode-eslint",
+    "esbenp.prettier-vscode",
+    "mrmlnc.vscode-less",
+    "lokalise.i18n-ally",
+    "redhat.vscode-yaml",
+    "csstools.postcss",
+    "mikestead.dotenv",
+    "eamodio.gitlens",
+    "antfu.iconify",
+    "antfu.unocss",
+    "Vue.volar"
+  ]
+}

BIN
client/favicon.ico


+ 157 - 0
client/filt.ts

@@ -0,0 +1,157 @@
+export const openWindow = (
+  url: string,
+  opt?: {
+    target?: '_self' | '_blank' | string
+    noopener?: boolean
+    noreferrer?: boolean
+  }
+) => {
+  const { target = '__blank', noopener = true, noreferrer = true } = opt || {}
+  const feature: string[] = []
+
+  noopener && feature.push('noopener=yes')
+  noreferrer && feature.push('noreferrer=yes')
+
+  window.open(url, target, feature.join(','))
+}
+
+/**
+ * @description: base64 to blob
+ */
+export const dataURLtoBlob = (base64Buf: string): Blob => {
+  const arr = base64Buf.split(',')
+  const typeItem = arr[0]
+  const mime = typeItem.match(/:(.*?);/)![1]
+  const bstr = window.atob(arr[1])
+  let n = bstr.length
+  const u8arr = new Uint8Array(n)
+  while (n--) {
+    u8arr[n] = bstr.charCodeAt(n)
+  }
+  return new Blob([u8arr], { type: mime })
+}
+
+/**
+ * img url to base64
+ * @param url
+ */
+export const urlToBase64 = (url: string, mineType?: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    let canvas = document.createElement('CANVAS') as Nullable<HTMLCanvasElement>
+    const ctx = canvas!.getContext('2d')
+
+    const img = new Image()
+    img.crossOrigin = ''
+    img.onload = function () {
+      if (!canvas || !ctx) {
+        return reject()
+      }
+      canvas.height = img.height
+      canvas.width = img.width
+      ctx.drawImage(img, 0, 0)
+      const dataURL = canvas.toDataURL(mineType || 'image/png')
+      canvas = null
+      resolve(dataURL)
+    }
+    img.src = url
+  })
+}
+
+/**
+ * Download online pictures
+ * @param url
+ * @param filename
+ * @param mime
+ * @param bom
+ */
+export const downloadByOnlineUrl = (
+  url: string,
+  filename: string,
+  mime?: string,
+  bom?: BlobPart
+) => {
+  urlToBase64(url).then((base64) => {
+    downloadByBase64(base64, filename, mime, bom)
+  })
+}
+
+/**
+ * Download pictures based on base64
+ * @param buf
+ * @param filename
+ * @param mime
+ * @param bom
+ */
+export const downloadByBase64 = (buf: string, filename: string, mime?: string, bom?: BlobPart) => {
+  const base64Buf = dataURLtoBlob(buf)
+  downloadByData(base64Buf, filename, mime, bom)
+}
+
+/**
+ * Download according to the background interface file stream
+ * @param {*} data
+ * @param {*} filename
+ * @param {*} mime
+ * @param {*} bom
+ */
+export const downloadByData = (data: BlobPart, filename: string, mime?: string, bom?: BlobPart) => {
+  const blobData = typeof bom !== 'undefined' ? [bom, data] : [data]
+  const blob = new Blob(blobData, { type: mime || 'application/octet-stream' })
+
+  const blobURL = window.URL.createObjectURL(blob)
+  const tempLink = document.createElement('a')
+  tempLink.style.display = 'none'
+  tempLink.href = blobURL
+  tempLink.setAttribute('download', filename)
+  if (typeof tempLink.download === 'undefined') {
+    tempLink.setAttribute('target', '_blank')
+  }
+  document.body.appendChild(tempLink)
+  tempLink.click()
+  document.body.removeChild(tempLink)
+  window.URL.revokeObjectURL(blobURL)
+}
+
+/**
+ * Download file according to file address
+ * @param {*} sUrl
+ */
+export const downloadByUrl = ({
+  url,
+  target = '_blank',
+  fileName
+}: {
+  url: string
+  target?: '_self' | '_blank'
+  fileName?: string
+}): boolean => {
+  const isChrome = window.navigator.userAgent.toLowerCase().indexOf('chrome') > -1
+  const isSafari = window.navigator.userAgent.toLowerCase().indexOf('safari') > -1
+
+  if (/(iP)/g.test(window.navigator.userAgent)) {
+    console.error('Your browser does not support download!')
+    return false
+  }
+  if (isChrome || isSafari) {
+    const link = document.createElement('a')
+    link.href = url
+    link.target = target
+
+    if (link.download !== undefined) {
+      link.download = fileName || url.substring(url.lastIndexOf('/') + 1, url.length)
+    }
+
+    if (document.createEvent) {
+      const e = document.createEvent('MouseEvents')
+      e.initEvent('click', true, true)
+      link.dispatchEvent(e)
+      return true
+    }
+  }
+  if (url.indexOf('?') === -1) {
+    url += '?download'
+  }
+
+  openWindow(url, { target })
+  return true
+}

+ 44 - 0
client/form.d.ts

@@ -0,0 +1,44 @@
+import type { CSSProperties } from 'vue'
+import { ColProps, ComponentProps, ComponentName } from '@/types/components'
+import type { AxiosPromise } from 'axios'
+
+export type FormSetPropsType = {
+  field: string
+  path: string
+  value: any
+}
+
+export type FormValueType = string | number | string[] | number[] | boolean | undefined | null
+
+export type FormItemProps = {
+  labelWidth?: string | number
+  required?: boolean
+  rules?: Recordable
+  error?: string
+  showMessage?: boolean
+  inlineMessage?: boolean
+  style?: CSSProperties
+}
+
+export type FormSchema = {
+  // 唯一值
+  field: string
+  // 标题
+  label?: string
+  // 提示
+  labelMessage?: string
+  // col组件属性
+  colProps?: ColProps
+  // 表单组件属性,slots对应的是表单组件的插槽,规则:${field}-xxx,具体可以查看element-plus文档
+  componentProps?: { slots?: Recordable } & ComponentProps
+  // formItem组件属性
+  formItemProps?: FormItemProps
+  // 渲染的组件
+  component?: ComponentName
+  // 初始值
+  value?: FormValueType
+  // 是否隐藏
+  hidden?: boolean
+  // 远程加载下拉项
+  api?: <T = any>() => AxiosPromise<T>
+}

+ 54 - 0
client/formCreate.ts

@@ -0,0 +1,54 @@
+/**
+ * 针对 https://github.com/xaboy/form-create-designer 封装的工具类
+ */
+
+// 编码表单 Conf
+export const encodeConf = (designerRef: object) => {
+  // @ts-ignore
+  return JSON.stringify(designerRef.value.getOption())
+}
+
+// 编码表单 Fields
+export const encodeFields = (designerRef: object) => {
+  // @ts-ignore
+  const rule = designerRef.value.getRule()
+  const fields: string[] = []
+  rule.forEach((item) => {
+    fields.push(JSON.stringify(item))
+  })
+  return fields
+}
+
+// 解码表单 Fields
+export const decodeFields = (fields: string[]) => {
+  const rule: object[] = []
+  fields.forEach((item) => {
+    rule.push(JSON.parse(item))
+  })
+  return rule
+}
+
+// 设置表单的 Conf 和 Fields
+export const setConfAndFields = (designerRef: object, conf: string, fields: string) => {
+  // @ts-ignore
+  designerRef.value.setOption(JSON.parse(conf))
+  // @ts-ignore
+  designerRef.value.setRule(decodeFields(fields))
+}
+
+// 设置表单的 Conf 和 Fields
+export const setConfAndFields2 = (
+  detailPreview: object,
+  conf: string,
+  fields: string,
+  value?: object
+) => {
+  // @ts-ignore
+  detailPreview.value.option = JSON.parse(conf)
+  // @ts-ignore
+  detailPreview.value.rule = decodeFields(fields)
+  if (value) {
+    // @ts-ignore
+    detailPreview.value.value = value
+  }
+}

+ 7 - 0
client/formRules.ts

@@ -0,0 +1,7 @@
+const { t } = useI18n()
+
+// 必填项
+export const required = {
+  required: true,
+  message: t('common.required')
+}

+ 223 - 0
client/formatTime.ts

@@ -0,0 +1,223 @@
+import dayjs from 'dayjs'
+
+/**
+ * 时间日期转换
+ * @param date 当前时间,new Date() 格式
+ * @param format 需要转换的时间格式字符串
+ * @description format 字符串随意,如 `YYYY-mm、YYYY-mm-dd`
+ * @description format 季度:"YYYY-mm-dd HH:MM:SS QQQQ"
+ * @description format 星期:"YYYY-mm-dd HH:MM:SS WWW"
+ * @description format 几周:"YYYY-mm-dd HH:MM:SS ZZZ"
+ * @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ"
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatDate(date: Date | number, format?: string): string {
+  // 日期不存在,则返回空
+  if (!date) {
+    return ''
+  }
+  // 日期存在,则进行格式化
+  if (format === undefined) {
+    format = 'YYYY-MM-DD HH:mm:ss'
+  }
+  return dayjs(date).format(format)
+}
+
+/**
+ * 获取当前的日期+时间
+ */
+export function getNowDateTime() {
+  return dayjs()
+}
+
+/**
+ * 获取当前日期是第几周
+ * @param dateTime 当前传入的日期值
+ * @returns 返回第几周数字值
+ */
+export function getWeek(dateTime: Date): number {
+  const temptTime = new Date(dateTime.getTime())
+  // 周几
+  const weekday = temptTime.getDay() || 7
+  // 周1+5天=周六
+  temptTime.setDate(temptTime.getDate() - weekday + 1 + 5)
+  let firstDay = new Date(temptTime.getFullYear(), 0, 1)
+  const dayOfWeek = firstDay.getDay()
+  let spendDay = 1
+  if (dayOfWeek != 0) spendDay = 7 - dayOfWeek + 1
+  firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay)
+  const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86400000)
+  return Math.ceil(d / 7)
+}
+
+/**
+ * 将时间转换为 `几秒前`、`几分钟前`、`几小时前`、`几天前`
+ * @param param 当前时间,new Date() 格式或者字符串时间格式
+ * @param format 需要转换的时间格式字符串
+ * @description param 10秒:  10 * 1000
+ * @description param 1分:   60 * 1000
+ * @description param 1小时: 60 * 60 * 1000
+ * @description param 24小时:60 * 60 * 24 * 1000
+ * @description param 3天:   60 * 60* 24 * 1000 * 3
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatPast(param: string | Date, format = 'YYYY-mm-dd HH:MM:SS'): string {
+  // 传入格式处理、存储转换值
+  let t: any, s: number
+  // 获取js 时间戳
+  let time: number = new Date().getTime()
+  // 是否是对象
+  typeof param === 'string' || 'object' ? (t = new Date(param).getTime()) : (t = param)
+  // 当前时间戳 - 传入时间戳
+  time = Number.parseInt(`${time - t}`)
+  if (time < 10000) {
+    // 10秒内
+    return '刚刚'
+  } else if (time < 60000 && time >= 10000) {
+    // 超过10秒少于1分钟内
+    s = Math.floor(time / 1000)
+    return `${s}秒前`
+  } else if (time < 3600000 && time >= 60000) {
+    // 超过1分钟少于1小时
+    s = Math.floor(time / 60000)
+    return `${s}分钟前`
+  } else if (time < 86400000 && time >= 3600000) {
+    // 超过1小时少于24小时
+    s = Math.floor(time / 3600000)
+    return `${s}小时前`
+  } else if (time < 259200000 && time >= 86400000) {
+    // 超过1天少于3天内
+    s = Math.floor(time / 86400000)
+    return `${s}天前`
+  } else {
+    // 超过3天
+    const date = typeof param === 'string' || 'object' ? new Date(param) : param
+    return formatDate(date, format)
+  }
+}
+
+/**
+ * 时间问候语
+ * @param param 当前时间,new Date() 格式
+ * @description param 调用 `formatAxis(new Date())` 输出 `上午好`
+ * @returns 返回拼接后的时间字符串
+ */
+export function formatAxis(param: Date): string {
+  const hour: number = new Date(param).getHours()
+  if (hour < 6) return '凌晨好'
+  else if (hour < 9) return '早上好'
+  else if (hour < 12) return '上午好'
+  else if (hour < 14) return '中午好'
+  else if (hour < 17) return '下午好'
+  else if (hour < 19) return '傍晚好'
+  else if (hour < 22) return '晚上好'
+  else return '夜里好'
+}
+
+/**
+ * 将毫秒,转换成时间字符串。例如说,xx 分钟
+ *
+ * @param ms 毫秒
+ * @returns {string} 字符串
+ */
+export function formatPast2(ms) {
+  const day = Math.floor(ms / (24 * 60 * 60 * 1000))
+  const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24)
+  const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60)
+  const second = Math.floor(ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60)
+  if (day > 0) {
+    return day + '天' + hour + '小时' + minute + '分钟'
+  }
+  if (hour > 0) {
+    return hour + '小时' + minute + '分钟'
+  }
+  if (minute > 0) {
+    return minute + '分钟'
+  }
+  if (second > 0) {
+    return second + '秒'
+  } else {
+    return 0 + '秒'
+  }
+}
+
+/**
+ * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD HH:mm:ss 格式
+ *
+ * @param row 行数据
+ * @param column 字段
+ * @param cellValue 字段值
+ */
+// @ts-ignore
+export const dateFormatter = (row, column, cellValue) => {
+  if (!cellValue) {
+    return
+  }
+  return formatDate(cellValue)
+}
+
+/**
+ * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD 格式
+ *
+ * @param row 行数据
+ * @param column 字段
+ * @param cellValue 字段值
+ */
+// @ts-ignore
+export const dateFormatter2 = (row, column, cellValue) => {
+  if (!cellValue) {
+    return
+  }
+  return formatDate(cellValue, 'YYYY-MM-DD')
+}
+
+/**
+ * 设置起始日期,时间为00:00:00
+ * @param param 传入日期
+ * @returns 带时间00:00:00的日期
+ */
+export function beginOfDay(param: Date) {
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0)
+}
+
+/**
+ * 设置结束日期,时间为23:59:59
+ * @param param 传入日期
+ * @returns 带时间23:59:59的日期
+ */
+export function endOfDay(param: Date) {
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59)
+}
+
+/**
+ * 计算两个日期间隔天数
+ * @param param1 日期1
+ * @param param2 日期2
+ */
+export function betweenDay(param1: Date, param2: Date) {
+  param1 = convertDate(param1)
+  param2 = convertDate(param2)
+  // 计算差值
+  return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000))
+}
+
+/**
+ * 日期计算
+ * @param param1 日期
+ * @param param2 添加的时间
+ */
+export function addTime(param1: Date, param2: number) {
+  param1 = convertDate(param1)
+  return new Date(param1.getTime() + param2)
+}
+
+/**
+ * 日期转换
+ * @param param 日期
+ */
+export function convertDate(param: Date | string) {
+  if (typeof param === 'string') {
+    return new Date(param)
+  }
+  return param
+}

+ 12 - 0
client/formatter.ts

@@ -0,0 +1,12 @@
+import { fenToYuan } from '@/utils'
+import { TableColumnCtx } from 'element-plus'
+
+// 格式化金额【分转元】
+export const fenToYuanFormat = (
+  row: any,
+  column: TableColumnCtx<any>,
+  cellValue: any,
+  index: number
+) => {
+  return `¥${fenToYuan(cellValue)}`
+}

+ 50 - 0
client/global.d.ts

@@ -0,0 +1,50 @@
+export {}
+declare global {
+  interface Fn<T = any> {
+    (...arg: T[]): T
+  }
+
+  type Nullable<T> = T | null
+
+  type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T>
+
+  type Recordable<T = any, K = string> = Record<K extends null | undefined ? string : K, T>
+
+  type ComponentRef<T> = InstanceType<T>
+
+  type LocaleType = 'zh-CN' | 'en'
+
+  type AxiosHeaders =
+    | 'application/json'
+    | 'application/x-www-form-urlencoded'
+    | 'multipart/form-data'
+
+  type AxiosMethod = 'get' | 'post' | 'delete' | 'put' | 'GET' | 'POST' | 'DELETE' | 'PUT'
+
+  type AxiosResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
+
+  interface AxiosConfig {
+    params?: any
+    data?: any
+    url?: string
+    method?: AxiosMethod
+    headersType?: string
+    responseType?: AxiosResponseType
+  }
+
+  interface IResponse<T = any> {
+    code: string
+    data: T extends any ? T : T & any
+  }
+
+  interface PageParam {
+    pageSize?: number
+    pageNo?: number
+  }
+
+  interface Tree {
+    id: number
+    name: string
+    children?: Tree[] | any[]
+  }
+}

+ 6 - 0
client/global.module.scss

@@ -0,0 +1,6 @@
+@import './variables.scss';
+// 导出变量
+:export {
+  namespace: $namespace;
+  elNamespace: $elNamespace;
+}

+ 27 - 0
client/hasPermi.ts

@@ -0,0 +1,27 @@
+import type { App } from 'vue'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+
+const { t } = useI18n() // 国际化
+
+export function hasPermi(app: App<Element>) {
+  app.directive('hasPermi', (el, binding) => {
+    const { wsCache } = useCache()
+    const { value } = binding
+    const all_permission = '*:*:*'
+    const permissions = wsCache.get(CACHE_KEY.USER).permissions
+
+    if (value && value instanceof Array && value.length > 0) {
+      const permissionFlag = value
+
+      const hasPermissions = permissions.some((permission: string) => {
+        return all_permission === permission || permissionFlag.includes(permission)
+      })
+
+      if (!hasPermissions) {
+        el.parentNode && el.parentNode.removeChild(el)
+      }
+    } else {
+      throw new Error(t('permission.hasPermission'))
+    }
+  })
+}

+ 27 - 0
client/hasRole.ts

@@ -0,0 +1,27 @@
+import type { App } from 'vue'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+
+const { t } = useI18n() // 国际化
+
+export function hasRole(app: App<Element>) {
+  app.directive('hasRole', (el, binding) => {
+    const { wsCache } = useCache()
+    const { value } = binding
+    const super_admin = 'admin'
+    const roles = wsCache.get(CACHE_KEY.USER).roles
+
+    if (value && value instanceof Array && value.length > 0) {
+      const roleFlag = value
+
+      const hasRole = roles.some((role: string) => {
+        return super_admin === role || roleFlag.includes(role)
+      })
+
+      if (!hasRole) {
+        el.parentNode && el.parentNode.removeChild(el)
+      }
+    } else {
+      throw new Error(t('permission.hasRole'))
+    }
+  })
+}

+ 3 - 0
client/helper.ts

@@ -0,0 +1,3 @@
+export const setHtmlPageLang = (locale: LocaleType) => {
+  document.querySelector('html')?.setAttribute('lang', locale)
+}

BIN
client/home.png


+ 5 - 0
client/icon.d.ts

@@ -0,0 +1,5 @@
+export interface IconTypes {
+  size?: number
+  color?: string
+  icon: string
+}

+ 1 - 0
client/icon.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M115.147.062a13 13 0 014.94.945c1.55.63 2.907 1.526 4.069 2.688a13.148 13.148 0 012.761 4.069c.678 1.55 1.017 3.245 1.017 5.086v102.3c0 3.681-1.187 6.733-3.56 9.155-2.373 2.422-5.352 3.633-8.937 3.633H12.992c-3.875 0-7-1.26-9.373-3.779-2.373-2.518-3.56-5.667-3.56-9.445V12.704c0-3.39 1.163-6.345 3.488-8.863C5.872 1.32 8.972.062 12.847.062h102.3zM81.434 109.047c1.744 0 3.003-.412 3.778-1.235.775-.824 1.163-1.914 1.163-3.27 0-1.26-.388-2.325-1.163-3.197-.775-.872-2.034-1.307-3.778-1.307H72.57c.097-.194.145-.485.145-.872V27.09h9.01c1.743 0 2.954-.436 3.633-1.308.678-.872 1.017-1.938 1.017-3.197 0-1.26-.34-2.325-1.017-3.197-.679-.872-1.89-1.308-3.633-1.308H46.268c-1.743 0-2.954.436-3.632 1.308-.678.872-1.018 1.938-1.018 3.197 0 1.26.34 2.325 1.018 3.197.678.872 1.889 1.308 3.632 1.308h8.138v72.075c0 .193.024.339.073.436.048.096.072.242.072.436H46.56c-1.744 0-3.003.435-3.778 1.307-.775.872-1.163 1.938-1.163 3.197 0 1.356.388 2.446 1.163 3.27.775.823 2.034 1.235 3.778 1.235h34.875z"/></svg>

+ 151 - 0
client/index.html

@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta
+      name="keywords"
+      content="芋道管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
+    />
+    <meta
+      name="description"
+      content="芋道管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
+    />
+    <title>%VITE_APP_TITLE%</title>
+  </head>
+  <body>
+    <div id="app">
+      <style>
+        .app-loading {
+          display: flex;
+          width: 100%;
+          height: 100%;
+          justify-content: center;
+          align-items: center;
+          flex-direction: column;
+          background: #f0f2f5;
+        }
+
+        .app-loading .app-loading-wrap {
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          display: flex;
+          -webkit-transform: translate3d(-50%, -50%, 0);
+          transform: translate3d(-50%, -50%, 0);
+          justify-content: center;
+          align-items: center;
+          flex-direction: column;
+        }
+
+        .app-loading .app-loading-title {
+          margin-bottom: 30px;
+          font-size: 20px;
+          font-weight: bold;
+          text-align: center;
+        }
+
+        .app-loading .app-loading-logo {
+          width: 100px;
+          margin: 0 auto 15px auto;
+        }
+
+        .app-loading .app-loading-item {
+          position: relative;
+          display: inline-block;
+          width: 60px;
+          height: 60px;
+          vertical-align: middle;
+          border-radius: 50%;
+        }
+
+        .app-loading .app-loading-outter {
+          position: absolute;
+          width: 100%;
+          height: 100%;
+          border: 4px solid #2d8cf0;
+          border-bottom: 0;
+          border-left-color: transparent;
+          border-radius: 50%;
+          animation: loader-outter 1s cubic-bezier(0.42, 0.61, 0.58, 0.41) infinite;
+        }
+
+        .app-loading .app-loading-inner {
+          position: absolute;
+          top: calc(50% - 20px);
+          left: calc(50% - 20px);
+          width: 40px;
+          height: 40px;
+          border: 4px solid #87bdff;
+          border-right: 0;
+          border-top-color: transparent;
+          border-radius: 50%;
+          animation: loader-inner 1s cubic-bezier(0.42, 0.61, 0.58, 0.41) infinite;
+        }
+
+        @-webkit-keyframes loader-outter {
+          0% {
+            -webkit-transform: rotate(0deg);
+            transform: rotate(0deg);
+          }
+
+          100% {
+            -webkit-transform: rotate(360deg);
+            transform: rotate(360deg);
+          }
+        }
+
+        @keyframes loader-outter {
+          0% {
+            -webkit-transform: rotate(0deg);
+            transform: rotate(0deg);
+          }
+
+          100% {
+            -webkit-transform: rotate(360deg);
+            transform: rotate(360deg);
+          }
+        }
+
+        @-webkit-keyframes loader-inner {
+          0% {
+            -webkit-transform: rotate(0deg);
+            transform: rotate(0deg);
+          }
+
+          100% {
+            -webkit-transform: rotate(-360deg);
+            transform: rotate(-360deg);
+          }
+        }
+
+        @keyframes loader-inner {
+          0% {
+            -webkit-transform: rotate(0deg);
+            transform: rotate(0deg);
+          }
+
+          100% {
+            -webkit-transform: rotate(-360deg);
+            transform: rotate(-360deg);
+          }
+        }
+      </style>
+      <div class="app-loading">
+        <div class="app-loading-wrap">
+          <div class="app-loading-title">
+            <img src="/logo.gif" class="app-loading-logo" alt="Logo" />
+            <div class="app-loading-title">%VITE_APP_TITLE%</div>
+          </div>
+          <div class="app-loading-item">
+            <div class="app-loading-outter"></div>
+            <div class="app-loading-inner"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 35 - 0
client/index.scss

@@ -0,0 +1,35 @@
+@import './var.css';
+@import 'element-plus/theme-chalk/dark/css-vars.css';
+
+.reset-margin [class*='el-icon'] + span {
+  margin-left: 2px !important;
+}
+
+// 解决抽屉弹出时,body宽度变化的问题
+.el-popup-parent--hidden {
+  width: 100% !important;
+}
+
+// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题
+.el-scrollbar__bar {
+  display: flex;
+  justify-content: flex-start;
+}
+
+/* nprogress 适配 element-plus 的主题色 */
+#nprogress {
+  & .bar {
+    background-color: var(--el-color-primary) !important;
+  }
+
+  & .peg {
+    box-shadow:
+      0 0 10px var(--el-color-primary),
+      0 0 5px var(--el-color-primary) !important;
+  }
+
+  & .spinner-icon {
+    border-top-color: var(--el-color-primary);
+    border-left-color: var(--el-color-primary);
+  }
+}

+ 33 - 0
client/index.ts

@@ -0,0 +1,33 @@
+import ImageViewer from './src/ImageViewer.vue'
+import { isClient } from '@/utils/is'
+import { createVNode, render, VNode } from 'vue'
+import { ImageViewerProps } from './src/types'
+
+let instance: Nullable<VNode> = null
+
+export function createImageViewer(options: ImageViewerProps) {
+  if (!isClient) return
+  const {
+    urlList,
+    initialIndex = 0,
+    infinite = true,
+    hideOnClickModal = false,
+    appendToBody = false,
+    zIndex = 2000,
+    show = true
+  } = options
+
+  const propsData: Partial<ImageViewerProps> = {}
+  const container = document.createElement('div')
+  propsData.urlList = urlList
+  propsData.initialIndex = initialIndex
+  propsData.infinite = infinite
+  propsData.hideOnClickModal = hideOnClickModal
+  propsData.appendToBody = appendToBody
+  propsData.zIndex = zIndex
+  propsData.show = show
+
+  document.body.appendChild(container)
+  instance = createVNode(ImageViewer, propsData)
+  render(instance, container)
+}

+ 4 - 0
client/infoTip.d.ts

@@ -0,0 +1,4 @@
+export interface TipSchema {
+  label: string
+  keys?: string[]
+}

+ 105 - 0
client/is.ts

@@ -0,0 +1,105 @@
+// 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 (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
+}

+ 31 - 0
client/jsencrypt.ts

@@ -0,0 +1,31 @@
+import { JSEncrypt } from 'jsencrypt'
+
+// 密钥对生成 http://web.chacuo.net/netrsakeypair
+
+const publicKey =
+  'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n' +
+  'nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
+
+const privateKey =
+  'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY\n' +
+  '7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN\n' +
+  'PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA\n' +
+  'kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow\n' +
+  'cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv\n' +
+  'DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh\n' +
+  'YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3\n' +
+  'UP8iWi1Qw0Y='
+
+// 加密
+export const encrypt = (txt: string) => {
+  const encryptor = new JSEncrypt()
+  encryptor.setPublicKey(publicKey) // 设置公钥
+  return encryptor.encrypt(txt) // 对数据进行加密
+}
+
+// 解密
+export const decrypt = (txt: string) => {
+  const encryptor = new JSEncrypt()
+  encryptor.setPrivateKey(privateKey) // 设置私钥
+  return encryptor.decrypt(txt) // 对数据进行解密
+}

+ 16 - 0
client/launch.json

@@ -0,0 +1,16 @@
+{
+  // Use IntelliSense to learn about possible attributes.
+  // Hover to view descriptions of existing attributes.
+  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "msedge",
+      "request": "launch",
+      "name": "Launch Edge against localhost",
+      "url": "http://localhost",
+      "webRoot": "${workspaceFolder}/src",
+      "sourceMaps": true
+    }
+  ]
+}

+ 1 - 0
client/layout.d.ts

@@ -0,0 +1 @@
+export type LayoutType = 'classic' | 'topLeft' | 'top' | 'cutMenu'

+ 59 - 0
client/locale.ts

@@ -0,0 +1,59 @@
+import { defineStore } from 'pinia'
+import { store } from '../index'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import en from 'element-plus/es/locale/lang/en'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { LocaleDropdownType } from '@/types/localeDropdown'
+
+const { wsCache } = useCache()
+
+const elLocaleMap = {
+  'zh-CN': zhCn,
+  en: en
+}
+interface LocaleState {
+  currentLocale: LocaleDropdownType
+  localeMap: LocaleDropdownType[]
+}
+
+export const useLocaleStore = defineStore('locales', {
+  state: (): LocaleState => {
+    return {
+      currentLocale: {
+        lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN',
+        elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN']
+      },
+      // 多语言
+      localeMap: [
+        {
+          lang: 'zh-CN',
+          name: '简体中文'
+        },
+        {
+          lang: 'en',
+          name: 'English'
+        }
+      ]
+    }
+  },
+  getters: {
+    getCurrentLocale(): LocaleDropdownType {
+      return this.currentLocale
+    },
+    getLocaleMap(): LocaleDropdownType[] {
+      return this.localeMap
+    }
+  },
+  actions: {
+    setCurrentLocale(localeMap: LocaleDropdownType) {
+      // this.locale = Object.assign(this.locale, localeMap)
+      this.currentLocale.lang = localeMap?.lang
+      this.currentLocale.elLocale = elLocaleMap[localeMap?.lang]
+      wsCache.set(CACHE_KEY.LANG, localeMap?.lang)
+    }
+  }
+})
+
+export const useLocaleStoreWithOut = () => {
+  return useLocaleStore(store)
+}

+ 10 - 0
client/localeDropdown.d.ts

@@ -0,0 +1,10 @@
+export interface Language {
+  el: Recordable
+  name: string
+}
+
+export interface LocaleDropdownType {
+  lang: LocaleType
+  name?: string
+  elLocale?: Language
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/login-bg.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/login-box-bg.svg


BIN
client/logo.gif


BIN
client/logo.png


+ 72 - 0
client/main.ts

@@ -0,0 +1,72 @@
+// 引入unocss css
+import '@/plugins/unocss'
+
+// 导入全局的svg图标
+import '@/plugins/svgIcon'
+
+// 初始化多语言
+import { setupI18n } from '@/plugins/vueI18n'
+
+// 引入状态管理
+import { setupStore } from '@/store'
+
+// 全局组件
+import { setupGlobCom } from '@/components'
+
+// 引入 element-plus
+import { setupElementPlus } from '@/plugins/elementPlus'
+
+// 引入 form-create
+import { setupFormCreate } from '@/plugins/formCreate'
+
+// 引入全局样式
+import '@/styles/index.scss'
+
+// 引入动画
+import '@/plugins/animate.css'
+
+// 路由
+import router, { setupRouter } from '@/router'
+
+// 权限
+import { setupAuth } from '@/directives'
+
+import { createApp } from 'vue'
+
+import App from './App.vue'
+
+import './permission'
+
+import '@/plugins/tongji' // 百度统计
+import Logger from '@/utils/Logger'
+
+import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
+
+// 创建实例
+const setupAll = async () => {
+  const app = createApp(App)
+
+  await setupI18n(app)
+
+  setupStore(app)
+
+  setupGlobCom(app)
+
+  setupElementPlus(app)
+
+  setupFormCreate(app)
+
+  setupRouter(app)
+
+  setupAuth(app)
+
+  await router.isReady()
+
+  app.use(VueDOMPurifyHTML)
+
+  app.mount('#app')
+}
+
+setupAll()
+
+Logger.prettyPrimary(`欢迎使用`, import.meta.env.VITE_APP_TITLE)

+ 1 - 0
client/member_balance.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693028338187" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22985" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M983.8 312.7C958 251.7 921 197 874 150c-47-47-101.8-83.9-162.7-109.7C648.2 13.5 581.1 0 512 0S375.8 13.5 312.7 40.2C251.7 66 197 102.9 150 150c-47 47-83.9 101.8-109.7 162.7C13.5 375.8 0 442.9 0 512s13.5 136.2 40.2 199.3C66 772.3 102.9 827 150 874c47 47 101.8 83.9 162.7 109.7 63.1 26.7 130.2 40.2 199.3 40.2s136.2-13.5 199.3-40.2C772.3 958 827 921 874 874c47-47 83.9-101.8 109.7-162.7 26.7-63.1 40.2-130.2 40.2-199.3s-13.4-136.2-40.1-199.3z m-55.3 375.2c-22.8 53.8-55.4 102.2-96.9 143.7s-89.9 74.1-143.7 96.9C632.2 952.1 573 964 512 964s-120.2-11.9-175.9-35.5c-53.8-22.8-102.2-55.4-143.7-96.9s-74.1-89.9-96.9-143.7C71.9 632.2 60 573 60 512s11.9-120.2 35.5-175.9c22.8-53.8 55.4-102.2 96.9-143.7s89.9-74.1 143.7-96.9C391.8 71.9 451 60 512 60s120.2 11.9 175.9 35.5c53.8 22.8 102.2 55.4 143.7 96.9s74.1 89.9 96.9 143.7C952.1 391.8 964 451 964 512s-11.9 120.2-35.5 175.9z" fill="#000000" p-id="22986"></path><path d="M706 469.1H574.7l84.2-180.6c7-15 0.4-32.9-14.5-39.9-15-7-32.9-0.4-39.9 14.5L512 461.5l-92.5-198.3c-7-15-24.9-21.5-39.9-14.5s-21.5 24.9-14.5 39.9l84.2 180.6H318c-16.5 0-30 13.5-30 30s13.5 30 30 30h164v64h-92.5c-20.6 0-37.5 13.5-37.5 30s16.9 30 37.5 30H482v95c0 16.5 13.5 30 30 30s30-13.5 30-30v-95h92.5c20.6 0 37.5-13.5 37.5-30s-16.9-30-37.5-30H542v-64h164c16.5 0 30-13.5 30-30 0-16.6-13.5-30.1-30-30.1z" fill="#000000" p-id="22987"></path></svg>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/member_expenditure_balance.svg


+ 1 - 0
client/member_level.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693027700643" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8876" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M936.96 385.877333l-203.434667-204.8-18.090667-7.68L308.565333 173.397333l-18.090667 7.68L87.04 385.877333c-9.728 9.898667-9.898667 25.941333-0.170667 35.84l406.869333 421.034667c4.778667 4.949333 11.434667 7.850667 18.432 7.850667 6.997333 0 13.653333-2.901333 18.432-7.850667l406.869333-421.034667C946.858667 411.648 946.688 395.776 936.96 385.877333zM868.522667 389.632l-141.994667 0-163.84-165.034667 141.994667 0L868.522667 389.632zM319.317333 224.768l143.018667 0-163.84 165.034667L155.477333 389.802667 319.317333 224.768zM176.469333 440.832l132.608 0 18.090667-7.509333 185.173333-186.538667 185.173333 186.538667 18.090667 7.509333 131.584 0L512 787.968 176.469333 440.832z" p-id="8877" fill="#000000"></path></svg>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/member_point.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/member_recharge_balance.svg


+ 1 - 0
client/message.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M0 20.967v59.59c0 11.59 8.537 20.966 19.075 20.966h28.613l1 26.477L76.8 101.523h32.125c10.538 0 19.075-9.377 19.075-20.966v-59.59C128 9.377 119.463 0 108.925 0h-89.85C8.538 0 0 9.377 0 20.967zm82.325 33.1c0-5.524 4.013-9.935 9.037-9.935 5.026 0 9.038 4.41 9.038 9.934 0 5.524-4.025 9.934-9.038 9.934-5.024 0-9.037-4.41-9.037-9.934zm-27.613 0c0-5.524 4.013-9.935 9.038-9.935s9.037 4.41 9.037 9.934c0 5.524-4.025 9.934-9.037 9.934-5.025 0-9.038-4.41-9.038-9.934zm-27.1 0c0-5.524 4.013-9.935 9.038-9.935s9.038 4.41 9.038 9.934c0 5.524-4.026 9.934-9.05 9.934-5.013 0-9.025-4.41-9.025-9.934z"/></svg>

+ 1 - 0
client/money.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M54.122 127.892v-28.68H7.513V87.274h46.609v-12.4H7.513v-12.86h38.003L.099 0h22.6l32.556 45.07c3.617 5.144 6.44 9.611 8.487 13.385 1.788-3.05 4.89-7.779 9.301-14.186L103.93 0h24.01L82.385 62.013h38.34v12.862h-46.41v12.4h46.41v11.937h-46.41v28.68H54.123z"/></svg>

+ 112 - 0
client/optimize.ts

@@ -0,0 +1,112 @@
+const include = [
+  'qs',
+  'url',
+  'vue',
+  'sass',
+  'mitt',
+  'axios',
+  'pinia',
+  'dayjs',
+  'qrcode',
+  'unocss',
+  'vue-router',
+  'vue-types',
+  'vue-i18n',
+  'crypto-js',
+  'cropperjs',
+  'lodash-es',
+  'nprogress',
+  'web-storage-cache',
+  '@iconify/iconify',
+  '@vueuse/core',
+  '@zxcvbn-ts/core',
+  'echarts/core',
+  'echarts/charts',
+  'echarts/components',
+  'echarts/renderers',
+  'echarts-wordcloud',
+  '@wangeditor/editor',
+  '@wangeditor/editor-for-vue',
+  'element-plus',
+  'element-plus/es',
+  'element-plus/es/locale/lang/zh-cn',
+  'element-plus/es/locale/lang/en',
+  'element-plus/es/components/avatar/style/css',
+  'element-plus/es/components/space/style/css',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/form/style/css',
+  'element-plus/es/components/radio-group/style/css',
+  'element-plus/es/components/radio/style/css',
+  'element-plus/es/components/checkbox/style/css',
+  'element-plus/es/components/checkbox-group/style/css',
+  'element-plus/es/components/switch/style/css',
+  'element-plus/es/components/time-picker/style/css',
+  'element-plus/es/components/date-picker/style/css',
+  'element-plus/es/components/descriptions/style/css',
+  'element-plus/es/components/descriptions-item/style/css',
+  'element-plus/es/components/link/style/css',
+  'element-plus/es/components/tooltip/style/css',
+  'element-plus/es/components/drawer/style/css',
+  'element-plus/es/components/dialog/style/css',
+  'element-plus/es/components/checkbox-button/style/css',
+  'element-plus/es/components/option-group/style/css',
+  'element-plus/es/components/radio-button/style/css',
+  'element-plus/es/components/cascader/style/css',
+  'element-plus/es/components/color-picker/style/css',
+  'element-plus/es/components/input-number/style/css',
+  'element-plus/es/components/rate/style/css',
+  'element-plus/es/components/select-v2/style/css',
+  'element-plus/es/components/tree-select/style/css',
+  'element-plus/es/components/slider/style/css',
+  'element-plus/es/components/time-select/style/css',
+  'element-plus/es/components/autocomplete/style/css',
+  'element-plus/es/components/image-viewer/style/css',
+  'element-plus/es/components/upload/style/css',
+  'element-plus/es/components/col/style/css',
+  'element-plus/es/components/form-item/style/css',
+  'element-plus/es/components/alert/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/select/style/css',
+  'element-plus/es/components/input/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/tag/style/css',
+  'element-plus/es/components/pagination/style/css',
+  'element-plus/es/components/table/style/css',
+  'element-plus/es/components/table-v2/style/css',
+  'element-plus/es/components/table-column/style/css',
+  'element-plus/es/components/card/style/css',
+  'element-plus/es/components/row/style/css',
+  'element-plus/es/components/button/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/option/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/tree/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/badge/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/image/style/css',
+  'element-plus/es/components/collapse-transition/style/css',
+  'element-plus/es/components/timeline/style/css',
+  'element-plus/es/components/timeline-item/style/css',
+  'element-plus/es/components/collapse/style/css',
+  'element-plus/es/components/collapse-item/style/css',
+  'element-plus/es/components/button-group/style/css',
+  'element-plus/es/components/text/style/css'
+]
+
+const exclude = ['@iconify/json']
+
+export { include, exclude }

+ 146 - 0
client/package.json

@@ -0,0 +1,146 @@
+{
+  "name": "yudao-ui-admin-vue3",
+  "version": "1.8.2-snapshot",
+  "description": "基于vue3、vite4、element-plus、typesScript",
+  "author": "xingyu",
+  "private": false,
+  "scripts": {
+    "i": "pnpm install",
+    "dev": "vite --mode base",
+    "front": "vite --mode front",
+    "ts:check": "vue-tsc --noEmit",
+    "build:pro": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode pro",
+    "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
+    "build:base": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode base",
+    "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage",
+    "build:static": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode static",
+    "build:front": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode front",
+    "serve:pro": "vite preview --mode pro",
+    "serve:dev": "vite preview --mode dev",
+    "preview": "pnpm build:base && vite preview",
+    "clean": "npx rimraf node_modules",
+    "clean:cache": "npx rimraf node_modules/.cache",
+    "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
+    "lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
+    "lint:style": "stylelint --fix \"./src/**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
+    "lint:lint-staged": "lint-staged -c "
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.1.0",
+    "@form-create/designer": "^3.1.3",
+    "@form-create/element-ui": "^3.1.24",
+    "@iconify/iconify": "^3.1.1",
+    "@videojs-player/vue": "^1.0.0",
+    "@vueuse/core": "^10.4.1",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "^5.1.10",
+    "@zxcvbn-ts/core": "^3.0.4",
+    "animate.css": "^4.1.1",
+    "axios": "^1.5.0",
+    "benz-amr-recorder": "^1.1.5",
+    "bpmn-js-token-simulation": "^0.10.0",
+    "camunda-bpmn-moddle": "^7.0.1",
+    "cropperjs": "^1.6.1",
+    "crypto-js": "^4.1.1",
+    "dayjs": "^1.11.10",
+    "diagram-js": "^12.3.0",
+    "echarts": "^5.4.3",
+    "echarts-wordcloud": "^2.1.0",
+    "element-plus": "2.3.14",
+    "fast-xml-parser": "^4.3.0",
+    "highlight.js": "^11.8.0",
+    "intro.js": "^7.2.0",
+    "jsencrypt": "^3.3.2",
+    "lodash-es": "^4.17.21",
+    "min-dash": "^4.1.1",
+    "mitt": "^3.0.1",
+    "nprogress": "^0.2.0",
+    "pinia": "^2.1.6",
+    "qrcode": "^1.5.3",
+    "qs": "^6.11.2",
+    "steady-xml": "^0.1.0",
+    "url": "^0.11.3",
+    "video.js": "^7.21.5",
+    "vue": "^3.3.4",
+    "vue-dompurify-html": "^4.1.4",
+    "vue-i18n": "^9.4.1",
+    "vue-router": "^4.2.5",
+    "vue-types": "^5.1.1",
+    "vuedraggable": "^4.1.0",
+    "web-storage-cache": "^1.1.1",
+    "xml-js": "^1.6.11"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^17.7.1",
+    "@commitlint/config-conventional": "^17.7.0",
+    "@iconify/json": "^2.2.119",
+    "@intlify/unplugin-vue-i18n": "^1.2.0",
+    "@purge-icons/generated": "^0.9.0",
+    "@types/intro.js": "^5.1.1",
+    "@types/lodash-es": "^4.17.9",
+    "@types/node": "^20.6.0",
+    "@types/nprogress": "^0.2.0",
+    "@types/qrcode": "^1.5.2",
+    "@types/qs": "^6.9.8",
+    "@typescript-eslint/eslint-plugin": "^6.7.2",
+    "@typescript-eslint/parser": "^6.7.2",
+    "@unocss/transformer-variant-group": "^0.56.1",
+    "@unocss/eslint-config": "^0.56.1",
+    "@vitejs/plugin-legacy": "^4.1.1",
+    "@vitejs/plugin-vue": "^4.3.4",
+    "@vitejs/plugin-vue-jsx": "^3.0.2",
+    "@vue-macros/volar": "^0.14.3",
+    "autoprefixer": "^10.4.16",
+    "bpmn-js": "8.9.0",
+    "bpmn-js-properties-panel": "0.46.0",
+    "consola": "^3.2.3",
+    "eslint": "^8.49.0",
+    "eslint-config-prettier": "^9.0.0",
+    "eslint-define-config": "^1.23.0",
+    "eslint-plugin-prettier": "^5.0.0",
+    "eslint-plugin-vue": "^9.17.0",
+    "lint-staged": "^14.0.1",
+    "postcss": "^8.4.30",
+    "postcss-html": "^1.5.0",
+    "postcss-scss": "^4.0.8",
+    "prettier": "^3.0.3",
+    "rimraf": "^5.0.1",
+    "rollup": "^3.29.2",
+    "sass": "^1.68.0",
+    "stylelint": "^15.10.3",
+    "stylelint-config-html": "^1.1.0",
+    "stylelint-config-recommended": "^13.0.0",
+    "stylelint-config-standard": "^34.0.0",
+    "stylelint-order": "^6.0.3",
+    "terser": "^5.20.0",
+    "typescript": "5.2.2",
+    "unocss": "^0.56.1",
+    "unplugin-auto-import": "^0.16.6",
+    "unplugin-element-plus": "^0.8.0",
+    "unplugin-vue-components": "^0.25.2",
+    "vite": "4.4.9",
+    "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-ejs": "^1.6.4",
+    "vite-plugin-eslint": "^1.8.1",
+    "vite-plugin-progress": "^0.0.7",
+    "vite-plugin-purge-icons": "^0.9.2",
+    "vite-plugin-svg-icons": "^2.0.1",
+    "vite-plugin-top-level-await": "^1.3.1",
+    "vue-eslint-parser": "^9.3.1",
+    "vue-tsc": "^1.8.13"
+  },
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://gitee.com/yudaocode/yudao-ui-admin-vue3"
+  },
+  "bugs": {
+    "url": "https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues"
+  },
+  "homepage": "https://gitee.com/yudaocode/yudao-ui-admin-vue3",
+  "packageManager": "pnpm@8.6.0",
+  "engines": {
+    "node": ">= 16.0.0",
+    "pnpm": ">=8.6.0"
+  }
+}

+ 1 - 0
client/peoples.svg

@@ -0,0 +1 @@
+<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M95.648 118.762c0 5.035-3.563 9.121-7.979 9.121H7.98c-4.416 0-7.979-4.086-7.979-9.121C0 100.519 15.408 83.47 31.152 76.75c-9.099-6.43-15.216-17.863-15.216-30.987v-9.128c0-20.16 14.293-36.518 31.893-36.518s31.894 16.358 31.894 36.518v9.122c0 13.137-6.123 24.556-15.216 30.993 15.738 6.726 31.141 23.769 31.141 42.012z"/><path d="M106.032 118.252h15.867c3.376 0 6.101-3.125 6.101-6.972 0-13.957-11.787-26.984-23.819-32.123 6.955-4.919 11.638-13.66 11.638-23.704v-6.985c0-15.416-10.928-27.926-24.39-27.926-1.674 0-3.306.193-4.89.561 1.936 4.713 3.018 9.974 3.018 15.526v9.121c0 13.137-3.056 23.111-11.066 30.993 14.842 4.41 27.312 23.42 27.541 41.509z"/></svg>

+ 70 - 0
client/permission.ts

@@ -0,0 +1,70 @@
+import router from './router'
+import type { RouteRecordRaw } from 'vue-router'
+import { isRelogin } from '@/config/axios/service'
+import { getAccessToken } from '@/utils/auth'
+import { useTitle } from '@/hooks/web/useTitle'
+import { useNProgress } from '@/hooks/web/useNProgress'
+import { usePageLoading } from '@/hooks/web/usePageLoading'
+import { useDictStoreWithOut } from '@/store/modules/dict'
+import { useUserStoreWithOut } from '@/store/modules/user'
+import { usePermissionStoreWithOut } from '@/store/modules/permission'
+
+const { start, done } = useNProgress()
+
+const { loadStart, loadDone } = usePageLoading()
+// 路由不重定向白名单
+const whiteList = [
+  '/login',
+  '/social-login',
+  '/auth-redirect',
+  '/bind',
+  '/register',
+  '/oauthLogin/gitee'
+]
+
+// 路由加载前
+router.beforeEach(async (to, from, next) => {
+  start()
+  loadStart()
+  if (getAccessToken()) {
+    if (to.path === '/login') {
+      next({ path: '/' })
+    } else {
+      // 获取所有字典
+      const dictStore = useDictStoreWithOut()
+      const userStore = useUserStoreWithOut()
+      const permissionStore = usePermissionStoreWithOut()
+      if (!dictStore.getIsSetDict) {
+        await dictStore.setDictMap()
+      }
+      if (!userStore.getIsSetUser) {
+        isRelogin.show = true
+        await userStore.setUserInfoAction()
+        isRelogin.show = false
+        // 后端过滤菜单
+        await permissionStore.generateRoutes()
+        permissionStore.getAddRouters.forEach((route) => {
+          router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表
+        })
+        const redirectPath = from.query.redirect || to.path
+        const redirect = decodeURIComponent(redirectPath as string)
+        const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
+        next(nextData)
+      } else {
+        next()
+      }
+    }
+  } else {
+    if (whiteList.indexOf(to.path) !== -1) {
+      next()
+    } else {
+      next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
+    }
+  }
+})
+
+router.afterEach((to) => {
+  useTitle(to?.meta?.title as string)
+  done() // 结束Progress
+  loadDone()
+})

+ 5 - 0
client/postcss.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  plugins: {
+    autoprefixer: {}
+  }
+}

+ 22 - 0
client/prettier.config.js

@@ -0,0 +1,22 @@
+module.exports = {
+  printWidth: 100, // 每行代码长度(默认80)
+  tabWidth: 2, // 每个tab相当于多少个空格(默认2)ab进行缩进(默认false)
+  useTabs: false, // 是否使用tab
+  semi: false, // 声明结尾使用分号(默认true)
+  vueIndentScriptAndStyle: false,
+  singleQuote: true, // 使用单引号(默认false)
+  quoteProps: 'as-needed',
+  bracketSpacing: true, // 对象字面量的大括号间使用空格(默认true)
+  trailingComma: 'none', // 多行使用拖尾逗号(默认none)
+  jsxSingleQuote: false,
+  // 箭头函数参数括号 默认avoid 可选 avoid| always
+  // avoid 能省略括号的时候就省略 例如x => x
+  // always 总是有括号
+  arrowParens: 'always',
+  insertPragma: false,
+  requirePragma: false,
+  proseWrap: 'never',
+  htmlWhitespaceSensitivity: 'strict',
+  endOfLine: 'auto',
+  rangeStart: 0
+}

BIN
client/profile.jpg


+ 28 - 0
client/propTypes.ts

@@ -0,0 +1,28 @@
+import { createTypes, VueTypesInterface, VueTypeValidableDef } from 'vue-types'
+import { CSSProperties } from 'vue'
+
+// 自定义扩展vue-types
+type PropTypes = VueTypesInterface & {
+  readonly style: VueTypeValidableDef<CSSProperties>
+}
+
+const propTypes = createTypes({
+  func: undefined,
+  bool: undefined,
+  string: undefined,
+  number: undefined,
+  object: undefined,
+  integer: undefined
+}) as PropTypes
+
+// 需要自定义扩展的类型
+// see: https://dwightjack.github.io/vue-types/advanced/extending-vue-types.html#the-extend-method
+// propTypes.extend([
+//   {
+//     name: 'style',
+//     getter: true,
+//     type: [String, Object],
+//     default: undefined
+//   }
+// ])
+export { propTypes }

+ 103 - 0
client/property.ts

@@ -0,0 +1,103 @@
+import request from '@/config/axios'
+
+/**
+ * 商品属性
+ */
+export interface PropertyVO {
+  id?: number
+  /** 名称 */
+  name: string
+  /** 备注 */
+  remark?: string
+}
+
+/**
+ * 属性值
+ */
+export interface PropertyValueVO {
+  id?: number
+  /** 属性项的编号 */
+  propertyId?: number
+  /** 名称 */
+  name: string
+  /** 备注 */
+  remark?: string
+}
+
+/**
+ * 商品属性值的明细
+ */
+export interface PropertyValueDetailVO {
+  /** 属性项的编号 */
+  propertyId: number // 属性的编号
+  /** 属性的名称 */
+  propertyName: string
+  /** 属性值的编号 */
+  valueId: number
+  /** 属性值的名称 */
+  valueName: string
+}
+
+// ------------------------ 属性项 -------------------
+
+// 创建属性项
+export const createProperty = (data: PropertyVO) => {
+  return request.post({ url: '/product/property/create', data })
+}
+
+// 更新属性项
+export const updateProperty = (data: PropertyVO) => {
+  return request.put({ url: '/product/property/update', data })
+}
+
+// 删除属性项
+export const deleteProperty = (id: number) => {
+  return request.delete({ url: `/product/property/delete?id=${id}` })
+}
+
+// 获得属性项
+export const getProperty = (id: number): Promise<PropertyVO> => {
+  return request.get({ url: `/product/property/get?id=${id}` })
+}
+
+// 获得属性项分页
+export const getPropertyPage = (params: PageParam) => {
+  return request.get({ url: '/product/property/page', params })
+}
+
+// 获得属性项列表
+export const getPropertyList = (params: any) => {
+  return request.get({ url: '/product/property/list', params })
+}
+
+// 获得属性项列表
+export const getPropertyListAndValue = (data: any) => {
+  return request.post({ url: '/product/property/get-value-list', data })
+}
+
+// ------------------------ 属性值 -------------------
+
+// 获得属性值分页
+export const getPropertyValuePage = (params: PageParam & any) => {
+  return request.get({ url: '/product/property/value/page', params })
+}
+
+// 获得属性值
+export const getPropertyValue = (id: number): Promise<PropertyValueVO> => {
+  return request.get({ url: `/product/property/value/get?id=${id}` })
+}
+
+// 创建属性值
+export const createPropertyValue = (data: PropertyValueVO) => {
+  return request.post({ url: '/product/property/value/create', data })
+}
+
+// 更新属性值
+export const updatePropertyValue = (data: PropertyValueVO) => {
+  return request.put({ url: '/product/property/value/update', data })
+}
+
+// 删除属性值
+export const deletePropertyValue = (id: number) => {
+  return request.delete({ url: `/product/property/value/delete?id=${id}` })
+}

+ 9 - 0
client/qrcode.d.ts

@@ -0,0 +1,9 @@
+export interface QrcodeLogo {
+  src?: string
+  logoSize?: number
+  bgColor?: string
+  borderSize?: number
+  crossOrigin?: string
+  borderRadius?: number
+  logoRadius?: number
+}

+ 455 - 0
client/remaining.ts

@@ -0,0 +1,455 @@
+import { Layout } from '@/utils/routerHelper'
+
+const { t } = useI18n()
+/**
+ * redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
+ * name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
+ * meta : {
+ hidden: true              当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false)
+
+ alwaysShow: true          当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式,
+ 只有一个时,会将那个子路由当做根路由显示在侧边栏,
+ 若你想不管路由下面的 children 声明的个数都显示你的根路由,
+ 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,
+ 一直显示根路由(默认 false)
+
+ title: 'title'            设置该路由在侧边栏和面包屑中展示的名字
+
+ icon: 'svg-name'          设置该路由的图标
+
+ noCache: true             如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
+
+ breadcrumb: false         如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)
+
+ affix: true               如果设置为true,则会一直固定在tag项中(默认 false)
+
+ noTagsView: true          如果设置为true,则不会出现在tag中(默认 false)
+
+ activeMenu: '/dashboard'  显示高亮的路由路径
+
+ followAuth: '/dashboard'  跟随哪个路由进行权限过滤
+
+ canTo: true               设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)
+ }
+ **/
+const remainingRouter: AppRouteRecordRaw[] = [
+  {
+    path: '/redirect',
+    component: Layout,
+    name: 'Redirect',
+    children: [
+      {
+        path: '/redirect/:path(.*)',
+        name: 'Redirect',
+        component: () => import('@/views/Redirect/Redirect.vue'),
+        meta: {}
+      }
+    ],
+    meta: {
+      hidden: true,
+      noTagsView: true
+    }
+  },
+  {
+    path: '/',
+    component: Layout,
+    redirect: '/index',
+    name: 'Home',
+    meta: {},
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/Home/Index.vue'),
+        name: 'Index',
+        meta: {
+          title: t('router.home'),
+          icon: 'ep:home-filled',
+          noCache: false,
+          affix: true
+        }
+      }
+    ]
+  },
+  {
+    path: '/user',
+    component: Layout,
+    name: 'UserInfo',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'profile',
+        component: () => import('@/views/Profile/Index.vue'),
+        name: 'Profile',
+        meta: {
+          canTo: true,
+          hidden: true,
+          noTagsView: false,
+          icon: 'ep:user',
+          title: t('common.profile')
+        }
+      },
+      {
+        path: 'notify-message',
+        component: () => import('@/views/system/notify/my/index.vue'),
+        name: 'MyNotifyMessage',
+        meta: {
+          canTo: true,
+          hidden: true,
+          noTagsView: false,
+          icon: 'ep:message',
+          title: '我的站内信'
+        }
+      }
+    ]
+  },
+
+  {
+    path: '/dict',
+    component: Layout,
+    name: 'dict',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'type/data/:dictType',
+        component: () => import('@/views/system/dict/data/index.vue'),
+        name: 'SystemDictData',
+        meta: {
+          title: '字典数据',
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: '',
+          activeMenu: '/system/dict'
+        }
+      }
+    ]
+  },
+
+  {
+    path: '/codegen',
+    component: Layout,
+    name: 'CodegenEdit',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'edit',
+        component: () => import('@/views/infra/codegen/EditTable.vue'),
+        name: 'InfraCodegenEditTable',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '修改生成配置',
+          activeMenu: 'infra/codegen/index'
+        }
+      }
+    ]
+  },
+  {
+    path: '/job',
+    component: Layout,
+    name: 'JobL',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'job-log',
+        component: () => import('@/views/infra/job/logger/index.vue'),
+        name: 'InfraJobLog',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '调度日志',
+          activeMenu: 'infra/job/index'
+        }
+      }
+    ]
+  },
+  {
+    path: '/login',
+    component: () => import('@/views/Login/Login.vue'),
+    name: 'Login',
+    meta: {
+      hidden: true,
+      title: t('router.login'),
+      noTagsView: true
+    }
+  },
+  {
+    path: '/sso',
+    component: () => import('@/views/Login/Login.vue'),
+    name: 'SSOLogin',
+    meta: {
+      hidden: true,
+      title: t('router.login'),
+      noTagsView: true
+    }
+  },
+  {
+    path: '/403',
+    component: () => import('@/views/Error/403.vue'),
+    name: 'NoAccess',
+    meta: {
+      hidden: true,
+      title: '403',
+      noTagsView: true
+    }
+  },
+  {
+    path: '/404',
+    component: () => import('@/views/Error/404.vue'),
+    name: 'NoFound',
+    meta: {
+      hidden: true,
+      title: '404',
+      noTagsView: true
+    }
+  },
+  {
+    path: '/500',
+    component: () => import('@/views/Error/500.vue'),
+    name: 'Error',
+    meta: {
+      hidden: true,
+      title: '500',
+      noTagsView: true
+    }
+  },
+  {
+    path: '/bpm',
+    component: Layout,
+    name: 'bpm',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: '/manager/form/edit',
+        component: () => import('@/views/bpm/form/editor/index.vue'),
+        name: 'BpmFormEditor',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '设计流程表单',
+          activeMenu: '/bpm/manager/form'
+        }
+      },
+      {
+        path: '/manager/model/edit',
+        component: () => import('@/views/bpm/model/editor/index.vue'),
+        name: 'BpmModelEditor',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '设计流程',
+          activeMenu: '/bpm/manager/model'
+        }
+      },
+      {
+        path: '/manager/definition',
+        component: () => import('@/views/bpm/definition/index.vue'),
+        name: 'BpmProcessDefinition',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '流程定义',
+          activeMenu: '/bpm/manager/model'
+        }
+      },
+      {
+        path: '/manager/task-assign-rule',
+        component: () => import('@/views/bpm/taskAssignRule/index.vue'),
+        name: 'BpmTaskAssignRuleList',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '任务分配规则'
+        }
+      },
+      {
+        path: '/process-instance/create',
+        component: () => import('@/views/bpm/processInstance/create/index.vue'),
+        name: 'BpmProcessInstanceCreate',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '发起流程',
+          activeMenu: 'bpm/processInstance/create'
+        }
+      },
+      {
+        path: '/process-instance/detail',
+        component: () => import('@/views/bpm/processInstance/detail/index.vue'),
+        name: 'BpmProcessInstanceDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '流程详情',
+          activeMenu: 'bpm/processInstance/detail'
+        }
+      },
+      {
+        path: '/bpm/oa/leave/create',
+        component: () => import('@/views/bpm/oa/leave/create.vue'),
+        name: 'OALeaveCreate',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '发起 OA 请假',
+          activeMenu: '/bpm/oa/leave'
+        }
+      },
+      {
+        path: '/bpm/oa/leave/detail',
+        component: () => import('@/views/bpm/oa/leave/detail.vue'),
+        name: 'OALeaveDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          title: '查看 OA 请假',
+          activeMenu: '/bpm/oa/leave'
+        }
+      }
+    ]
+  },
+  {
+    path: '/product',
+    component: Layout,
+    name: 'Product',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'spu/add',
+        component: () => import('@/views/mall/product/spu/form/index.vue'),
+        name: 'ProductSpuAdd',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '添加商品',
+          activeMenu: '/product/product-spu'
+        }
+      },
+      {
+        path: 'spu/edit/:spuId(\\d+)',
+        component: () => import('@/views/mall/product/spu/form/index.vue'),
+        name: 'ProductSpuEdit',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '编辑商品',
+          activeMenu: '/product/product-spu'
+        }
+      },
+      {
+        path: 'spu/detail/:spuId(\\d+)',
+        component: () => import('@/views/mall/product/spu/form/index.vue'),
+        name: 'ProductSpuDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:view',
+          title: '商品详情',
+          activeMenu: '/product/product-spu'
+        }
+      },
+      {
+        path: 'property/value/:propertyId(\\d+)',
+        component: () => import('@/views/mall/product/property/value/index.vue'),
+        name: 'ProductPropertyValue',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:view',
+          title: '商品属性值',
+          activeMenu: '/product/property'
+        }
+      }
+    ]
+  },
+  {
+    path: '/trade',
+    component: Layout,
+    name: 'Order',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'order/detail/:orderId(\\d+)',
+        component: () => import('@/views/mall/trade/order/detail/index.vue'),
+        name: 'TradeOrderDetail',
+        meta: { title: '订单详情', icon: '', activeMenu: '/trade/trade/order' }
+      },
+      {
+        path: 'after-sale/detail/:orderId(\\d+)',
+        component: () => import('@/views/mall/trade/afterSale/detail/index.vue'),
+        name: 'TradeAfterSaleDetail',
+        meta: { title: '退款详情', icon: '', activeMenu: '/trade/trade/after-sale' }
+      }
+    ]
+  },
+  {
+    path: '/member',
+    component: Layout,
+    name: 'member',
+    meta: { hidden: true },
+    children: [
+      {
+        path: 'user/detail/:id',
+        name: 'MemberUserDetail',
+        meta: {
+          title: '会员详情',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/member/user/detail/index.vue')
+      }
+    ]
+  },
+  {
+    path: '/pay',
+    component: Layout,
+    name: 'pay',
+    meta: { hidden: true },
+    children: [
+      {
+        path: 'cashier',
+        name: 'PayCashier',
+        meta: {
+          title: '收银台',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/pay/cashier/index.vue')
+      }
+    ]
+  }
+]
+
+export default remainingRouter

+ 81 - 0
client/router.d.ts

@@ -0,0 +1,81 @@
+import type { RouteRecordRaw } from 'vue-router'
+import { defineComponent } from 'vue'
+
+/**
+* redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
+* name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
+* meta : {
+    hidden: true              当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false)
+
+    alwaysShow: true          当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式,
+                              只有一个时,会将那个子路由当做根路由显示在侧边栏,
+                              若你想不管路由下面的 children 声明的个数都显示你的根路由,
+                              你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,
+                              一直显示根路由(默认 false)
+
+    title: 'title'            设置该路由在侧边栏和面包屑中展示的名字
+
+    icon: 'svg-name'          设置该路由的图标
+
+    noCache: true             如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
+
+    breadcrumb: false         如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)
+
+    affix: true               如果设置为true,则会一直固定在tag项中(默认 false)
+
+    noTagsView: true          如果设置为true,则不会出现在tag中(默认 false)
+
+    activeMenu: '/home'  显示高亮的路由路径
+
+    followAuth: '/home'  跟随哪个路由进行权限过滤
+
+    canTo: true               设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)
+  }
+**/
+declare module 'vue-router' {
+  interface RouteMeta extends Record<string | number | symbol, unknown> {
+    hidden?: boolean
+    alwaysShow?: boolean
+    title?: string
+    icon?: string
+    noCache?: boolean
+    breadcrumb?: boolean
+    affix?: boolean
+    activeMenu?: string
+    noTagsView?: boolean
+    followAuth?: string
+    canTo?: boolean
+  }
+}
+
+type Component<T = any> =
+  | ReturnType<typeof defineComponent>
+  | (() => Promise<typeof import('*.vue')>)
+  | (() => Promise<T>)
+
+declare global {
+  interface AppRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
+    name: string
+    meta: RouteMeta
+    component?: Component | string
+    children?: AppRouteRecordRaw[]
+    props?: Recordable
+    fullPath?: string
+    keepAlive?: boolean
+  }
+
+  interface AppCustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
+    icon: any
+    name: string
+    meta: RouteMeta
+    component: string
+    componentName?: string
+    path: string
+    redirect: string
+    children?: AppCustomRouteRecordRaw[]
+    keepAlive?: boolean
+    visible?: boolean
+    parentId?: number
+    alwaysShow?: boolean
+  }
+}

+ 238 - 0
client/routerHelper.ts

@@ -0,0 +1,238 @@
+import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router'
+import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
+import { isUrl } from '@/utils/is'
+import { cloneDeep, omit } from 'lodash-es'
+
+const modules = import.meta.glob('../views/**/*.{vue,tsx}')
+/**
+ * 注册一个异步组件
+ * @param componentPath 例:/bpm/oa/leave/detail
+ */
+export const registerComponent = (componentPath: string) => {
+  for (const item in modules) {
+    if (item.includes(componentPath)) {
+      // 使用异步组件的方式来动态加载组件
+      // @ts-ignore
+      return defineAsyncComponent(modules[item])
+    }
+  }
+}
+/* Layout */
+export const Layout = () => import('@/layout/Layout.vue')
+
+export const getParentLayout = () => {
+  return () =>
+    new Promise((resolve) => {
+      resolve({
+        name: 'ParentLayout'
+      })
+    })
+}
+
+// 按照路由中meta下的rank等级升序来排序路由
+export const ascending = (arr: any[]) => {
+  arr.forEach((v) => {
+    if (v?.meta?.rank === null) v.meta.rank = undefined
+    if (v?.meta?.rank === 0) {
+      if (v.name !== 'home' && v.path !== '/') {
+        console.warn('rank only the home page can be 0')
+      }
+    }
+  })
+  return arr.sort((a: { meta: { rank: number } }, b: { meta: { rank: number } }) => {
+    return a?.meta?.rank - b?.meta?.rank
+  })
+}
+
+export const getRawRoute = (route: RouteLocationNormalized): RouteLocationNormalized => {
+  if (!route) return route
+  const { matched, ...opt } = route
+  return {
+    ...opt,
+    matched: (matched
+      ? matched.map((item) => ({
+          meta: item.meta,
+          name: item.name,
+          path: item.path
+        }))
+      : undefined) as RouteRecordNormalized[]
+  }
+}
+
+// 后端控制路由生成
+export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecordRaw[] => {
+  const res: AppRouteRecordRaw[] = []
+  const modulesRoutesKeys = Object.keys(modules)
+  for (const route of routes) {
+    const meta = {
+      title: route.name,
+      icon: route.icon,
+      hidden: !route.visible,
+      noCache: !route.keepAlive,
+      alwaysShow:
+        route.children &&
+        route.children.length === 1 &&
+        (route.alwaysShow !== undefined ? route.alwaysShow : true)
+    }
+    // 路由地址转首字母大写驼峰,作为路由名称,适配keepAlive
+    let data: AppRouteRecordRaw = {
+      path: route.path,
+      name:
+        route.componentName && route.componentName.length > 0
+          ? route.componentName
+          : toCamelCase(route.path, true),
+      redirect: route.redirect,
+      meta: meta
+    }
+    //处理顶级非目录路由
+    if (!route.children && route.parentId == 0 && route.component) {
+      data.component = Layout
+      data.meta = {}
+      data.name = toCamelCase(route.path, true) + 'Parent'
+      data.redirect = ''
+      meta.alwaysShow = true
+      const childrenData: AppRouteRecordRaw = {
+        path: '',
+        name: toCamelCase(route.path, true),
+        redirect: route.redirect,
+        meta: meta
+      }
+      const index = route?.component
+        ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
+        : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
+      childrenData.component = modules[modulesRoutesKeys[index]]
+      data.children = [childrenData]
+    } else {
+      // 目录
+      if (route.children) {
+        data.component = Layout
+        data.redirect = getRedirect(route.path, route.children)
+        // 外链
+      } else if (isUrl(route.path)) {
+        data = {
+          path: '/external-link',
+          component: Layout,
+          meta: {
+            name: route.name
+          },
+          children: [data]
+        } as AppRouteRecordRaw
+        // 菜单
+      } else {
+        // 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会根path保持一致)
+        const index = route?.component
+          ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
+          : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
+        data.component = modules[modulesRoutesKeys[index]]
+      }
+      if (route.children) {
+        data.children = generateRoute(route.children)
+      }
+    }
+    res.push(data as AppRouteRecordRaw)
+  }
+  return res
+}
+export const getRedirect = (parentPath: string, children: AppCustomRouteRecordRaw[]) => {
+  if (!children || children.length == 0) {
+    return parentPath
+  }
+  const path = generateRoutePath(parentPath, children[0].path)
+  // 递归子节点
+  if (children[0].children) return getRedirect(path, children[0].children)
+}
+const generateRoutePath = (parentPath: string, path: string) => {
+  if (parentPath.endsWith('/')) {
+    parentPath = parentPath.slice(0, -1) // 移除默认的 /
+  }
+  if (!path.startsWith('/')) {
+    path = '/' + path
+  }
+  return parentPath + path
+}
+export const pathResolve = (parentPath: string, path: string) => {
+  if (isUrl(path)) return path
+  const childPath = path.startsWith('/') || !path ? path : `/${path}`
+  return `${parentPath}${childPath}`.replace(/\/\//g, '/')
+}
+
+// 路由降级
+export const flatMultiLevelRoutes = (routes: AppRouteRecordRaw[]) => {
+  const modules: AppRouteRecordRaw[] = cloneDeep(routes)
+  for (let index = 0; index < modules.length; index++) {
+    const route = modules[index]
+    if (!isMultipleRoute(route)) {
+      continue
+    }
+    promoteRouteLevel(route)
+  }
+  return modules
+}
+
+// 层级是否大于2
+const isMultipleRoute = (route: AppRouteRecordRaw) => {
+  if (!route || !Reflect.has(route, 'children') || !route.children?.length) {
+    return false
+  }
+
+  const children = route.children
+
+  let flag = false
+  for (let index = 0; index < children.length; index++) {
+    const child = children[index]
+    if (child.children?.length) {
+      flag = true
+      break
+    }
+  }
+  return flag
+}
+
+// 生成二级路由
+const promoteRouteLevel = (route: AppRouteRecordRaw) => {
+  let router: Router | null = createRouter({
+    routes: [route as RouteRecordRaw],
+    history: createWebHashHistory()
+  })
+
+  const routes = router.getRoutes()
+  addToChildren(routes, route.children || [], route)
+  router = null
+
+  route.children = route.children?.map((item) => omit(item, 'children'))
+}
+
+// 添加所有子菜单
+const addToChildren = (
+  routes: RouteRecordNormalized[],
+  children: AppRouteRecordRaw[],
+  routeModule: AppRouteRecordRaw
+) => {
+  for (let index = 0; index < children.length; index++) {
+    const child = children[index]
+    const route = routes.find((item) => item.name === child.name)
+    if (!route) {
+      continue
+    }
+    routeModule.children = routeModule.children || []
+    if (!routeModule.children.find((item) => item.name === route.name)) {
+      routeModule.children?.push(route as unknown as AppRouteRecordRaw)
+    }
+    if (child.children?.length) {
+      addToChildren(routes, child.children, routeModule)
+    }
+  }
+}
+const toCamelCase = (str: string, upperCaseFirst: boolean) => {
+  str = (str || '')
+    .replace(/-(.)/g, function (group1: string) {
+      return group1.toUpperCase()
+    })
+    .replaceAll('-', '')
+
+  if (upperCaseFirst && str) {
+    str = str.charAt(0).toUpperCase() + str.slice(1)
+  }
+
+  return str
+}

+ 239 - 0
client/service.ts

@@ -0,0 +1,239 @@
+import axios, {
+  AxiosError,
+  AxiosInstance,
+  AxiosRequestHeaders,
+  AxiosResponse,
+  InternalAxiosRequestConfig
+} from 'axios'
+
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
+import qs from 'qs'
+import { config } from '@/config/axios/config'
+import { getAccessToken, getRefreshToken, getTenantId, removeToken, setToken } from '@/utils/auth'
+import errorCode from './errorCode'
+
+import { resetRouter } from '@/router'
+import { useCache } from '@/hooks/web/useCache'
+
+const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
+const { result_code, base_url, request_timeout } = config
+
+// 需要忽略的提示。忽略后,自动 Promise.reject('error')
+const ignoreMsgs = [
+  '无效的刷新令牌', // 刷新令牌被删除时,不用提示
+  '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面
+]
+// 是否显示重新登录
+export const isRelogin = { show: false }
+// Axios 无感知刷新令牌,参考 https://www.dashingdog.cn/article/11 与 https://segmentfault.com/a/1190000020210980 实现
+// 请求队列
+let requestList: any[] = []
+// 是否正在刷新中
+let isRefreshToken = false
+// 请求白名单,无须token的接口
+const whiteList: string[] = ['/login', '/refresh-token']
+
+// 创建axios实例
+const service: AxiosInstance = axios.create({
+  baseURL: base_url, // api 的 base_url
+  timeout: request_timeout, // 请求超时时间
+  withCredentials: false // 禁用 Cookie 等信息
+})
+
+// request拦截器
+service.interceptors.request.use(
+  (config: InternalAxiosRequestConfig) => {
+    // 是否需要设置 token
+    let isToken = (config!.headers || {}).isToken === false
+    whiteList.some((v) => {
+      if (config.url) {
+        config.url.indexOf(v) > -1
+        return (isToken = false)
+      }
+    })
+    if (getAccessToken() && !isToken) {
+      ;(config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
+    }
+    // 设置租户
+    if (tenantEnable && tenantEnable === 'true') {
+      const tenantId = getTenantId()
+      if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId
+    }
+    const params = config.params || {}
+    const data = config.data || false
+    if (
+      config.method?.toUpperCase() === 'POST' &&
+      (config.headers as AxiosRequestHeaders)['Content-Type'] ===
+        'application/x-www-form-urlencoded'
+    ) {
+      config.data = qs.stringify(data)
+    }
+    // get参数编码
+    if (config.method?.toUpperCase() === 'GET' && params) {
+      let url = config.url + '?'
+      for (const propName of Object.keys(params)) {
+        const value = params[propName]
+        if (value !== void 0 && value !== null && typeof value !== 'undefined') {
+          if (typeof value === 'object') {
+            for (const val of Object.keys(value)) {
+              const params = propName + '[' + val + ']'
+              const subPart = encodeURIComponent(params) + '='
+              url += subPart + encodeURIComponent(value[val]) + '&'
+            }
+          } else {
+            url += `${propName}=${encodeURIComponent(value)}&`
+          }
+        }
+      }
+      // 给 get 请求加上时间戳参数,避免从缓存中拿数据
+      // const now = new Date().getTime()
+      // params = params.substring(0, url.length - 1) + `?_t=${now}`
+      url = url.slice(0, -1)
+      config.params = {}
+      config.url = url
+    }
+    return config
+  },
+  (error: AxiosError) => {
+    // Do something with request error
+    console.log(error) // for debug
+    Promise.reject(error)
+  }
+)
+
+// response 拦截器
+service.interceptors.response.use(
+  async (response: AxiosResponse<any>) => {
+    const { data } = response
+    const config = response.config
+    if (!data) {
+      // 返回“[HTTP]请求没有返回值”;
+      throw new Error()
+    }
+    const { t } = useI18n()
+    // 未设置状态码则默认成功状态
+    const code = data.code || result_code
+    // 二进制数据则直接返回
+    if (
+      response.request.responseType === 'blob' ||
+      response.request.responseType === 'arraybuffer'
+    ) {
+      return response.data
+    }
+    // 获取错误信息
+    const msg = data.msg || errorCode[code] || errorCode['default']
+    if (ignoreMsgs.indexOf(msg) !== -1) {
+      // 如果是忽略的错误码,直接返回 msg 异常
+      return Promise.reject(msg)
+    } else if (code === 401) {
+      // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
+      if (!isRefreshToken) {
+        isRefreshToken = true
+        // 1. 如果获取不到刷新令牌,则只能执行登出操作
+        if (!getRefreshToken()) {
+          return handleAuthorized()
+        }
+        // 2. 进行刷新访问令牌
+        try {
+          const refreshTokenRes = await refreshToken()
+          // 2.1 刷新成功,则回放队列的请求 + 当前请求
+          setToken((await refreshTokenRes).data.data)
+          config.headers!.Authorization = 'Bearer ' + getAccessToken()
+          requestList.forEach((cb: any) => {
+            cb()
+          })
+          requestList = []
+          return service(config)
+        } catch (e) {
+          // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
+          // 2.2 刷新失败,只回放队列的请求
+          requestList.forEach((cb: any) => {
+            cb()
+          })
+          // 提示是否要登出。即不回放当前请求!不然会形成递归
+          return handleAuthorized()
+        } finally {
+          requestList = []
+          isRefreshToken = false
+        }
+      } else {
+        // 添加到队列,等待刷新获取到新的令牌
+        return new Promise((resolve) => {
+          requestList.push(() => {
+            config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改
+            resolve(service(config))
+          })
+        })
+      }
+    } else if (code === 500) {
+      ElMessage.error(t('sys.api.errMsg500'))
+      return Promise.reject(new Error(msg))
+    } else if (code === 901) {
+      ElMessage.error({
+        offset: 300,
+        dangerouslyUseHTMLString: true,
+        message:
+          '<div>' +
+          t('sys.api.errMsg901') +
+          '</div>' +
+          '<div> &nbsp; </div>' +
+          '<div>参考 https://doc.iocoder.cn/ 教程</div>' +
+          '<div> &nbsp; </div>' +
+          '<div>5 分钟搭建本地环境</div>'
+      })
+      return Promise.reject(new Error(msg))
+    } else if (code !== 200) {
+      if (msg === '无效的刷新令牌') {
+        // hard coding:忽略这个提示,直接登出
+        console.log(msg)
+      } else {
+        ElNotification.error({ title: msg })
+      }
+      return Promise.reject('error')
+    } else {
+      return data
+    }
+  },
+  (error: AxiosError) => {
+    console.log('err' + error) // for debug
+    let { message } = error
+    const { t } = useI18n()
+    if (message === 'Network Error') {
+      message = t('sys.api.errorMessage')
+    } else if (message.includes('timeout')) {
+      message = t('sys.api.apiTimeoutMessage')
+    } else if (message.includes('Request failed with status code')) {
+      message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3)
+    }
+    ElMessage.error(message)
+    return Promise.reject(error)
+  }
+)
+
+const refreshToken = async () => {
+  axios.defaults.headers.common['tenant-id'] = getTenantId()
+  return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())
+}
+const handleAuthorized = () => {
+  const { t } = useI18n()
+  if (!isRelogin.show) {
+    isRelogin.show = true
+    ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
+      showCancelButton: false,
+      closeOnClickModal: false,
+      showClose: false,
+      confirmButtonText: t('login.relogin'),
+      type: 'warning'
+    }).then(() => {
+      const { wsCache } = useCache()
+      resetRouter() // 重置静态路由表
+      wsCache.clear()
+      removeToken()
+      isRelogin.show = false
+      // 干掉token后再走一次路由让它过router.beforeEach的校验
+      window.location.href = window.location.href
+    })
+  }
+  return Promise.reject(t('sys.api.timeoutMessage'))
+}
+export { service }

+ 144 - 0
client/settings.json

@@ -0,0 +1,144 @@
+{
+  "typescript.tsdk": "./node_modules/typescript/lib",
+  "npm.packageManager": "pnpm",
+  "editor.tabSize": 2,
+  "prettier.printWidth": 100, // 超过最大值换行
+  "editor.defaultFormatter": "esbenp.prettier-vscode",
+  "files.eol": "\n",
+  "search.exclude": {
+    "**/node_modules": true,
+    "**/*.log": true,
+    "**/*.log*": true,
+    "**/bower_components": true,
+    "**/dist": true,
+    "**/elehukouben": true,
+    "**/.git": true,
+    "**/.gitignore": true,
+    "**/.svn": true,
+    "**/.DS_Store": true,
+    "**/.idea": true,
+    "**/.vscode": false,
+    "**/yarn.lock": true,
+    "**/tmp": true,
+    "out": true,
+    "dist": true,
+    "node_modules": true,
+    "CHANGELOG.md": true,
+    "examples": true,
+    "res": true,
+    "screenshots": true,
+    "yarn-error.log": true,
+    "**/.yarn": true
+  },
+  "files.exclude": {
+    "**/.cache": true,
+    "**/.editorconfig": true,
+    "**/.eslintcache": true,
+    "**/bower_components": true,
+    "**/.idea": true,
+    "**/tmp": true,
+    "**/.git": true,
+    "**/.svn": true,
+    "**/.hg": true,
+    "**/CVS": true,
+    "**/.DS_Store": true
+  },
+  "files.watcherExclude": {
+    "**/.git/objects/**": true,
+    "**/.git/subtree-cache/**": true,
+    "**/.vscode/**": true,
+    "**/node_modules/**": true,
+    "**/tmp/**": true,
+    "**/bower_components/**": true,
+    "**/dist/**": true,
+    "**/yarn.lock": true
+  },
+  "stylelint.enable": true,
+  "stylelint.validate": ["css", "less", "postcss", "scss", "vue", "sass"],
+  "path-intellisense.mappings": {
+    "@/": "${workspaceRoot}/src"
+  },
+  "[javascriptreact]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[typescript]": {
+    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+  },
+  "[typescriptreact]": {
+    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+  },
+  "[html]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[css]": {
+    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+  },
+  "[less]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[scss]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[markdown]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": true
+  },
+  "[vue]": {
+    "editor.codeActionsOnSave": {
+      "source.fixAll.eslint": true,
+      "source.fixAll.stylelint": true
+    }
+  },
+  "i18n-ally.localesPaths": ["src/locales"],
+  "i18n-ally.keystyle": "nested",
+  "i18n-ally.sortKeys": true,
+  "i18n-ally.namespace": true,
+  "i18n-ally.enabledParsers": ["ts"],
+  "i18n-ally.sourceLanguage": "en",
+  "i18n-ally.displayLanguage": "zh-CN",
+  "i18n-ally.enabledFrameworks": ["vue", "react"],
+  "cSpell.words": [
+    "brotli",
+    "browserslist",
+    "codemirror",
+    "commitlint",
+    "cropperjs",
+    "echarts",
+    "esnext",
+    "esno",
+    "iconify",
+    "INTLIFY",
+    "lintstagedrc",
+    "logicflow",
+    "nprogress",
+    "pinia",
+    "pnpm",
+    "qrcode",
+    "sider",
+    "sortablejs",
+    "stylelint",
+    "unocss",
+    "unplugin",
+    "unref",
+    "videojs",
+    "vitejs",
+    "vueuse",
+    "wangeditor",
+    "xingyu",
+    "yudao",
+    "zxcvbn"
+  ],
+  // 控制相关文件嵌套展示
+  "explorer.fileNesting.enabled": true,
+  "explorer.fileNesting.expand": false,
+  "explorer.fileNesting.patterns": {
+    "*.ts": "$(capture).test.ts, $(capture).test.tsx",
+    "*.tsx": "$(capture).test.ts, $(capture).test.tsx",
+    "*.env": "$(capture).env.*",
+    "package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
+  },
+  "terminal.integrated.scrollback": 10000,
+  "nuxt.isNuxtApp": false
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
client/shopping.svg


+ 108 - 0
client/spu.ts

@@ -0,0 +1,108 @@
+import request from '@/config/axios'
+
+export interface Property {
+  propertyId?: number // 属性编号
+  propertyName?: string // 属性名称
+  valueId?: number // 属性值编号
+  valueName?: string // 属性值名称
+}
+
+export interface Sku {
+  id?: number // 商品 SKU 编号
+  name?: string // 商品 SKU 名称
+  spuId?: number // SPU 编号
+  properties?: Property[] // 属性数组
+  price?: number | string // 商品价格
+  marketPrice?: number | string // 市场价
+  costPrice?: number | string // 成本价
+  barCode?: string // 商品条码
+  picUrl?: string // 图片地址
+  stock?: number // 库存
+  weight?: number // 商品重量,单位:kg 千克
+  volume?: number // 商品体积,单位:m^3 平米
+  firstBrokerageRecord?: number | string // 一级分销的佣金
+  secondBrokerageRecord?: number | string // 二级分销的佣金
+  salesCount?: number // 商品销量
+}
+
+export interface Spu {
+  id?: number
+  name?: string // 商品名称
+  categoryId?: number | null // 商品分类
+  keyword?: string // 关键字
+  unit?: number | null // 单位
+  picUrl?: string // 商品封面图
+  sliderPicUrls?: string[] // 商品轮播图
+  introduction?: string // 商品简介
+  deliveryTemplateId?: number | null // 运费模版
+  brandId?: number | null // 商品品牌编号
+  specType?: boolean // 商品规格
+  subCommissionType?: boolean // 分销类型
+  skus?: Sku[] // sku数组
+  description?: string // 商品详情
+  sort?: number // 商品排序
+  giveIntegral?: number // 赠送积分
+  virtualSalesCount?: number // 虚拟销量
+  recommendHot?: boolean // 是否热卖
+  recommendBenefit?: boolean // 是否优惠
+  recommendBest?: boolean // 是否精品
+  recommendNew?: boolean // 是否新品
+  recommendGood?: boolean // 是否优品
+  price?: number // 商品价格
+  salesCount?: number // 商品销量
+  marketPrice?: number // 市场价
+  costPrice?: number // 成本价
+  stock?: number // 商品库存
+  createTime?: Date // 商品创建时间
+  status?: number // 商品状态
+}
+
+// 获得 Spu 列表
+export const getSpuPage = (params: PageParam) => {
+  return request.get({ url: '/product/spu/page', params })
+}
+
+// 获得 Spu 列表 tabsCount
+export const getTabsCount = () => {
+  return request.get({ url: '/product/spu/get-count' })
+}
+
+// 创建商品 Spu
+export const createSpu = (data: Spu) => {
+  return request.post({ url: '/product/spu/create', data })
+}
+
+// 更新商品 Spu
+export const updateSpu = (data: Spu) => {
+  return request.put({ url: '/product/spu/update', data })
+}
+
+// 更新商品 Spu status
+export const updateStatus = (data: { id: number; status: number }) => {
+  return request.put({ url: '/product/spu/update-status', data })
+}
+
+// 获得商品 Spu
+export const getSpu = (id: number) => {
+  return request.get({ url: `/product/spu/get-detail?id=${id}` })
+}
+
+// 获得商品 Spu 详情列表
+export const getSpuDetailList = (ids: number[]) => {
+  return request.get({ url: `/product/spu/list?spuIds=${ids}` })
+}
+
+// 删除商品 Spu
+export const deleteSpu = (id: number) => {
+  return request.delete({ url: `/product/spu/delete?id=${id}` })
+}
+
+// 导出商品 Spu Excel
+export const exportSpu = async (params) => {
+  return await request.download({ url: '/product/spu/export', params })
+}
+
+// 获得商品 SPU 精简列表
+export const getSpuSimpleList = async () => {
+  return request.get({ url: '/product/spu/get-simple-list' })
+}

+ 233 - 0
client/stylelint.config.js

@@ -0,0 +1,233 @@
+module.exports = {
+  root: true,
+  plugins: ['stylelint-order'],
+  customSyntax: 'postcss-html',
+  extends: ['stylelint-config-standard'],
+  rules: {
+    'selector-pseudo-class-no-unknown': [
+      true,
+      {
+        ignorePseudoClasses: ['global', 'deep']
+      }
+    ],
+    'at-rule-no-unknown': [
+      true,
+      {
+        ignoreAtRules: ['function', 'if', 'each', 'include', 'mixin']
+      }
+    ],
+    // 命名规范
+    "selector-class-pattern": null,
+    'no-empty-source': null,
+    'named-grid-areas-no-invalid': null,
+    'unicode-bom': 'never',
+    'no-descending-specificity': null,
+    'font-family-no-missing-generic-family-keyword': null,
+    'declaration-colon-space-after': 'always-single-line',
+    'declaration-colon-space-before': 'never',
+    'declaration-block-trailing-semicolon': null,
+    'rule-empty-line-before': [
+      'always',
+      {
+        ignore: ['after-comment', 'first-nested']
+      }
+    ],
+    'unit-no-unknown': [
+      true,
+      {
+        ignoreUnits: ['rpx']
+      }
+    ],
+    'order/order': [
+      [
+        'dollar-variables',
+        'custom-properties',
+        'at-rules',
+        'declarations',
+        {
+          type: 'at-rule',
+          name: 'supports'
+        },
+        {
+          type: 'at-rule',
+          name: 'media'
+        },
+        'rules'
+      ],
+      {
+        severity: 'warning'
+      }
+    ],
+    // Specify the alphabetical order of the attributes in the declaration block
+    'order/properties-order': [
+      'position',
+      'top',
+      'right',
+      'bottom',
+      'left',
+      'z-index',
+      'display',
+      'float',
+      'width',
+      'height',
+      'max-width',
+      'max-height',
+      'min-width',
+      'min-height',
+      'padding',
+      'padding-top',
+      'padding-right',
+      'padding-bottom',
+      'padding-left',
+      'margin',
+      'margin-top',
+      'margin-right',
+      'margin-bottom',
+      'margin-left',
+      'margin-collapse',
+      'margin-top-collapse',
+      'margin-right-collapse',
+      'margin-bottom-collapse',
+      'margin-left-collapse',
+      'overflow',
+      'overflow-x',
+      'overflow-y',
+      'clip',
+      'clear',
+      'font',
+      'font-family',
+      'font-size',
+      'font-smoothing',
+      'osx-font-smoothing',
+      'font-style',
+      'font-weight',
+      'hyphens',
+      'src',
+      'line-height',
+      'letter-spacing',
+      'word-spacing',
+      'color',
+      'text-align',
+      'text-decoration',
+      'text-indent',
+      'text-overflow',
+      'text-rendering',
+      'text-size-adjust',
+      'text-shadow',
+      'text-transform',
+      'word-break',
+      'word-wrap',
+      'white-space',
+      'vertical-align',
+      'list-style',
+      'list-style-type',
+      'list-style-position',
+      'list-style-image',
+      'pointer-events',
+      'cursor',
+      'background',
+      'background-attachment',
+      'background-color',
+      'background-image',
+      'background-position',
+      'background-repeat',
+      'background-size',
+      'border',
+      'border-collapse',
+      'border-top',
+      'border-right',
+      'border-bottom',
+      'border-left',
+      'border-color',
+      'border-image',
+      'border-top-color',
+      'border-right-color',
+      'border-bottom-color',
+      'border-left-color',
+      'border-spacing',
+      'border-style',
+      'border-top-style',
+      'border-right-style',
+      'border-bottom-style',
+      'border-left-style',
+      'border-width',
+      'border-top-width',
+      'border-right-width',
+      'border-bottom-width',
+      'border-left-width',
+      'border-radius',
+      'border-top-right-radius',
+      'border-bottom-right-radius',
+      'border-bottom-left-radius',
+      'border-top-left-radius',
+      'border-radius-topright',
+      'border-radius-bottomright',
+      'border-radius-bottomleft',
+      'border-radius-topleft',
+      'content',
+      'quotes',
+      'outline',
+      'outline-offset',
+      'opacity',
+      'filter',
+      'visibility',
+      'size',
+      'zoom',
+      'transform',
+      'box-align',
+      'box-flex',
+      'box-orient',
+      'box-pack',
+      'box-shadow',
+      'box-sizing',
+      'table-layout',
+      'animation',
+      'animation-delay',
+      'animation-duration',
+      'animation-iteration-count',
+      'animation-name',
+      'animation-play-state',
+      'animation-timing-function',
+      'animation-fill-mode',
+      'transition',
+      'transition-delay',
+      'transition-duration',
+      'transition-property',
+      'transition-timing-function',
+      'background-clip',
+      'backface-visibility',
+      'resize',
+      'appearance',
+      'user-select',
+      'interpolation-mode',
+      'direction',
+      'marks',
+      'page',
+      'set-link-source',
+      'unicode-bidi',
+      'speak'
+    ]
+  },
+  ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts'],
+  overrides: [
+    {
+      files: ['*.vue', '**/*.vue', '*.html', '**/*.html'],
+      extends: ['stylelint-config-recommended', 'stylelint-config-html'],
+      rules: {
+        'keyframes-name-pattern': null,
+        'selector-pseudo-class-no-unknown': [
+          true,
+          {
+            ignorePseudoClasses: ['deep', 'global']
+          }
+        ],
+        'selector-pseudo-element-no-unknown': [
+          true,
+          {
+            ignorePseudoElements: ['v-deep', 'v-global', 'v-slotted']
+          }
+        ]
+      }
+    }
+  ]
+}

+ 44 - 0
client/table.d.ts

@@ -0,0 +1,44 @@
+export type TableColumn = {
+  field: string
+  label?: string
+  width?: number | string
+  fixed?: 'left' | 'right'
+  children?: TableColumn[]
+} & Recordable
+
+export type VxeTableColumn = {
+  field: string
+  title?: string
+  children?: TableColumn[]
+} & Recordable
+
+export type TableSlotDefault = {
+  row: Recordable
+  column: TableColumn
+  $index: number
+} & Recordable
+
+export interface Pagination {
+  small?: boolean
+  background?: boolean
+  pageSize?: number
+  defaultPageSize?: number
+  total?: number
+  pageCount?: number
+  pagerCount?: number
+  currentPage?: number
+  defaultCurrentPage?: number
+  layout?: string
+  pageSizes?: number[]
+  popperClass?: string
+  prevText?: string
+  nextText?: string
+  disabled?: boolean
+  hideOnSinglePage?: boolean
+}
+
+export interface TableSetPropsType {
+  field: string
+  path: string
+  value: any
+}

+ 140 - 0
client/tagsView.ts

@@ -0,0 +1,140 @@
+import router from '@/router'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { getRawRoute } from '@/utils/routerHelper'
+import { defineStore } from 'pinia'
+import { store } from '../index'
+import { findIndex } from '@/utils'
+
+export interface TagsViewState {
+  visitedViews: RouteLocationNormalizedLoaded[]
+  cachedViews: Set<string>
+}
+
+export const useTagsViewStore = defineStore('tagsView', {
+  state: (): TagsViewState => ({
+    visitedViews: [],
+    cachedViews: new Set()
+  }),
+  getters: {
+    getVisitedViews(): RouteLocationNormalizedLoaded[] {
+      return this.visitedViews
+    },
+    getCachedViews(): string[] {
+      return Array.from(this.cachedViews)
+    }
+  },
+  actions: {
+    // 新增缓存和tag
+    addView(view: RouteLocationNormalizedLoaded): void {
+      this.addVisitedView(view)
+      this.addCachedView()
+    },
+    // 新增tag
+    addVisitedView(view: RouteLocationNormalizedLoaded) {
+      if (this.visitedViews.some((v) => v.path === view.path)) return
+      if (view.meta?.noTagsView) return
+      this.visitedViews.push(
+        Object.assign({}, view, {
+          title: view.meta?.title || 'no-name'
+        })
+      )
+    },
+    // 新增缓存
+    addCachedView() {
+      const cacheMap: Set<string> = new Set()
+      for (const v of this.visitedViews) {
+        const item = getRawRoute(v)
+        const needCache = !item.meta?.noCache
+        if (!needCache) {
+          continue
+        }
+        const name = item.name as string
+        cacheMap.add(name)
+      }
+      if (Array.from(this.cachedViews).sort().toString() === Array.from(cacheMap).sort().toString())
+        return
+      this.cachedViews = cacheMap
+    },
+    // 删除某个
+    delView(view: RouteLocationNormalizedLoaded) {
+      this.delVisitedView(view)
+      this.delCachedView()
+    },
+    // 删除tag
+    delVisitedView(view: RouteLocationNormalizedLoaded) {
+      for (const [i, v] of this.visitedViews.entries()) {
+        if (v.path === view.path) {
+          this.visitedViews.splice(i, 1)
+          break
+        }
+      }
+    },
+    // 删除缓存
+    delCachedView() {
+      const route = router.currentRoute.value
+      const index = findIndex<string>(this.getCachedViews, (v) => v === route.name)
+      if (index > -1) {
+        this.cachedViews.delete(this.getCachedViews[index])
+      }
+    },
+    // 删除所有缓存和tag
+    delAllViews() {
+      this.delAllVisitedViews()
+      this.delCachedView()
+    },
+    // 删除所有tag
+    delAllVisitedViews() {
+      // const affixTags = this.visitedViews.filter((tag) => tag.meta.affix)
+      this.visitedViews = []
+    },
+    // 删除其他
+    delOthersViews(view: RouteLocationNormalizedLoaded) {
+      this.delOthersVisitedViews(view)
+      this.addCachedView()
+    },
+    // 删除其他tag
+    delOthersVisitedViews(view: RouteLocationNormalizedLoaded) {
+      this.visitedViews = this.visitedViews.filter((v) => {
+        return v?.meta?.affix || v.path === view.path
+      })
+    },
+    // 删除左侧
+    delLeftViews(view: RouteLocationNormalizedLoaded) {
+      const index = findIndex<RouteLocationNormalizedLoaded>(
+        this.visitedViews,
+        (v) => v.path === view.path
+      )
+      if (index > -1) {
+        this.visitedViews = this.visitedViews.filter((v, i) => {
+          return v?.meta?.affix || v.path === view.path || i > index
+        })
+        this.addCachedView()
+      }
+    },
+    // 删除右侧
+    delRightViews(view: RouteLocationNormalizedLoaded) {
+      const index = findIndex<RouteLocationNormalizedLoaded>(
+        this.visitedViews,
+        (v) => v.path === view.path
+      )
+      if (index > -1) {
+        this.visitedViews = this.visitedViews.filter((v, i) => {
+          return v?.meta?.affix || v.path === view.path || i < index
+        })
+        this.addCachedView()
+      }
+    },
+    updateVisitedView(view: RouteLocationNormalizedLoaded) {
+      for (let v of this.visitedViews) {
+        if (v.path === view.path) {
+          v = Object.assign(v, view)
+          break
+        }
+      }
+    }
+  }
+})
+
+export const useTagsViewStoreWithOut = () => {
+  return useTagsViewStore(store)
+}

+ 16 - 0
client/theme.d.ts

@@ -0,0 +1,16 @@
+export type ThemeTypes = {
+  elColorPrimary?: string
+  leftMenuBorderColor?: string
+  leftMenuBgColor?: string
+  leftMenuBgLightColor?: string
+  leftMenuBgActiveColor?: string
+  leftMenuCollapseBgActiveColor?: string
+  leftMenuTextColor?: string
+  leftMenuTextActiveColor?: string
+  logoTitleTextColor?: string
+  logoBorderColor?: string
+  topHeaderBgColor?: string
+  topHeaderTextColor?: string
+  topHeaderHoverColor?: string
+  topToolBorderColor?: string
+}

+ 6 - 0
client/theme.scss

@@ -0,0 +1,6 @@
+// .text-color {
+//   color: var(--el-text-color-regular);
+// }
+// .dark .dark\:text-color {
+//   color: rgba(255, 255, 255, var(--dark-text-color));
+// }

Некоторые файлы не были показаны из-за большого количества измененных файлов