feat: 搭建后台管理框架

This commit is contained in:
Vicky committed 2026-10-13 13:58:05 +08:00
1 parent 6d28bab72b
commit 40546bfd01
238 files changed
+18160 -3837

No files matched your search

+47 -3
View File
@@ -1,5 +1,49 @@
<template><RouterView /></template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { ConfigGlobal } from '@/components/ConfigGlobal'
import { useDesign } from '@/hooks/web/useDesign'
<script setup>
import { RouterView } from 'vue-router'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('app')
const appStore = useAppStore()
const currentSize = computed(() => appStore.getCurrentSize)
const greyMode = computed(() => appStore.getGreyMode)
appStore.initTheme()
</script>
<template>
<ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
</ConfigGlobal>
</template>
<style lang="less">
@prefix-cls: ~'@{namespace}-app';
.size {
width: 100%;
height: 100%;
}
html,
body {
padding: 0 !important;
margin: 0;
overflow: hidden;
.size;
#app {
.size;
}
}
.@{prefix-cls}-grey-mode {
filter: grayscale(100%);
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import request from '@/axios'
import type { UserType } from './types'
interface RoleParams {
roleName: string
}
export const loginApi = (data: UserType): Promise<IResponse<UserType>> => {
return request.post({ url: '/mock/user/login', data })
}
export const loginOutApi = (): Promise<IResponse> => {
return request.get({ url: '/mock/user/loginOut' })
}
export const getAdminRoleApi = (
params: RoleParams
): Promise<IResponse<AppCustomRouteRecordRaw[]>> => {
return request.get({ url: '/mock/role/list', params })
}
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
return request.get({ url: '/mock/role/list2', params })
}
+12
View File
@@ -0,0 +1,12 @@
export type UserLoginType = {
username: string
password: string
}
export type UserType = {
username: string
password: string
role: string
roleId: string
permissions: string | string[]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st26{fill:#fff}</style><g id="图层_11"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="401.773" y1="162.104" x2="401.773" y2="717.596"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M485.03 203.46c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S8.66 530.13 64.45 611.49s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.31-38.96-140.75-38.46-207 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="494.782" y1="599.604" x2="494.782" y2="428.659"><stop offset=".34" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M406.65 428.66h216.44l-22.53 49.03s59.19 57.87-14.13 121.91c-134.28-44.17-221.74-37.1-219.98-38.87 1.77-1.76 40.2-132.07 40.2-132.07z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="116.855" y1="542.49" x2="116.855" y2="405.316"><stop offset=".227" stop-color="#B7ACE0"/><stop offset=".789" stop-color="#E8E7FA"/></linearGradient><path d="M117.64 405.56s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="116.857" y1="420.547" x2="116.857" y2="571.681"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M116.86 571.68c-.55 0-1-.45-1-1V421.55c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="617.984" y1="450.968" x2="617.984" y2="362.644"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M618.49 362.8s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.25-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="617.985" y1="372.451" x2="617.985" y2="469.764"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M617.99 469.76c-.36 0-.64-.29-.64-.64V373.1c0-.36.29-.64.64-.64s.64.29.64.64v96.02c0 .36-.29.64-.64.64z" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="463.902" y1="88.362" x2="429.148" y2="148.558"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="446.52" cy="118.46" r="34.75" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="421.565" y1="118.828" x2="421.565" y2="176.282"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M466.3 137.41h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.69-19.44-19.43-19.44z" style="fill:url(#SVGID_8_)"/><g><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="688.586" y1="540.208" x2="688.586" y2="512.38"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="688.59" cy="526.29" r="13.91" style="fill:url(#SVGID_9_)"/><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="688.635" y1="515.894" x2="688.635" y2="560.69"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M688.64 560.69c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43v43.94a.44.44 0 01-.43.43z" style="fill:url(#SVGID_10_)"/></g><g><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="2622.045" y1="266.481" x2="2451.058" y2="562.64" gradientTransform="matrix(-1 0 0 1 2941.346 0)"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M248.82 393.99c0-24.52-.03-49.03.01-73.54.02-14.37 4.24-18.36 17.97-20.53 41.87-6.61 82.03-18.72 117.91-42.29 10.38-6.82 18.3-7.59 29.06-.47 34.85 23.06 73.26 37.11 114.55 42.8 13.12 1.81 16.84 5.88 16.85 19.25.04 45.72-.4 91.44.18 137.15.34 26.77-8.17 49.99-24.02 70.73-31.46 41.17-74.88 63.76-122.21 80.03-2.5.86-5.83.67-8.36-.23-38.47-13.74-74.58-31.84-104.15-61.09-22.97-22.73-37.84-49.56-37.79-83.22.03-22.87.01-45.73 0-68.59z" style="fill:url(#SVGID_11_)"/><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="2625.25" y1="279.944" x2="2462.749" y2="561.403" gradientTransform="matrix(-1 0 0 1 2941.346 0)"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBLine truncated

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st49{fill:#d4e4fe}</style><g id="图层_5"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="401.193" y1="159.763" x2="401.193" y2="715.254"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M484.45 201.12c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S8.08 527.79 63.87 609.15s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.31-38.96-140.75-38.46-207 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="484.537" y1="604.68" x2="484.537" y2="493.367"><stop offset=".34" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M285.1 583.44c1.77-1.63 77.74-90.07 77.74-90.07h321.13l-99.5 111.31-299.37-21.24z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="616.023" y1="627.266" x2="657.332" y2="555.716"><stop offset="0" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M604.49 620.61L659.43 556.93 633.22 624.12z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="116.275" y1="540.149" x2="116.275" y2="402.974"><stop offset=".003" stop-color="#9A9ADB"/><stop offset=".789" stop-color="#CECDF1"/></linearGradient><path d="M117.06 403.22s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="116.277" y1="418.206" x2="116.277" y2="569.34"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M116.28 569.34c-.55 0-1-.45-1-1V419.21c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="617.404" y1="448.627" x2="617.404" y2="360.303"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M617.91 360.46s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.25-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="617.405" y1="370.11" x2="617.405" y2="467.422"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M617.41 467.42c-.36 0-.64-.29-.64-.64v-96.02c0-.36.29-.64.64-.64.36 0 .64.29.64.64v96.02c0 .35-.29.64-.64.64z" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="463.322" y1="86.02" x2="428.568" y2="146.217"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="445.95" cy="116.12" r="34.75" style="fill:url(#SVGID_8_)"/><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="420.985" y1="116.487" x2="420.985" y2="173.941"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M465.72 135.07h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.69-19.44-19.43-19.44z" style="fill:url(#SVGID_9_)"/><g><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="688.006" y1="537.867" x2="688.006" y2="510.039"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="688.01" cy="523.95" r="13.91" style="fill:url(#SVGID_10_)"/><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="688.056" y1="513.553" x2="688.056" y2="558.349"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M688.06 558.35c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43v43.94a.44.44 0 01-.43.43z" style="fill:url(#SVGID_11_)"/></g><g><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="2879.853" y1="308.382" x2="2737.462" y2="450.774" gradientTransform="matrix(-1 0 0 1 3207.18 0)"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M270.73 392.79l91.4-73.3c7.43 11.92 20.65 19.87 35.7 19.87 16.43 0 30.69-9.48 37.6-23.26l92.11 76.85 10.83-12.98-98.5-82.19c0-.16.01-.31.01-.47 0-23.18-18.86-42.04-42.05-42.04-23.18 0-42.04 18.86-42.04 42.04 0 1.8.13 3.58.35 5.32l-95.98 76.97 10.57 13.19zm101.96-95.48c0-13.86 11.28-25.14 25.14-25.14s25.14 11.28 25.14 25.14-11.28 25.14-25.14 25.14-25.14-11.27-25.14-25.14z" style="fill:url(#SVGID_12_)"/><linearGradient id="SVGID_13_" gradientUnits="uLine truncated

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st26{fill:#fff}</style><g id="图层_16"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="402.832" y1="159.843" x2="402.832" y2="715.335"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M486.09 201.2c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S9.72 527.87 65.5 609.23s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.3-38.96-140.75-38.46-206.99 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="117.913" y1="540.229" x2="117.913" y2="403.055"><stop offset=".227" stop-color="#B7ACE0"/><stop offset=".789" stop-color="#E8E7FA"/></linearGradient><path d="M118.7 403.3s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="117.915" y1="418.287" x2="117.915" y2="569.42"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M117.92 569.42c-.55 0-1-.45-1-1V419.29c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="619.042" y1="448.707" x2="619.042" y2="360.383"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M619.55 360.54s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.26-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="619.043" y1="370.19" x2="619.043" y2="467.503"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M619.04 467.5c-.36 0-.64-.29-.64-.64v-96.02c0-.36.29-.64.64-.64s.64.29.64.64v96.02c.01.35-.28.64-.64.64z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="464.96" y1="86.101" x2="430.206" y2="146.297"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="447.58" cy="116.2" r="34.75" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="422.623" y1="116.567" x2="422.623" y2="174.021"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M467.36 135.15h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.7-19.44-19.43-19.44z" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="689.644" y1="537.948" x2="689.644" y2="510.119"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="689.64" cy="524.03" r="13.91" style="fill:url(#SVGID_8_)"/><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="689.694" y1="513.633" x2="689.694" y2="558.429"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M689.69 558.43c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43V558c0 .24-.19.43-.43.43z" style="fill:url(#SVGID_9_)"/><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="289.384" y1="477.19" x2="289.384" y2="411.226"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M202.07 451.28L270.1 411.23 376.7 411.23 315.15 477.19 237.41 476.01z" style="fill:url(#SVGID_10_)"/><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="454.145" y1="502.809" x2="454.145" y2="420.65"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M386.71 479.55L431.76 420.65 521.58 420.65 423.81 502.81 394.37 495.15z" style="fill:url(#SVGID_11_)"/><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="589.016" y1="472.132" x2="589.016" y2="397.68"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M501.26 458.64l64.79-60.96h110.72l-48.99 66.61a19.243 19.243 0 01-17.85 7.7l-108.67-13.35z" style="fill:url(#SVGID_12_)"/><linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="314.267" y1="607.349" x2="314.267" y2="497.361"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M212.23 592.77L303.67 497.36 416.3 497.36 297.04 607.35 247.57 604.7z" style="fill:url(#SVGID_13_)"/><g><linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="515.604Line truncated

After

Width:  |  Height:  |  Size: 19 KiB

+52
View File
@@ -0,0 +1,52 @@
import { AxiosResponse, InternalAxiosRequestConfig } from './types'
import { ElMessage } from 'element-plus'
import qs from 'qs'
import { SUCCESS_CODE, TRANSFORM_REQUEST_DATA } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
import { objToFormData } from '@/utils'
const defaultRequestInterceptors = (config: InternalAxiosRequestConfig) => {
if (
config.method === 'post' &&
config.headers['Content-Type'] === 'application/x-www-form-urlencoded'
) {
config.data = qs.stringify(config.data)
} else if (
TRANSFORM_REQUEST_DATA &&
config.method === 'post' &&
config.headers['Content-Type'] === 'multipart/form-data'
) {
config.data = objToFormData(config.data)
}
if (config.method === 'get' && config.params) {
let url = config.url as string
url += '?'
const keys = Object.keys(config.params)
for (const key of keys) {
if (config.params[key] !== void 0 && config.params[key] !== null) {
url += `${key}=${encodeURIComponent(config.params[key])}&`
}
}
url = url.substring(0, url.length - 1)
config.params = {}
config.url = url
}
return config
}
const defaultResponseInterceptors = (response: AxiosResponse) => {
if (response?.config?.responseType === 'blob') {
// 如果是文件流,直接过
return response
} else if (response.data.code === SUCCESS_CODE) {
return response.data
} else {
ElMessage.error(response?.data?.message)
if (response?.data?.code === 401) {
const userStore = useUserStoreWithOut()
userStore.logout()
}
}
}
export { defaultResponseInterceptors, defaultRequestInterceptors }
+41
View File
@@ -0,0 +1,41 @@
import service from './service'
import { CONTENT_TYPE } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
const request = (option: AxiosConfig) => {
const { url, method, params, data, headers, responseType } = option
const userStore = useUserStoreWithOut()
return service.request({
url: url,
method,
params,
data,
responseType: responseType,
headers: {
'Content-Type': CONTENT_TYPE,
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken ?? '',
...headers
}
})
}
export default {
get: <T = any>(option: AxiosConfig) => {
return request({ method: 'get', ...option }) as Promise<IResponse<T>>
},
post: <T = any>(option: AxiosConfig) => {
return request({ method: 'post', ...option }) as Promise<IResponse<T>>
},
delete: <T = any>(option: AxiosConfig) => {
return request({ method: 'delete', ...option }) as Promise<IResponse<T>>
},
put: <T = any>(option: AxiosConfig) => {
return request({ method: 'put', ...option }) as Promise<IResponse<T>>
},
cancelRequest: (url: string | string[]) => {
return service.cancelRequest(url)
},
cancelAllRequest: () => {
return service.cancelAllRequest()
}
}
+74
View File
@@ -0,0 +1,74 @@
import axios, { AxiosError } from 'axios'
import { defaultRequestInterceptors, defaultResponseInterceptors } from './config'
import { AxiosInstance, InternalAxiosRequestConfig, RequestConfig, AxiosResponse } from './types'
import { ElMessage } from 'element-plus'
import { REQUEST_TIMEOUT } from '@/constants'
export const PATH_URL = import.meta.env.VITE_API_BASE_PATH
const abortControllerMap: Map<string, AbortController> = new Map()
const axiosInstance: AxiosInstance = axios.create({
timeout: REQUEST_TIMEOUT,
baseURL: PATH_URL
})
axiosInstance.interceptors.request.use((res: InternalAxiosRequestConfig) => {
const controller = new AbortController()
const url = res.url || ''
res.signal = controller.signal
abortControllerMap.set(url, controller)
return res
})
axiosInstance.interceptors.response.use(
(res: AxiosResponse) => {
const url = res.config.url || ''
abortControllerMap.delete(url)
// 这里不能做任何处理,否则后面的 interceptors 拿不到完整的上下文了
return res
},
(error: AxiosError) => {
console.log('err: ' + error) // for debug
ElMessage.error(error.message)
return Promise.reject(error)
}
)
axiosInstance.interceptors.request.use(defaultRequestInterceptors)
axiosInstance.interceptors.response.use(defaultResponseInterceptors)
const service = {
request: (config: RequestConfig) => {
return new Promise((resolve, reject) => {
if (config.interceptors?.requestInterceptors) {
config = config.interceptors.requestInterceptors(config as any)
}
axiosInstance
.request(config)
.then((res) => {
resolve(res)
})
.catch((err: any) => {
reject(err)
})
})
},
cancelRequest: (url: string | string[]) => {
const urlList = Array.isArray(url) ? url : [url]
for (const _url of urlList) {
abortControllerMap.get(_url)?.abort()
abortControllerMap.delete(_url)
}
},
cancelAllRequest() {
for (const [_, controller] of abortControllerMap) {
controller.abort()
}
abortControllerMap.clear()
}
}
export default service
+31
View File
@@ -0,0 +1,31 @@
import type {
InternalAxiosRequestConfig,
AxiosResponse,
AxiosRequestConfig,
AxiosInstance,
AxiosRequestHeaders,
AxiosError
} from 'axios'
interface RequestInterceptors<T> {
// 请求拦截
requestInterceptors?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig
requestInterceptorsCatch?: (err: any) => any
// 响应拦截
responseInterceptors?: (config: T) => T
responseInterceptorsCatch?: (err: any) => any
}
interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
interceptors?: RequestInterceptors<T>
}
export {
AxiosResponse,
RequestInterceptors,
RequestConfig,
AxiosInstance,
InternalAxiosRequestConfig,
AxiosRequestHeaders,
AxiosError
}
+3
View File
@@ -0,0 +1,3 @@
import Backtop from './src/Backtop.vue'
export { Backtop }
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { ElBacktop } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls, variables } = useDesign()
const prefixCls = getPrefixCls('backtop')
</script>
<template>
<ElBacktop
:class="`${prefixCls}-backtop`"
:target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`"
/>
</template>
+3
View File
@@ -0,0 +1,3 @@
import Breadcrumb from './src/Breadcrumb.vue'
export { Breadcrumb }
@@ -0,0 +1,126 @@
<script lang="tsx">
import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus'
import { ref, watch, computed, unref, defineComponent, TransitionGroup } from 'vue'
import { useRouter } from 'vue-router'
import { usePermissionStore } from '@/store/modules/permission'
import { filterBreadcrumb } from './helper'
import { filter, treeToList } from '@/utils/tree'
import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
import { useI18n } from '@/hooks/web/useI18n'
import { Icon } from '@/components/Icon'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('breadcrumb')
const appStore = useAppStore()
// 面包屑图标
const breadcrumbIcon = computed(() => appStore.getBreadcrumbIcon)
export default defineComponent({
name: 'Breadcrumb',
setup() {
const { currentRoute } = useRouter()
const { t } = useI18n()
const levelList = ref<AppRouteRecordRaw[]>([])
const permissionStore = usePermissionStore()
const menuRouters = computed(() => {
const routers = permissionStore.getRouters
return filterBreadcrumb(routers)
})
const getBreadcrumb = () => {
const currentPath = currentRoute.value.matched.slice(-1)[0].path
levelList.value = filter<AppRouteRecordRaw>(unref(menuRouters), (node: AppRouteRecordRaw) => {
return node.path === currentPath
})
}
const renderBreadcrumb = () => {
const breadcrumbList = treeToList<AppRouteRecordRaw[]>(unref(levelList))
return breadcrumbList.map((v) => {
const disabled = !v.redirect || v.redirect === 'noredirect'
const meta = v.meta as RouteMeta
return (
<ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
{meta?.icon && breadcrumbIcon.value ? (
<>
<Icon icon={meta.icon} class="mr-[5px]"></Icon> {t(v?.meta?.title)}
</>
) : (
t(v?.meta?.title)
)}
</ElBreadcrumbItem>
)
})
}
watch(
() => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => {
if (route.path.startsWith('/redirect/')) {
return
}
getBreadcrumb()
},
{
immediate: true
}
)
return () => (
<ElBreadcrumb separator="/" class={`${prefixCls} flex items-center h-full ml-[10px]`}>
<TransitionGroup appear enter-active-class="animate__animated animate__fadeInRight">
{renderBreadcrumb()}
</TransitionGroup>
</ElBreadcrumb>
)
}
})
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{elNamespace}-breadcrumb';
.@{prefix-cls} {
:deep(&__item) {
display: flex;
.@{prefix-cls}__inner {
display: flex;
align-items: center;
color: var(--top-header-text-color);
&:hover {
color: var(--el-color-primary);
}
}
}
:deep(&__item):not(:last-child) {
.@{prefix-cls}__inner {
color: var(--top-header-text-color);
&:hover {
color: var(--el-color-primary);
}
}
}
:deep(&__item):last-child {
.@{prefix-cls}__inner {
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-text-color-placeholder);
}
}
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { pathResolve } from '@/utils/routerHelper'
import type { RouteMeta } from 'vue-router'
export const filterBreadcrumb = (
routes: AppRouteRecordRaw[],
parentPath = ''
): AppRouteRecordRaw[] => {
const res: AppRouteRecordRaw[] = []
for (const route of routes) {
const meta = route?.meta as RouteMeta
if (meta.hidden && !meta.canTo) {
continue
}
const data: AppRouteRecordRaw =
!meta.alwaysShow && route.children?.length === 1
? { ...route.children[0], path: pathResolve(route.path, route.children[0].path) }
: { ...route }
data.path = pathResolve(parentPath, data.path)
if (data.children) {
data.children = filterBreadcrumb(data.children, data.path)
}
if (data) {
res.push(data)
}
}
return res
}
+3
View File
@@ -0,0 +1,3 @@
import BaseButton from './src/Button.vue'
export { BaseButton }
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign'
import { ElButton, ComponentSize, ButtonType } from 'element-plus'
import { PropType, Component, computed, unref } from 'vue'
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
const getTheme = computed(() => appStore.getTheme)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('button')
const props = defineProps({
size: {
type: String as PropType<ComponentSize>,
default: undefined
},
type: {
type: String as PropType<ButtonType>,
default: 'default'
},
disabled: {
type: Boolean,
default: false
},
plain: {
type: Boolean,
default: false
},
text: {
type: Boolean,
default: false
},
bg: {
type: Boolean,
default: false
},
link: {
type: Boolean,
default: false
},
round: {
type: Boolean,
default: false
},
circle: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
loadingIcon: {
type: [String, Object] as PropType<String | Component>,
default: undefined
},
icon: {
type: [String, Object] as PropType<String | Component>,
default: undefined
},
autofocus: {
type: Boolean,
default: false
},
nativeType: {
type: String as PropType<'button' | 'submit' | 'reset'>,
default: 'button'
},
autoInsertSpace: {
type: Boolean,
default: false
},
color: {
type: String,
default: ''
},
darker: {
type: Boolean,
default: false
},
tag: {
type: [String, Object] as PropType<String | Component>,
default: 'button'
}
})
const emits = defineEmits(['click'])
const color = computed(() => {
const { type } = props
if (type === 'primary') {
return unref(getTheme).elColorPrimary
}
return ''
})
const style = computed(() => {
const { type } = props
if (type === 'primary') {
return '--el-button-text-color: #fff; --el-button-hover-text-color: #fff'
}
return ''
})
</script>
<template>
<ElButton
:class="`${prefixCls} color-#fff`"
v-bind="{ ...props }"
:color="color"
:style="style"
@click="() => emits('click')"
>
<slot></slot>
<slot name="icon"></slot>
<slot name="loading"></slot>
</ElButton>
</template>
+3
View File
@@ -0,0 +1,3 @@
import Collapse from './src/Collapse.vue'
export { Collapse }
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, unref } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('collapse')
defineProps({
color: propTypes.string.def('')
})
const appStore = useAppStore()
const collapse = computed(() => appStore.getCollapse)
const toggleCollapse = () => {
const collapsed = unref(collapse)
appStore.setCollapse(!collapsed)
}
</script>
<template>
<div :class="prefixCls" @click="toggleCollapse">
<Icon
:size="18"
:icon="collapse ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'"
:color="color"
class="cursor-pointer"
/>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
import ConfigGlobal from './src/ConfigGlobal.vue'
export type { ConfigGlobalTypes } from './src/types'
export { ConfigGlobal }
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { provide, computed, watch, onMounted } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { ComponentSize, ElConfigProvider } from 'element-plus'
import { useLocaleStore } from '@/store/modules/locale'
import { useWindowSize } from '@vueuse/core'
import { useAppStore } from '@/store/modules/app'
import { setCssVar } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign'
const { variables } = useDesign()
const appStore = useAppStore()
const props = defineProps({
size: propTypes.oneOf<ComponentSize>(['default', 'small', 'large']).def('default')
})
provide('configGlobal', props)
// 初始化所有主题色
onMounted(() => {
appStore.setCssVarTheme()
})
const { width } = useWindowSize()
// 监听窗口变化
watch(
() => width.value,
(width: number) => {
if (width < 768) {
!appStore.getMobile ? appStore.setMobile(true) : undefined
setCssVar('--left-menu-min-width', '0')
appStore.setCollapse(true)
appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
} else {
appStore.getMobile ? appStore.setMobile(false) : undefined
setCssVar('--left-menu-min-width', '64px')
}
},
{
immediate: true
}
)
// 多语言相关
const localeStore = useLocaleStore()
const currentLocale = computed(() => localeStore.currentLocale)
</script>
<template>
<ElConfigProvider
:namespace="variables.elNamespace"
:locale="currentLocale.elLocale"
:message="{ max: 1 }"
:size="size"
>
<slot></slot>
</ElConfigProvider>
</template>
@@ -0,0 +1,5 @@
import { ComponentSize } from 'element-plus'
export interface ConfigGlobalTypes {
size?: ComponentSize
}
@@ -0,0 +1,3 @@
import ContentDetailWrap from './src/ContentDetailWrap.vue'
export { ContentDetailWrap }
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ElCard } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('content-detail-wrap')
defineProps({
title: propTypes.string.def(''),
message: propTypes.string.def('')
})
</script>
<template>
<div :class="[`${prefixCls}-container`, 'relative']">
<ElCard :class="[`${prefixCls}-body`, 'mb-20px']" shadow="never">
<div class="mb-20px pb-20px" style="border-bottom: 1px solid var(--el-border-color)">
<slot name="header"></slot>
</div>
<slot></slot>
</ElCard>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
import ContentWrap from './src/ContentWrap.vue'
export { ContentWrap }
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ElCard, ElTooltip } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('content-wrap')
defineProps({
title: propTypes.string.def(''),
message: propTypes.string.def('')
})
</script>
<template>
<ElCard :class="[prefixCls]" shadow="never">
<template v-if="title" #header>
<div class="flex items-center">
<span class="text-16px font-700">{{ title }}</span>
<ElTooltip v-if="message" effect="dark" placement="right">
<template #content>
<div class="max-w-200px">{{ message }}</div>
</template>
<Icon class="ml-5px" icon="bi:question-circle-fill" :size="14" />
</ElTooltip>
</div>
</template>
<div>
<slot></slot>
</div>
</ElCard>
</template>
+12
View File
@@ -0,0 +1,12 @@
import ContextMenu from './src/ContextMenu.vue'
import { ElDropdown } from 'element-plus'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
export type { ContextMenuSchema } from './src/types'
export interface ContextMenuExpose {
elDropdownMenuRef: ComponentRef<typeof ElDropdown>
tagItem: RouteLocationNormalizedLoaded
}
export { ContextMenu }
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { PropType, ref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { useDesign } from '@/hooks/web/useDesign'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { ContextMenuSchema } from './types'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('context-menu')
const { t } = useI18n()
const emit = defineEmits(['visibleChange'])
const props = defineProps({
schema: {
type: Array as PropType<ContextMenuSchema[]>,
default: () => []
},
trigger: {
type: String as PropType<'click' | 'hover' | 'focus' | 'contextmenu'>,
default: 'contextmenu'
},
tagItem: {
type: Object as PropType<RouteLocationNormalizedLoaded>,
default: () => ({})
}
})
const command = (item: ContextMenuSchema) => {
item.command && item.command(item)
}
const visibleChange = (visible: boolean) => {
emit('visibleChange', visible, props.tagItem)
}
const elDropdownMenuRef = ref<ComponentRef<typeof ElDropdown>>()
defineExpose({
elDropdownMenuRef,
tagItem: props.tagItem
})
</script>
<template>
<ElDropdown
ref="elDropdownMenuRef"
:class="prefixCls"
:trigger="trigger"
placement="bottom-start"
@command="command"
@visible-change="visibleChange"
popper-class="v-context-menu-popper"
>
<slot></slot>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(item, index) in schema"
:key="`dropdown${index}`"
:divided="item.divided"
:disabled="item.disabled"
:command="item"
>
<Icon :icon="item.icon" /> {{ t(item.label) }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
@@ -0,0 +1,7 @@
export interface ContextMenuSchema {
disabled?: boolean
divided?: boolean
icon?: string
label: string
command?: (item: ContextMenuSchema) => void
}
+5
View File
@@ -0,0 +1,5 @@
import Descriptions from './src/Descriptions.vue'
export type { DescriptionsSchema } from './src/types'
export { Descriptions }
@@ -0,0 +1,161 @@
<script lang="tsx">
import { ElCollapseTransition, ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign'
import { propTypes } from '@/utils/propTypes'
import { ref, unref, PropType, computed, defineComponent } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { DescriptionsSchema } from './types'
import { Icon } from '@/components/Icon'
import { get } from 'lodash-es'
const appStore = useAppStore()
const mobile = computed(() => appStore.getMobile)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('descriptions')
export default defineComponent({
name: 'Descriptions',
props: {
title: propTypes.string.def(''),
message: propTypes.string.def(''),
collapse: propTypes.bool.def(true),
border: propTypes.bool.def(true),
column: propTypes.number.def(2),
size: propTypes.oneOf(['large', 'default', 'small']).def('default'),
direction: propTypes.oneOf(['horizontal', 'vertical']).def('horizontal'),
extra: propTypes.string.def(''),
schema: {
type: Array as PropType<DescriptionsSchema[]>,
default: () => []
},
data: {
type: Object as PropType<any>,
default: () => ({})
}
},
setup(props, { slots, attrs }) {
const getBindValue = computed((): any => {
const delArr: string[] = ['title', 'message', 'collapse', 'schema', 'data', 'class']
const obj = { ...attrs, ...props }
for (const key in obj) {
if (delArr.indexOf(key) !== -1) {
delete obj[key]
}
}
if (unref(mobile)) {
obj.direction = 'vertical'
}
return obj
})
const getBindItemValue = (item: DescriptionsSchema) => {
const delArr: string[] = ['field']
const obj = { ...item }
for (const key in obj) {
if (delArr.indexOf(key) !== -1) {
delete obj[key]
}
}
return obj
}
// 折叠
const show = ref(true)
const toggleClick = () => {
if (props.collapse) {
show.value = !unref(show)
}
}
return () => {
return (
<div
class={[
prefixCls,
'bg-[var(--el-color-white)] dark:bg-[var(--el-bg-color)] dark:border-[var(--el-border-color)] dark:border-1px'
]}
>
{props.title ? (
<div
class={[
`${prefixCls}-header`,
'relative h-50px flex justify-between items-center layout-border__bottom px-10px cursor-pointer'
]}
onClick={toggleClick}
>
<div class={[`${prefixCls}-header__title`, 'relative font-18px font-bold ml-10px']}>
<div class="flex items-center">
{props.title}
{props.message ? (
<ElTooltip content={props.message} placement="right">
<Icon icon="bi:question-circle-fill" class="ml-5px" size={14} />
</ElTooltip>
) : null}
</div>
</div>
{props.collapse ? <Icon icon={show.value ? 'ep:arrow-down' : 'ep:arrow-up'} /> : null}
</div>
) : null}
<ElCollapseTransition>
<div v-show={unref(show)} class={[`${prefixCls}-content`]}>
<ElDescriptions {...unref(getBindValue)}>
{{
extra: () => (slots['extra'] ? slots['extra']() : props.extra),
default: () => {
return props.schema.map((item) => {
return (
<ElDescriptionsItem key={item.field} {...getBindItemValue(item)}>
{{
label: () => (item.slots?.label ? item.slots?.label(item) : item.label),
default: () =>
item.slots?.default
? item.slots?.default(props.data)
: get(props.data, item.field)
}}
</ElDescriptionsItem>
)
})
}
}}
</ElDescriptions>
</div>
</ElCollapseTransition>
</div>
)
}
}
})
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-descriptions';
:deep(.@{elNamespace}-descriptions__header) {
display: none !important;
}
.@{prefix-cls}-header {
&__title {
&::after {
position: absolute;
top: 3px;
left: -10px;
width: 4px;
height: 70%;
background: var(--el-color-primary);
content: '';
}
}
}
.@{prefix-cls}-content {
:deep(.@{elNamespace}-descriptions__cell) {
width: 0;
}
}
</style>
@@ -0,0 +1,15 @@
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
slots?: {
default?: (...args: any[]) => JSX.Element | null
label?: (...args: any[]) => JSX.Element | null
}
}
+3
View File
@@ -0,0 +1,3 @@
import Dialog from './src/Dialog.vue'
export { Dialog }
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ElDialog, ElScrollbar } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue'
import { isNumber } from '@/utils/is'
const slots = useSlots()
const props = defineProps({
modelValue: propTypes.bool.def(false),
title: propTypes.string.def('Dialog'),
fullscreen: propTypes.bool.def(true),
maxHeight: propTypes.oneOfType([String, Number]).def('400px')
})
const getBindValue = computed(() => {
const delArr: string[] = ['fullscreen', 'title', 'maxHeight']
const attrs = useAttrs()
const obj = { ...attrs, ...props }
for (const key in obj) {
if (delArr.indexOf(key) !== -1) {
delete obj[key]
}
}
return obj
})
const isFullscreen = ref(false)
const toggleFull = () => {
isFullscreen.value = !unref(isFullscreen)
}
const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
watch(
() => isFullscreen.value,
async (val: boolean) => {
await nextTick()
if (val) {
const windowHeight = document.documentElement.offsetHeight
dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
} else {
dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight
}
},
{
immediate: true
}
)
const dialogStyle = computed(() => {
return {
height: unref(dialogHeight)
}
})
</script>
<template>
<ElDialog
v-bind="getBindValue"
:fullscreen="isFullscreen"
destroy-on-close
lock-scroll
draggable
top="0"
:close-on-click-modal="false"
:show-close="false"
>
<template #header="{ close }">
<div class="flex justify-between items-center h-54px pl-15px pr-15px relative">
<slot name="title">
{{ title }}
</slot>
<div
class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]"
>
<Icon
v-if="fullscreen"
class="cursor-pointer is-hover !h-54px mr-10px"
:icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
color="var(--el-color-info)"
hover-color="var(--el-color-primary)"
@click="toggleFull"
/>
<Icon
class="cursor-pointer is-hover !h-54px"
icon="ep:close"
hover-color="var(--el-color-primary)"
color="var(--el-color-info)"
@click="close"
/>
</div>
</div>
</template>
<ElScrollbar :style="dialogStyle">
<slot></slot>
</ElScrollbar>
<template v-if="slots.footer" #footer>
<slot name="footer"></slot>
</template>
</ElDialog>
</template>
<style lang="less">
.@{elNamespace}-overlay-dialog {
display: flex;
justify-content: center;
align-items: center;
}
.@{elNamespace}-dialog {
margin: 0 !important;
&__header {
height: 54px;
padding: 0;
margin-right: 0 !important;
border-bottom: 1px solid var(--el-border-color);
}
&__body {
padding: 15px !important;
}
&__footer {
border-top: 1px solid var(--el-border-color);
}
&__headerbtn {
top: 0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import Editor from './src/Editor.vue'
import { IDomEditor } from '@wangeditor/editor'
export interface EditorExpose {
getEditorRef: () => Promise<IDomEditor>
}
export { Editor }
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { onBeforeUnmount, computed, PropType, unref, nextTick, ref, watch, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { IDomEditor, IEditorConfig, i18nChangeLanguage } from '@wangeditor/editor'
import { propTypes } from '@/utils/propTypes'
import { isNumber } from '@/utils/is'
import { ElMessage } from 'element-plus'
import { useLocaleStore } from '@/store/modules/locale'
const localeStore = useLocaleStore()
const currentLocale = computed(() => localeStore.getCurrentLocale)
i18nChangeLanguage(unref(currentLocale).lang)
const props = defineProps({
editorId: propTypes.string.def('wangeEditor-1'),
height: propTypes.oneOfType([Number, String]).def('500px'),
editorConfig: {
type: Object as PropType<IEditorConfig>,
default: () => undefined
},
modelValue: propTypes.string.def('')
})
const emit = defineEmits(['change', 'update:modelValue'])
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef<IDomEditor>()
const valueHtml = ref('')
watch(
() => props.modelValue,
(val: string) => {
if (val === unref(valueHtml)) return
valueHtml.value = val
},
{
immediate: true
}
)
// 监听
watch(
() => valueHtml.value,
(val: string) => {
emit('update:modelValue', val)
}
)
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor
}
// 编辑器配置
const editorConfig = computed((): IEditorConfig => {
return Object.assign(
{
readOnly: false,
customAlert: (s: string, t: string) => {
switch (t) {
case 'success':
ElMessage.success(s)
break
case 'info':
ElMessage.info(s)
break
case 'warning':
ElMessage.warning(s)
break
case 'error':
ElMessage.error(s)
break
default:
ElMessage.info(s)
break
}
},
autoFocus: false,
scroll: true,
uploadImgShowBase64: true
},
props.editorConfig || {}
)
})
const editorStyle = computed(() => {
return {
height: isNumber(props.height) ? `${props.height}px` : props.height
}
})
// 回调函数
const handleChange = (editor: IDomEditor) => {
emit('change', editor)
}
// 组件销毁时,及时销毁编辑器
onBeforeUnmount(() => {
const editor = unref(editorRef.value)
// 销毁,并移除 editor
editor?.destroy()
})
const getEditorRef = async (): Promise<IDomEditor> => {
await nextTick()
return unref(editorRef.value) as IDomEditor
}
defineExpose({
getEditorRef
})
</script>
<template>
<div class="border-1 border-solid border-[var(--el-border-color)] z-10">
<!-- 工具栏 -->
<Toolbar
:editor="editorRef"
:editorId="editorId"
class="border-0 b-b-1 border-solid border-[var(--el-border-color)]"
/>
<!-- 编辑器 -->
<Editor
v-model="valueHtml"
:editorId="editorId"
:defaultConfig="editorConfig"
:style="editorStyle"
@on-change="handleChange"
@on-created="handleCreated"
/>
</div>
</template>
<style src="@wangeditor/editor/dist/css/style.css"></style>
+3
View File
@@ -0,0 +1,3 @@
import Error from './src/Error.vue'
export { Error }
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import pageError from '@/assets/svgs/404.svg'
import networkError from '@/assets/svgs/500.svg'
import noPermission from '@/assets/svgs/403.svg'
import { propTypes } from '@/utils/propTypes'
import { useI18n } from '@/hooks/web/useI18n'
import { ElButton } from 'element-plus'
interface ErrorMap {
url: string
message: string
buttonText: string
}
const { t } = useI18n()
const errorMap: {
[key: string]: ErrorMap
} = {
'404': {
url: pageError,
message: t('error.pageError'),
buttonText: t('error.returnToHome')
},
'500': {
url: networkError,
message: t('error.networkError'),
buttonText: t('error.returnToHome')
},
'403': {
url: noPermission,
message: t('error.noPermission'),
buttonText: t('error.returnToHome')
}
}
const props = defineProps({
type: propTypes.string.validate((v: string) => ['404', '500', '403'].includes(v)).def('404')
})
const emit = defineEmits(['errorClick'])
const btnClick = () => {
emit('errorClick', props.type)
}
</script>
<template>
<div class="flex justify-center">
<div class="text-center">
<img width="350" :src="errorMap[type].url" alt="" />
<div class="text-14px text-[var(--el-color-info)]">{{ errorMap[type].message }}</div>
<div class="mt-20px">
<ElButton type="primary" @click="btnClick">{{ errorMap[type].buttonText }}</ElButton>
</div>
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
import Form from './src/Form.vue'
import type { FormSchema, FormSetProps } from './src/types'
export type {
ComponentNameEnum,
ComponentName,
InputComponentProps,
AutocompleteComponentProps,
InputNumberComponentProps,
SelectOption,
SelectComponentProps,
SelectV2ComponentProps,
CascaderComponentProps,
SwitchComponentProps,
RateComponentProps,
ColorPickerComponentProps,
TransferComponentProps,
RadioOption,
RadioGroupComponentProps,
RadioButtonComponentProps,
CheckboxOption,
CheckboxGroupComponentProps,
DividerComponentProps,
DatePickerComponentProps,
DateTimePickerComponentProps,
TimePickerComponentProps,
TimeSelectComponentProps,
ColProps,
FormSetProps,
FormItemProps,
FormSchema,
FormProps,
PlaceholderModel,
InputPasswordComponentProps,
TreeSelectComponentProps
} from './src/types'
export interface FormExpose {
setValues: (data: Recordable) => void
setProps: (props: Recordable) => void
delSchema: (field: string) => void
addSchema: (formSchema: FormSchema, index?: number) => void
setSchema: (schemaProps: FormSetProps[]) => void
formModel: Recordable
getComponentExpose: (field: string) => any
getFormItemExpose: (field: string) => any
}
export { Form }
+420
View File
@@ -0,0 +1,420 @@
<script lang="tsx">
import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue'
import {
ElForm,
ElFormItem,
ElRow,
ElCol,
FormRules,
ComponentSize
// FormItemProp
} from 'element-plus'
import { componentMap } from './helper/componentMap'
import { propTypes } from '@/utils/propTypes'
import { getSlot } from '@/utils/tsxHelper'
import {
setTextPlaceholder,
setGridProp,
setComponentProps,
setItemComponentSlots,
initModel
} from './helper'
import { useRenderSelect } from './components/useRenderSelect'
import { useRenderRadio } from './components/useRenderRadio'
import { useRenderCheckbox } from './components/useRenderCheckbox'
import { useDesign } from '@/hooks/web/useDesign'
import { findIndex } from '@/utils'
import { get, set } from 'lodash-es'
import { FormProps } from './types'
import {
FormSchema,
FormSetProps,
ComponentNameEnum,
SelectComponentProps,
RadioGroupComponentProps,
CheckboxGroupComponentProps
} from './types'
const { renderSelectOptions } = useRenderSelect()
const { renderRadioOptions } = useRenderRadio()
const { renderCheckboxOptions } = useRenderCheckbox()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('form')
export default defineComponent({
name: 'Form',
props: {
// 生成Form的布局结构数组
schema: {
type: Array as PropType<FormSchema[]>,
default: () => []
},
// 是否需要栅格布局
isCol: propTypes.bool.def(true),
// 表单数据对象
model: {
type: Object as PropType<any>,
default: () => ({})
},
// 是否自动设置placeholder
autoSetPlaceholder: propTypes.bool.def(true),
// 是否自定义内容
isCustom: propTypes.bool.def(false),
// 表单label宽度
labelWidth: propTypes.oneOfType([String, Number]).def('auto'),
rules: {
type: Object as PropType<FormRules>,
default: () => ({})
},
labelPosition: propTypes.oneOf(['left', 'right', 'top']).def('right'),
labelSuffix: propTypes.string.def(''),
hideRequiredAsterisk: propTypes.bool.def(false),
requireAsteriskPosition: propTypes.oneOf(['left', 'right']).def('left'),
showMessage: propTypes.bool.def(true),
inlineMessage: propTypes.bool.def(false),
statusIcon: propTypes.bool.def(false),
validateOnRuleChange: propTypes.bool.def(true),
size: {
type: String as PropType<ComponentSize>,
default: undefined
},
disabled: propTypes.bool.def(false),
scrollToError: propTypes.bool.def(false),
scrollToErrorOffset: propTypes.oneOfType([Boolean, Object]).def(undefined)
// onValidate: {
// type: Function as PropType<(prop: FormItemProp, isValid: boolean, message: string) => void>,
// default: () => {}
// }
},
emits: ['register'],
setup(props, { slots, expose, emit }) {
// element form 实例
const elFormRef = ref<ComponentRef<typeof ElForm>>()
const mergeProps = ref<FormProps>({})
const getProps = computed(() => {
const propsObj = { ...props }
Object.assign(propsObj, unref(mergeProps))
return propsObj
})
// 存储表单实例
const formComponents = ref({})
// 存储form-item实例
const formItemComponents = ref({})
// 表单数据
const formModel = ref<Recordable>(props.model)
onMounted(() => {
emit('register', unref(elFormRef)?.$parent, unref(elFormRef))
})
// 对表单赋值
const setValues = (data: Recordable = {}) => {
formModel.value = Object.assign(unref(formModel), data)
}
const setProps = (props: FormProps = {}) => {
mergeProps.value = Object.assign(unref(mergeProps), props)
}
const delSchema = (field: string) => {
const { schema } = unref(getProps)
const index = findIndex(schema, (v: FormSchema) => v.field === field)
if (index > -1) {
schema.splice(index, 1)
}
}
const addSchema = (formSchema: FormSchema, index?: number) => {
const { schema } = unref(getProps)
if (index !== void 0) {
schema.splice(index, 0, formSchema)
return
}
schema.push(formSchema)
}
const setSchema = (schemaProps: FormSetProps[]) => {
const { schema } = unref(getProps)
for (const v of schema) {
for (const item of schemaProps) {
if (v.field === item.field) {
set(v, item.path, item.value)
}
}
}
}
const getOptions = async (fn: Function, item: FormSchema) => {
const options = await fn()
setSchema([
{
field: item.field,
path:
item.component === ComponentNameEnum.TREE_SELECT
? 'componentProps.data'
: 'componentProps.options',
value: options
}
])
}
/**
* @description: 获取表单组件实例
* @param filed 表单字段
*/
const getComponentExpose = (filed: string) => {
return unref(formComponents)[filed]
}
/**
* @description: 获取formItem实例
* @param filed 表单字段
*/
const getFormItemExpose = (filed: string) => {
return unref(formItemComponents)[filed]
}
const setComponentRefMap = (ref: any, filed: string) => {
formComponents.value[filed] = ref
}
const setFormItemRefMap = (ref: any, filed: string) => {
formItemComponents.value[filed] = ref
}
expose({
setValues,
formModel,
setProps,
delSchema,
addSchema,
setSchema,
getComponentExpose,
getFormItemExpose
})
// 监听表单结构化数组,重新生成formModel
watch(
() => unref(getProps).schema,
(schema = []) => {
formModel.value = initModel(schema, unref(formModel))
},
{
immediate: true,
deep: true
}
)
// 渲染包裹标签,是否使用栅格布局
const renderWrap = () => {
const { isCol } = unref(getProps)
const content = isCol ? (
<ElRow gutter={20}>{renderFormItemWrap()}</ElRow>
) : (
renderFormItemWrap()
)
return content
}
// 是否要渲染el-col
const renderFormItemWrap = () => {
// hidden属性表示隐藏,不做渲染
const { schema = [], isCol } = unref(getProps)
return schema
.filter((v) => !v.remove && !v.hidden)
.map((item) => {
// 如果是 Divider 组件,需要自己占用一行
const isDivider = item.component === 'Divider'
const Com = componentMap['Divider'] as ReturnType<typeof defineComponent>
return isDivider ? (
<Com {...{ contentPosition: 'left', ...item.componentProps }}>{item?.label}</Com>
) : isCol ? (
// 如果需要栅格,需要包裹 ElCol
<ElCol {...setGridProp(item.colProps)}>{renderFormItem(item)}</ElCol>
) : (
renderFormItem(item)
)
})
}
// 渲染formItem
const renderFormItem = (item: FormSchema) => {
// 如果有optionApi,优先使用optionApi
if (item.optionApi) {
// 内部自动调用接口,不影响其它渲染
getOptions(item.optionApi, item)
}
const formItemSlots: Recordable = {
default: () => {
if (item?.formItemProps?.slots?.default) {
return item?.formItemProps?.slots?.default(formModel.value)
} else {
const Com = componentMap[item.component as string] as ReturnType<typeof defineComponent>
const { autoSetPlaceholder } = unref(getProps)
const componentSlots = (item?.componentProps as any)?.slots || {}
const slotsMap: Recordable = {
...setItemComponentSlots(componentSlots)
}
// // 如果是select组件,并且没有自定义模板,自动渲染options
if (item.component === ComponentNameEnum.SELECT) {
slotsMap.default = !componentSlots.default
? () => renderSelectOptions(item)
: () => {
return componentSlots.default(
unref((item?.componentProps as SelectComponentProps)?.options)
)
}
}
// 虚拟列表
if (item.component === ComponentNameEnum.SELECT_V2 && componentSlots.default) {
slotsMap.default = ({ item }) => {
return componentSlots.default(item)
}
}
// 单选框组和按钮样式
if (
item.component === ComponentNameEnum.RADIO_GROUP ||
item.component === ComponentNameEnum.RADIO_BUTTON
) {
slotsMap.default = !componentSlots.default
? () => renderRadioOptions(item)
: () => {
return componentSlots.default(
unref((item?.componentProps as CheckboxGroupComponentProps)?.options)
)
}
}
// 多选框组和按钮样式
if (
item.component === ComponentNameEnum.CHECKBOX_GROUP ||
item.component === ComponentNameEnum.CHECKBOX_BUTTON
) {
slotsMap.default = !componentSlots.default
? () => renderCheckboxOptions(item)
: () => {
return componentSlots.default(
unref((item?.componentProps as RadioGroupComponentProps)?.options)
)
}
}
const Comp = () => {
// 如果field是多层路径,需要转换成对象
const itemVal = computed({
get: () => {
return get(formModel.value, item.field)
},
set: (val) => {
set(formModel.value, item.field, val)
}
})
return (
<Com
vModel={itemVal.value}
ref={(el: any) => setComponentRefMap(el, item.field)}
{...(autoSetPlaceholder && setTextPlaceholder(item))}
{...setComponentProps(item)}
style={
item.componentProps?.style || {
width: '100%'
}
}
>
{{ ...slotsMap }}
</Com>
)
}
return <>{Comp()}</>
}
}
}
if (item?.formItemProps?.slots?.label) {
formItemSlots.label = (...args: any[]) => {
return (item?.formItemProps?.slots as any)?.label(...args)
}
}
if (item?.formItemProps?.slots?.error) {
formItemSlots.error = (...args: any[]) => {
return (item?.formItemProps?.slots as any)?.error(...args)
}
}
return (
<ElFormItem
v-show={!item.hidden}
ref={(el: any) => setFormItemRefMap(el, item.field)}
{...(item.formItemProps || {})}
prop={item.field}
label={item.label || ''}
>
{formItemSlots}
</ElFormItem>
)
}
// 过滤传入Form组件的属性
const getFormBindValue = () => {
// 避免在标签上出现多余的属性
const delKeys = ['schema', 'isCol', 'autoSetPlaceholder', 'isCustom', 'model']
const props = { ...unref(getProps) }
for (const key in props) {
if (delKeys.indexOf(key) !== -1) {
delete props[key]
}
}
return props as FormProps
}
return () => (
<ElForm
ref={elFormRef}
{...getFormBindValue()}
model={unref(getProps).isCustom ? unref(getProps).model : formModel}
class={prefixCls}
>
{{
// 如果需要自定义,就什么都不渲染,而是提供默认插槽
default: () => {
const { isCustom } = unref(getProps)
return isCustom ? getSlot(slots, 'default') : renderWrap()
}
}}
</ElForm>
)
}
})
</script>
<style lang="less" scoped>
.@{elNamespace}-form.@{namespace}-form .@{elNamespace}-row {
margin-right: 0 !important;
margin-left: 0 !important;
}
.@{elNamespace}-form--inline {
:deep(.el-form-item__content) {
& > :first-child {
min-width: 229.5px;
}
}
.@{elNamespace}-input-number {
// 229.5px是兼容el-input-number的最小宽度,
min-width: 229.5px;
}
}
</style>
@@ -0,0 +1,32 @@
import { FormSchema, ComponentNameEnum, CheckboxGroupComponentProps } from '../types'
import { ElCheckbox, ElCheckboxButton } from 'element-plus'
import { defineComponent } from 'vue'
export const useRenderCheckbox = () => {
const renderCheckboxOptions = (item: FormSchema) => {
// 如果有别名,就取别名
const componentProps = item?.componentProps as CheckboxGroupComponentProps
const valueAlias = componentProps?.props?.value || 'value'
const labelAlias = componentProps?.props?.label || 'label'
const disabledAlias = componentProps?.props?.disabled || 'disabled'
const Com = (
item.component === ComponentNameEnum.CHECKBOX_GROUP ? ElCheckbox : ElCheckboxButton
) as ReturnType<typeof defineComponent>
return componentProps?.options?.map((option) => {
const { value, ...other } = option
return (
<Com
{...other}
disabled={option[disabledAlias || 'disabled']}
label={option[valueAlias || 'value']}
>
{option[labelAlias || 'label']}
</Com>
)
})
}
return {
renderCheckboxOptions
}
}
@@ -0,0 +1,32 @@
import { FormSchema, ComponentNameEnum, RadioGroupComponentProps } from '../types'
import { ElRadio, ElRadioButton } from 'element-plus'
import { defineComponent } from 'vue'
export const useRenderRadio = () => {
const renderRadioOptions = (item: FormSchema) => {
// 如果有别名,就取别名
const componentProps = item?.componentProps as RadioGroupComponentProps
const valueAlias = componentProps?.props?.value || 'value'
const labelAlias = componentProps?.props?.label || 'label'
const disabledAlias = componentProps?.props?.disabled || 'disabled'
const Com = (
item.component === ComponentNameEnum.RADIO_GROUP ? ElRadio : ElRadioButton
) as ReturnType<typeof defineComponent>
return componentProps?.options?.map((option) => {
const { value, ...other } = option
return (
<Com
{...other}
disabled={option[disabledAlias || 'disabled']}
label={option[valueAlias || 'value']}
>
{option[labelAlias || 'label']}
</Com>
)
})
}
return {
renderRadioOptions
}
}
@@ -0,0 +1,58 @@
import { ElOption, ElOptionGroup } from 'element-plus'
import { FormSchema, SelectComponentProps, SelectOption } from '../types'
export const useRenderSelect = () => {
// 渲染 select options
const renderSelectOptions = (item: FormSchema) => {
const componentsProps = item?.componentProps as SelectComponentProps
const optionGroupDefaultSlot = componentsProps?.slots?.optionGroupDefault
// 如果有别名,就取别名
const labelAlias = componentsProps?.props?.label
const keyAlias = componentsProps?.props?.key
return componentsProps?.options?.map((option) => {
if (option?.options?.length) {
return optionGroupDefaultSlot ? (
optionGroupDefaultSlot(option)
) : (
<ElOptionGroup label={option[labelAlias || 'label']} key={option[keyAlias || 'key']}>
{{
default: () =>
option?.options?.map((v) => {
return renderSelectOptionItem(item, v)
})
}}
</ElOptionGroup>
)
} else {
return renderSelectOptionItem(item, option)
}
})
}
// 渲染 select option item
const renderSelectOptionItem = (item: FormSchema, option: SelectOption) => {
// 如果有别名,就取别名
const componentsProps = item.componentProps as SelectComponentProps
const labelAlias = componentsProps?.props?.label
const valueAlias = componentsProps?.props?.value
const keyAlias = componentsProps?.props?.key
const optionDefaultSlot = componentsProps.slots?.optionDefault
return (
<ElOption
{...option}
key={option[keyAlias || 'key']}
label={option[labelAlias || 'label']}
value={option[valueAlias || 'value']}
>
{{
default: () => (optionDefaultSlot ? optionDefaultSlot(option) : undefined)
}}
</ElOption>
)
}
return {
renderSelectOptions
}
}
@@ -0,0 +1,53 @@
import type { Component } from 'vue'
import {
ElCascader,
ElCheckboxGroup,
ElColorPicker,
ElDatePicker,
ElInput,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSelectV2,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTransfer,
ElAutocomplete,
ElDivider,
ElTreeSelect,
ElUpload
} from 'element-plus'
import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { ComponentName } from '../types'
const componentMap: Recordable<Component, ComponentName> = {
RadioGroup: ElRadioGroup,
RadioButton: ElRadioGroup,
CheckboxGroup: ElCheckboxGroup,
CheckboxButton: ElCheckboxGroup,
Input: ElInput,
Autocomplete: ElAutocomplete,
InputNumber: ElInputNumber,
Select: ElSelect,
Cascader: ElCascader,
Switch: ElSwitch,
Slider: ElSlider,
TimePicker: ElTimePicker,
DatePicker: ElDatePicker,
Rate: ElRate,
ColorPicker: ElColorPicker,
Transfer: ElTransfer,
Divider: ElDivider,
TimeSelect: ElTimeSelect,
SelectV2: ElSelectV2,
InputPassword: InputPassword,
Editor: Editor,
TreeSelect: ElTreeSelect,
Upload: ElUpload
}
export { componentMap }
+162
View File
@@ -0,0 +1,162 @@
import { useI18n } from '@/hooks/web/useI18n'
import { PlaceholderModel, FormSchema, ComponentNameEnum, ColProps } from '../types'
import { isFunction } from '@/utils/is'
import { firstUpperCase, humpToDash } from '@/utils'
import { set, get } from 'lodash-es'
const { t } = useI18n()
/**
*
* @param schema 对应组件数据
* @returns 返回提示信息对象
* @description 用于自动设置placeholder
*/
export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => {
const textMap = [
ComponentNameEnum.INPUT,
ComponentNameEnum.AUTOCOMPLETE,
ComponentNameEnum.INPUT_NUMBER,
ComponentNameEnum.INPUT_PASSWORD
]
const selectMap = [
ComponentNameEnum.SELECT,
ComponentNameEnum.TIME_PICKER,
ComponentNameEnum.DATE_PICKER,
ComponentNameEnum.TIME_SELECT,
ComponentNameEnum.SELECT_V2
]
if (textMap.includes(schema?.component as ComponentNameEnum)) {
return {
placeholder: t('common.inputText')
}
}
if (selectMap.includes(schema?.component as ComponentNameEnum)) {
// 一些范围选择器
const twoTextMap = ['datetimerange', 'daterange', 'monthrange', 'datetimerange', 'daterange']
if (
twoTextMap.includes(
((schema?.componentProps as any)?.type ||
(schema?.componentProps as any)?.isRange) as string
)
) {
return {
startPlaceholder: t('common.startTimeText'),
endPlaceholder: t('common.endTimeText'),
rangeSeparator: '-'
}
} else {
return {
placeholder: t('common.selectText')
}
}
}
return {}
}
/**
*
* @param col 内置栅格
* @returns 返回栅格属性
* @description 合并传入进来的栅格属性
*/
export const setGridProp = (col: ColProps = {}): ColProps => {
const colProps: ColProps = {
// 如果有span,代表用户优先级更高,所以不需要默认栅格
...(col.span
? {}
: {
xs: 24,
sm: 12,
md: 12,
lg: 12,
xl: 12
}),
...col
}
return colProps
}
/**
*
* @param item 传入的组件属性
* @returns 默认添加 clearable 属性
*/
export const setComponentProps = (item: FormSchema): Recordable => {
// const notNeedClearable = ['ColorPicker']
// 拆分事件并组合
const onEvents = (item?.componentProps as any)?.on || {}
const newOnEvents: Recordable = {}
for (const key in onEvents) {
if (onEvents[key]) {
newOnEvents[`on${firstUpperCase(key)}`] = (...args: any[]) => {
onEvents[key](...args)
}
}
}
const componentProps: Recordable = {
clearable: true,
...item.componentProps,
...newOnEvents
}
// 需要删除额外的属性
if (componentProps.slots) {
delete componentProps.slots
}
if (componentProps.on) {
delete componentProps.on
}
return componentProps
}
/**
*
* @param formModel 表单数据
* @param slotsProps 插槽属性
*/
export const setItemComponentSlots = (slotsProps: Recordable = {}): Recordable => {
const slotObj: Recordable = {}
for (const key in slotsProps) {
if (slotsProps[key]) {
if (isFunction(slotsProps[key])) {
slotObj[humpToDash(key)] = (...args: any[]) => {
return slotsProps[key]?.(...args)
}
} else {
slotObj[humpToDash(key)] = () => {
return slotsProps[key]
}
}
}
}
return slotObj
}
/**
*
* @param schema Form表单结构化数组
* @param formModel FormMoel
* @returns FormMoel
* @description 生成对应的formModel
*/
export const initModel = (schema: FormSchema[], formModel: Recordable) => {
const model: Recordable = { ...formModel }
schema.map((v) => {
if (v.remove) {
delete model[v.field]
} else if (v.component !== 'Divider') {
// const hasField = Reflect.has(model, v.field)
const hasField = get(model, v.field)
// 如果先前已经有值存在,则不进行重新赋值,而是采用现有的值
set(
model,
v.field,
hasField !== void 0 ? get(model, v.field) : v.value !== void 0 ? v.value : undefined
)
// model[v.field] = hasField ? model[v.field] : v.value !== void 0 ? v.value : undefined
}
})
return model
}
+665
View File
@@ -0,0 +1,665 @@
import {
AutocompleteProps,
InputNumberProps,
CascaderProps,
CascaderNode,
CascaderValue,
SwitchProps,
ComponentSize,
InputProps,
RateProps,
ColorPickerProps,
TransferProps,
RadioGroupProps,
RadioButtonProps,
CheckboxGroupProps,
DividerProps,
DatePickerProps,
FormItemProps as ElFormItemProps,
FormProps as ElFormProps,
ISelectProps,
UploadProps
} from 'element-plus'
import { IEditorConfig } from '@wangeditor/editor'
import { CSSProperties } from 'vue'
export interface PlaceholderModel {
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
rangeSeparator?: string
}
export enum ComponentNameEnum {
RADIO_GROUP = 'RadioGroup',
RADIO_BUTTON = 'RadioButton',
CHECKBOX_GROUP = 'CheckboxGroup',
CHECKBOX_BUTTON = 'CheckboxButton',
INPUT = 'Input',
AUTOCOMPLETE = 'Autocomplete',
INPUT_NUMBER = 'InputNumber',
SELECT = 'Select',
CASCADER = 'Cascader',
SWITCH = 'Switch',
SLIDER = 'Slider',
TIME_PICKER = 'TimePicker',
DATE_PICKER = 'DatePicker',
RATE = 'Rate',
COLOR_PICKER = 'ColorPicker',
TRANSFER = 'Transfer',
DIVIDER = 'Divider',
TIME_SELECT = 'TimeSelect',
SELECT_V2 = 'SelectV2',
INPUT_PASSWORD = 'InputPassword',
EDITOR = 'Editor',
TREE_SELECT = 'TreeSelect',
UPLOAD = 'Upload'
}
type CamelCaseComponentName = keyof typeof ComponentNameEnum extends infer K
? K extends string
? K extends `${infer A}_${infer B}`
? `${Capitalize<Lowercase<A>>}${Capitalize<Lowercase<B>>}`
: Capitalize<Lowercase<K>>
: never
: never
export type ComponentName = CamelCaseComponentName
export interface InputPasswordComponentProps {
strength?: boolean
style?: CSSProperties
}
export interface InputComponentProps extends Partial<InputProps> {
rows?: number
on?: {
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
change?: (value: string | number) => void
clear?: () => void
input?: (value: string | number) => void
}
slots?: {
prefix?: (...args: any[]) => JSX.Element | null
suffix?: (...args: any[]) => JSX.Element | null
prepend?: (...args: any[]) => JSX.Element | null
append?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface AutocompleteComponentProps extends Partial<AutocompleteProps> {
on?: {
select?: (item: any) => void
change?: (value: string | number) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
prefix?: (...args: any[]) => JSX.Element | null
suffix?: (...args: any[]) => JSX.Element | null
prepend?: (...args: any[]) => JSX.Element | null
append?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface InputNumberComponentProps extends Partial<InputNumberProps> {
on?: {
change?: (currentValue: number | undefined, oldValue: number | undefined) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
}
style?: CSSProperties
}
export interface SelectOption {
label?: string
disabled?: boolean
value?: any
key?: string | number
options?: SelectOption[]
[key: string]: any
}
export interface SelectComponentProps extends Omit<Partial<ISelectProps>, 'options'> {
/**
* 数据源的字段别名
*/
props?: {
key?: string
value?: string
label?: string
children?: string
}
on?: {
change?: (value: string | number | boolean | Object) => void
visibleChange?: (visible: boolean) => void
removeTag?: (tag: any) => void
clear?: () => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
}
slots?: {
default?: (options: SelectOption[]) => JSX.Element[] | null
optionGroupDefault?: (item: SelectOption) => JSX.Element
optionDefault?: (option: SelectOption) => JSX.Element | null
prefix?: (...args: any[]) => JSX.Element | null
empty?: (...args: any[]) => JSX.Element | null
}
options?: SelectOption[]
style?: CSSProperties
}
export interface SelectV2ComponentProps {
multiple?: boolean
disabled?: boolean
valueKey?: string
size?: ComponentSize
clearable?: boolean
clearIcon?: string | JSX.Element | null
collapseTags?: boolean
multipleLimit?: number
name?: string
effect?: string
autocomplete?: string
placeholder?: string
filterable?: boolean
allowCreate?: boolean
reserveKeyword?: boolean
noDataText?: string
popperClass?: string
teleported?: boolean
persistent?: boolean
popperOptions?: any
automaticDropdown?: boolean
height?: number
scrollbarAlwaysOn?: boolean
remote?: boolean
remoteMethod?: (query: string) => void
validateEvent?: boolean
placement?: AutocompleteProps['placement']
collapseTagsTooltip?: boolean
on?: {
change?: (value: string | number | boolean | Object) => void
visibleChange?: (visible: boolean) => void
removeTag?: (tag: any) => void
clear?: () => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
}
options?: SelectOption[]
slots?: {
default?: (option: SelectOption) => JSX.Element | null
}
style?: CSSProperties
}
export interface CascaderComponentProps {
options?: Record<string, unknown>[]
props?: CascaderProps
size?: ComponentSize
placeholder?: string
disabled?: boolean
clearable?: boolean
showAllLevels?: boolean
collapseTags?: boolean
collapseTagsTooltip?: boolean
separator?: string
filterable?: boolean
filterMethod?: (node: CascaderNode, keyword: string) => boolean
debounce?: number
beforeFilter?: (value: string) => boolean
popperClass?: string
teleported?: boolean
tagType?: ElementPlusInfoType
validateEvent?: boolean
on?: {
change?: (value: CascaderValue) => void
expandChange?: (value: CascaderValue) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
visibleChange?: (value: boolean) => void
removeTag?: (value: CascaderNode['valueByOption']) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
empty?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface SwitchComponentProps extends Partial<SwitchProps> {
on?: {
change?: (value: boolean | string | number) => void
}
style?: CSSProperties
}
export interface RateComponentProps extends Partial<RateProps> {
on?: {
change?: (value: number) => void
}
style?: CSSProperties
}
export interface ColorPickerComponentProps extends Partial<ColorPickerProps> {
on?: {
change?: (value: string) => void
activeChange?: (value: string) => void
}
style?: CSSProperties
}
export interface TransferComponentProps extends Partial<TransferProps> {
on?: {
change?: (
value: number | string,
direction: 'left' | 'right',
movedKeys: string[] | number[]
) => void
leftCheckChange?: (value: any[]) => void
rightCheckChange?: (value: any[]) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
leftFooter?: (...args: any[]) => JSX.Element | null
rightFooter?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface RadioOption {
label?: string
value?: string | number | boolean
disabled?: boolean
border?: boolean
size?: ComponentSize
name?: string
[key: string]: any
}
export interface RadioGroupComponentProps extends Partial<RadioGroupProps> {
options?: RadioOption[]
/**
* 数据源的字段别名
*/
props?: {
label?: string
value?: string
disabled?: string
}
on?: {
change?: (value: string | number | boolean) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element[] | null
}
style?: CSSProperties
}
export interface RadioButtonComponentProps extends Partial<RadioButtonProps> {
options?: RadioOption[]
/**
* 数据源的字段别名
*/
props?: {
label?: string
value?: string
disabled?: string
}
on?: {
change?: (value: string | number | boolean) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element[] | null
}
style?: CSSProperties
}
export interface CheckboxOption {
label?: string
value?: string | number | boolean
disabled?: boolean
trueLabel?: string | number
falseLabel?: string | number
border?: boolean
size?: ComponentSize
name?: string
checked?: boolean
indeterminate?: boolean
validateEvent?: boolean
tabindex?: number | string
id?: string
controls?: boolean
[key: string]: any
}
export interface CheckboxGroupComponentProps extends Partial<CheckboxGroupProps> {
options?: CheckboxOption[]
/**
* 数据源的字段别名
*/
props?: {
label?: string
value?: string
disabled?: string
}
on?: {
change?: (value: string | number | boolean) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element[] | null
}
style?: CSSProperties
}
export interface DividerComponentProps extends Partial<DividerProps> {
on?: {
change?: (value: number) => void
input?: (value: number) => void
}
style?: CSSProperties
}
export interface DatePickerComponentProps extends Partial<DatePickerProps> {
on?: {
change?: (value: string | Date | number | string[]) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
calendarChange?: (val: [Date, Date]) => void
panelChange?: (date, mode, view) => void
visibleChange?: (visibility: boolean) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
rangeSeparator?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface DateTimePickerComponentProps {
readonly?: boolean
disabled?: boolean
editable?: boolean
clearable?: boolean
size?: ComponentSize
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
timeArrowControl?: boolean
type?: 'year' | 'month' | 'date' | 'datetime' | 'datetimerange' | 'daterange' | 'week'
format?: string
popperClass?: string
rangeSeparator?: string
defaultValue?: Date | [Date, Date]
defaultTime?: Date | [Date, Date]
valueFormat?: string
id?: string
name?: string
unlinkPanels?: boolean
prefixIcon?: string | JSX.Element
clearIcon?: string | JSX.Element
shortcuts?: Array<{ text: string; value: Date | Function }>
disabledDate?: (date: Date) => boolean
cellClassName?: string | ((date: Date) => string | undefined)
teleported?: boolean
on?: {
change?: (value: string | Date | number | string[]) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
calendarChange?: (val: [Date, Date]) => void
visibleChange?: (visibility: boolean) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
rangeSeparator?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface TimePickerComponentProps {
readonly?: boolean
disabled?: boolean
editable?: boolean
clearable?: boolean
size?: ComponentSize
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
isRange?: boolean
arrowControl?: boolean
popperClass?: string
rangeSeparator?: string
format?: string
defaultValue?: Date | [Date, Date]
id?: string
name?: string
label?: string
prefixIcon?: string | JSX.Element
clearIcon?: string | JSX.Element
disabledHours?: (role: string, comparingDate?: any) => number[]
disabledMinutes?: (hour: number, role: string, comparingDate?: any) => number[]
disabledSeconds?: (hour: number, minute: number, role: string, comparingDate?: any) => number[]
teleported?: boolean
tabindex?: number | string
on?: {
change: (
val: number | string | Date | [number, number] | [string, string] | [Date, Date]
) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
visibleChange?: (visibility: boolean) => void
}
style?: CSSProperties
}
export interface TimeSelectComponentProps {
disabled?: boolean
editable?: boolean
clearable?: boolean
size?: ComponentSize
placeholder?: string
name?: string
effect?: string
prefixIcon?: string | JSX.Element
clearIcon?: string | JSX.Element
start?: string
end?: string
step?: string
minTime?: string
maxTime?: string
format?: string
on?: {
change?: (val: string) => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
}
style?: CSSProperties
}
export interface EditorComponentProps {
editorConfig?: IEditorConfig
style?: CSSProperties
}
export interface ColProps {
span?: number
xs?: number
sm?: number
md?: number
lg?: number
xl?: number
tag?: string
}
export interface FormSetProps {
field: string
path: string
value: any
}
export interface FormItemProps extends Partial<ElFormItemProps> {
style?: CSSProperties
slots?: {
default?: (...args: any[]) => JSX.Element | null
label?: (...args: any[]) => JSX.Element | null
error?: (...args: any[]) => JSX.Element | null
}
}
export interface UploadComponentProps extends Partial<UploadProps> {
slots?: {
default?: (...args: any[]) => JSX.Element | null
trigger?: (...args: any[]) => JSX.Element | null
tip?: (...args: any[]) => JSX.Element | null
file?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface TreeSelectComponentProps extends Omit<
Partial<SelectComponentProps>,
'props' | 'on' | 'slots'
> {
data?: any[]
emptyText?: string
nodeKey?: string
props?: {
children?: string
label?: string | ((...args: any[]) => string)
disabled?: string | ((...args: any[]) => string)
isLeaf?: string | ((...args: any[]) => string)
class?: string | ((...args: any[]) => string)
}
renderAfterExpand?: boolean
load?: (...args: any[]) => Promise<any>
renderContent?: (...args: any[]) => JSX.Element | null
highlightCurrent?: boolean
defaultExpandAll?: boolean
expandOnClickNode?: boolean
checkOnClickNode?: boolean
autoExpandParent?: boolean
defaultExpandedKeys?: any[]
showCheckbox?: boolean
checkStrictly?: boolean
defaultCheckedKeys?: any[]
currentNodeKey?: string | number
filterNodeMethod?: (...args: any[]) => boolean
accordion?: boolean
indent?: number
icon?: string | ((...args: any[]) => JSX.Element | null)
lazy?: boolean
draggable?: boolean
allowDrag?: (...args: any[]) => boolean
allowDrop?: (...args: any[]) => boolean
on?: {
change?: (value: string | number | boolean | Object) => void
visibleChange?: (visible: boolean) => void
removeTag?: (tag: any) => void
clear?: () => void
blur?: (event: FocusEvent) => void
focus?: (event: FocusEvent) => void
nodeClick?: (...args: any[]) => void
nodeContextMenu?: (...args: any[]) => void
checkChange?: (...args: any[]) => void
check?: (...args: any[]) => void
currentChange?: (...args: any[]) => void
nodeExpand?: (...args: any[]) => void
nodeCollapse?: (...args: any[]) => void
nodeDragStart?: (...args: any[]) => void
nodeDragEnter?: (...args: any[]) => void
nodeDragLeave?: (...args: any[]) => void
nodeDragOver?: (...args: any[]) => void
nodeDragEnd?: (...args: any[]) => void
nodeDrop?: (...args: any[]) => void
}
slots?: {
default?: (...args: any[]) => JSX.Element | null
optionGroupDefault?: (item: SelectOption) => JSX.Element
optionDefault?: (option: SelectOption) => JSX.Element | null
prefix?: (...args: any[]) => JSX.Element | null
empty?: (...args: any[]) => JSX.Element | null
}
style?: CSSProperties
}
export interface FormSchema {
/**
* 唯一标识
*/
field: string
/**
* 标题
*/
label?: string
/**
* col组件属性
*/
colProps?: ColProps
/**
* 表单组件属性,具体可以查看element-plus文档
*/
componentProps?:
| InputComponentProps
| AutocompleteComponentProps
| InputNumberComponentProps
| SelectComponentProps
| SelectV2ComponentProps
| CascaderComponentProps
| SwitchComponentProps
| RateComponentProps
| ColorPickerComponentProps
| TransferComponentProps
| RadioGroupComponentProps
| RadioButtonComponentProps
| DividerComponentProps
| DatePickerComponentProps
| DateTimePickerComponentProps
| TimePickerComponentProps
| InputPasswordComponentProps
| TreeSelectComponentProps
| UploadComponentProps
| any
/**
* formItem组件属性,具体可以查看element-plus文档
*/
formItemProps?: FormItemProps
/**
* 渲染的组件名称
*/
component?: ComponentName
/**
* 初始值
*/
value?: any
/**
* 是否隐藏,如果为true,会连同值一同删除,类似v-if
*/
remove?: boolean
/**
* 样式隐藏,不会把值一同删掉,类似v-show
*/
hidden?: boolean
/**
* @returns 远程加载下拉项
*/
optionApi?: any
}
export interface FormProps extends Partial<ElFormProps> {
schema?: FormSchema[]
isCol?: boolean
model?: Recordable
autoSetPlaceholder?: boolean
isCustom?: boolean
[key: string]: any
}
+5
View File
@@ -0,0 +1,5 @@
import Icon from './src/Icon.vue'
export type { IconTypes } from './src/types'
export { Icon }
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('icon')
const props = defineProps({
// icon name
icon: propTypes.string,
// icon color
color: propTypes.string,
// icon size
size: propTypes.number.def(16),
hoverColor: propTypes.string
})
const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon
})
// 是否使用在线图标
const isUseOnline = computed(() => {
return import.meta.env.VITE_USE_ONLINE_ICON === 'true'
})
const getIconifyStyle = computed(() => {
const { color, size } = props
return {
fontSize: `${size}px`,
color
}
})
</script>
<template>
<ElIcon :class="prefixCls" :size="size" :color="color">
<svg v-if="isLocal" aria-hidden="true">
<use :xlink:href="symbolId" />
</svg>
<template v-else>
<Icon v-if="isUseOnline" :icon="icon" :style="getIconifyStyle" />
<div v-else :class="`${icon} iconify`" :style="getIconifyStyle"></div>
</template>
</ElIcon>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-icon';
.@{prefix-cls},
.iconify {
:deep(svg) {
&:hover {
// stylelint-disable-next-line
color: v-bind(hoverColor) !important;
}
}
}
.iconify {
&:hover {
// stylelint-disable-next-line
color: v-bind(hoverColor) !important;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
export interface IconTypes {
size?: number
color?: string
icon: string
hoverColor?: string
}
+3
View File
@@ -0,0 +1,3 @@
import InputPassword from './src/InputPassword.vue'
export { InputPassword }
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, unref, computed, watch } from 'vue'
import { ElInput } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useConfigGlobal } from '@/hooks/web/useConfigGlobal'
import { zxcvbn } from '@zxcvbn-ts/core'
import type { ZxcvbnResult } from '@zxcvbn-ts/core'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('input-password')
const props = defineProps({
// 是否显示密码强度
strength: propTypes.bool.def(false),
modelValue: propTypes.string.def('')
})
watch(
() => props.modelValue,
(val: string) => {
if (val === unref(valueRef)) return
valueRef.value = val
}
)
const { configGlobal } = useConfigGlobal()
const emit = defineEmits(['update:modelValue'])
// 设置input的type属性
const textType = ref<'password' | 'text'>('password')
// 输入框的值
const valueRef = ref(props.modelValue)
// 监听
watch(
() => valueRef.value,
(val: string) => {
emit('update:modelValue', val)
}
)
// 获取密码强度
const getPasswordStrength = computed(() => {
const value = unref(valueRef)
const zxcvbnRef = zxcvbn(unref(valueRef)) as ZxcvbnResult
return value ? zxcvbnRef.score : -1
})
</script>
<template>
<div :class="[prefixCls, `${prefixCls}--${configGlobal?.size}`]">
<ElInput v-bind="$attrs" v-model="valueRef" showPassword :type="textType" />
<div
v-if="strength"
:class="`${prefixCls}__bar`"
class="relative h-6px mt-10px mb-6px mr-auto ml-auto"
>
<div :class="`${prefixCls}__bar--fill`" :data-score="getPasswordStrength"></div>
</div>
</div>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-input-password';
.@{prefix-cls} {
:deep(.@{elNamespace}-input__clear) {
margin-left: 5px;
}
&__bar {
background-color: var(--el-text-color-disabled);
border-radius: var(--el-border-radius-base);
&::before,
&::after {
position: absolute;
z-index: 10;
display: block;
width: 20%;
height: inherit;
background-color: transparent;
border-color: var(--el-color-white);
border-style: solid;
border-width: 0 5px;
content: '';
}
&::before {
left: 20%;
}
&::after {
right: 20%;
}
&--fill {
position: absolute;
width: 0;
height: inherit;
background-color: transparent;
border-radius: inherit;
transition:
width 0.5s ease-in-out,
background 0.25s;
&[data-score='0'] {
width: 20%;
background-color: var(--el-color-danger);
}
&[data-score='1'] {
width: 40%;
background-color: var(--el-color-danger);
}
&[data-score='2'] {
width: 60%;
background-color: var(--el-color-warning);
}
&[data-score='3'] {
width: 80%;
background-color: var(--el-color-success);
}
&[data-score='4'] {
width: 100%;
background-color: var(--el-color-success);
}
}
}
&--mini > &__bar {
border-radius: var(--el-border-radius-small);
}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import LocaleDropdown from './src/LocaleDropdown.vue'
export type { Language, LocaleDropdownType } from './src/types'
export { LocaleDropdown }
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, unref } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { useLocaleStore } from '@/store/modules/locale'
import { useLocale } from '@/hooks/web/useLocale'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('locale-dropdown')
defineProps({
color: propTypes.string.def('')
})
const localeStore = useLocaleStore()
const langMap = computed(() => localeStore.getLocaleMap)
const currentLang = computed(() => localeStore.getCurrentLocale)
const setLang = (lang: LocaleType) => {
if (lang === unref(currentLang).lang) return
// 需要重新加载页面让整个语言多初始化
window.location.reload()
localeStore.setCurrentLocale({
lang
})
const { changeLocale } = useLocale()
changeLocale(lang)
}
</script>
<template>
<ElDropdown :class="prefixCls" trigger="click" @command="setLang">
<Icon
:size="18"
icon="ion:language-sharp"
class="cursor-pointer !p-0"
:class="$attrs.class"
:color="color"
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem v-for="item in langMap" :key="item.lang" :command="item.lang">
{{ item.name }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
@@ -0,0 +1,10 @@
export interface Language {
el: Recordable
name: string
}
export interface LocaleDropdownType {
lang: LocaleType
name?: string
elLocale?: Language
}
+3
View File
@@ -0,0 +1,3 @@
import Logo from './src/Logo.vue'
export { Logo }
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted, unref } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('logo')
const appStore = useAppStore()
const show = ref(true)
const title = computed(() => appStore.getTitle)
const layout = computed(() => appStore.getLayout)
const collapse = computed(() => appStore.getCollapse)
onMounted(() => {
if (unref(collapse)) show.value = false
})
watch(
() => collapse.value,
(collapse: boolean) => {
if (unref(layout) === 'topLeft' || unref(layout) === 'cutMenu') {
show.value = true
return
}
if (!collapse) {
setTimeout(() => {
show.value = !collapse
}, 400)
} else {
show.value = !collapse
}
}
)
watch(
() => layout.value,
(layout) => {
if (layout === 'top' || layout === 'cutMenu') {
show.value = true
} else {
if (unref(collapse)) {
show.value = false
} else {
show.value = true
}
}
}
)
</script>
<template>
<div>
<router-link
:class="[
prefixCls,
layout !== 'classic' ? `${prefixCls}__Top` : '',
'flex !h-[var(--logo-height)] items-center cursor-pointer pl-8px relative decoration-none overflow-hidden'
]"
to="/"
>
<img
src="@/assets/imgs/logo.png"
class="w-[calc(var(--logo-height)-10px)] h-[calc(var(--logo-height)-10px)]"
/>
<div
v-if="show"
:class="[
'ml-10px text-16px font-700',
{
'text-[var(--logo-title-text-color)]': layout === 'classic',
'text-[var(--top-header-text-color)]':
layout === 'topLeft' || layout === 'top' || layout === 'cutMenu'
}
]"
>
{{ title }}
</div>
</router-link>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
import Menu from './src/Menu.vue'
export { Menu }
+260
View File
@@ -0,0 +1,260 @@
<script lang="tsx">
import { computed, defineComponent, unref, PropType } from 'vue'
import { ElMenu, ElScrollbar } from 'element-plus'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { useRenderMenuItem } from './components/useRenderMenuItem'
import { useRouter } from 'vue-router'
import { isUrl } from '@/utils/is'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('menu')
export default defineComponent({
name: 'Menu',
props: {
menuSelect: {
type: Function as PropType<(index: string) => void>,
default: undefined
}
},
setup(props) {
const appStore = useAppStore()
const layout = computed(() => appStore.getLayout)
const { push, currentRoute } = useRouter()
const permissionStore = usePermissionStore()
const menuMode = computed((): 'vertical' | 'horizontal' => {
// 竖
const vertical: LayoutType[] = ['classic', 'topLeft', 'cutMenu']
if (vertical.includes(unref(layout))) {
return 'vertical'
} else {
return 'horizontal'
}
})
const routers = computed(() =>
unref(layout) === 'cutMenu' ? permissionStore.getMenuTabRouters : permissionStore.getRouters
)
const collapse = computed(() => appStore.getCollapse)
const uniqueOpened = computed(() => appStore.getUniqueOpened)
const activeMenu = computed(() => {
const { meta, path } = unref(currentRoute)
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu as string
}
return path
})
const menuSelect = (index: string) => {
if (props.menuSelect) {
props.menuSelect(index)
}
// 自定义事件
if (isUrl(index)) {
window.open(index)
} else {
push(index)
}
}
const renderMenuWrap = () => {
if (unref(layout) === 'top') {
return renderMenu()
} else {
return <ElScrollbar>{renderMenu()}</ElScrollbar>
}
}
const renderMenu = () => {
return (
<ElMenu
defaultActive={unref(activeMenu)}
mode={unref(menuMode)}
collapse={
unref(layout) === 'top' || unref(layout) === 'cutMenu' ? false : unref(collapse)
}
uniqueOpened={unref(layout) === 'top' ? false : unref(uniqueOpened)}
backgroundColor="var(--left-menu-bg-color)"
textColor="var(--left-menu-text-color)"
activeTextColor="var(--left-menu-text-active-color)"
popperClass={
unref(menuMode) === 'vertical'
? `${prefixCls}-popper--vertical`
: `${prefixCls}-popper--horizontal`
}
onSelect={menuSelect}
>
{{
default: () => {
const { renderMenuItem } = useRenderMenuItem()
return renderMenuItem(unref(routers))
}
}}
</ElMenu>
)
}
return () => (
<div
id={prefixCls}
class={[
`${prefixCls} ${prefixCls}__${unref(menuMode)}`,
'h-[100%] overflow-hidden flex-col bg-[var(--left-menu-bg-color)]',
{
'w-[var(--left-menu-min-width)]': unref(collapse) && unref(layout) !== 'cutMenu',
'w-[var(--left-menu-max-width)]': !unref(collapse) && unref(layout) !== 'cutMenu'
}
]}
>
{renderMenuWrap()}
</div>
)
}
})
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-menu';
.@{prefix-cls} {
position: relative;
transition: width var(--transition-time-02);
:deep(.@{elNamespace}-menu) {
width: 100% !important;
border-right: none;
// 设置选中时子标题的颜色
.is-active {
& > .@{elNamespace}-sub-menu__title {
color: var(--left-menu-text-active-color) !important;
}
}
// 设置子菜单悬停的高亮和背景色
.@{elNamespace}-sub-menu__title,
.@{elNamespace}-menu-item {
&:hover {
color: var(--left-menu-text-active-color) !important;
background-color: var(--left-menu-bg-color) !important;
}
}
// 设置选中时的高亮背景和高亮颜色
.@{elNamespace}-menu-item.is-active {
color: var(--left-menu-text-active-color) !important;
background-color: var(--left-menu-bg-active-color) !important;
&:hover {
background-color: var(--left-menu-bg-active-color) !important;
}
}
.@{elNamespace}-menu-item.is-active {
position: relative;
}
// 设置子菜单的背景颜色
.@{elNamespace}-menu {
.@{elNamespace}-sub-menu__title,
.@{elNamespace}-menu-item:not(.is-active) {
background-color: var(--left-menu-bg-light-color) !important;
}
}
}
// 折叠时的最小宽度
:deep(.@{elNamespace}-menu--collapse) {
width: var(--left-menu-min-width);
& > .is-active,
& > .is-active > .@{elNamespace}-sub-menu__title {
position: relative;
background-color: var(--left-menu-collapse-bg-active-color) !important;
}
}
// 折叠动画的时候,就需要把文字给隐藏掉
:deep(.horizontal-collapse-transition) {
.@{prefix-cls}__title {
display: none;
}
}
// 水平菜单
&__horizontal {
height: calc(~'var(--top-tool-height)') !important;
:deep(.@{elNamespace}-menu--horizontal) {
height: calc(~'var(--top-tool-height)');
border-bottom: none;
// 重新设置底部高亮颜色
& > .@{elNamespace}-sub-menu.is-active {
.@{elNamespace}-sub-menu__title {
border-bottom-color: var(--el-color-primary) !important;
}
}
.@{elNamespace}-menu-item.is-active {
position: relative;
&::after {
display: none !important;
}
}
.@{prefix-cls}__title {
/* stylelint-disable-next-line */
max-height: calc(~'var(--top-tool-height) - 2px') !important;
/* stylelint-disable-next-line */
line-height: calc(~'var(--top-tool-height) - 2px');
}
}
}
}
</style>
<style lang="less">
@prefix-cls: ~'@{namespace}-menu-popper';
.@{prefix-cls}--vertical,
.@{prefix-cls}--horizontal {
// 设置选中时子标题的颜色
.is-active {
& > .el-sub-menu__title {
color: var(--left-menu-text-active-color) !important;
}
}
// 设置子菜单悬停的高亮和背景色
.el-sub-menu__title,
.el-menu-item {
&:hover {
color: var(--left-menu-text-active-color) !important;
background-color: var(--left-menu-bg-color) !important;
}
}
// 设置选中时的高亮背景
.el-menu-item.is-active {
position: relative;
background-color: var(--left-menu-bg-active-color) !important;
&:hover {
background-color: var(--left-menu-bg-active-color) !important;
}
}
}
</style>
@@ -0,0 +1,50 @@
import { ElSubMenu, ElMenuItem } from 'element-plus'
import { hasOneShowingChild } from '../helper'
import { isUrl } from '@/utils/is'
import { useRenderMenuTitle } from './useRenderMenuTitle'
import { pathResolve } from '@/utils/routerHelper'
const { renderMenuTitle } = useRenderMenuTitle()
export const useRenderMenuItem = () =>
// allRouters: AppRouteRecordRaw[] = [],
{
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
return routers
.filter((v) => !v.meta?.hidden)
.map((v) => {
const meta = v.meta ?? {}
const { oneShowingChild, onlyOneChild } = hasOneShowingChild(v.children, v)
const fullPath = isUrl(v.path) ? v.path : pathResolve(parentPath, v.path) // getAllParentPath<AppRouteRecordRaw>(allRouters, v.path).join('/')
if (
oneShowingChild &&
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!meta?.alwaysShow
) {
return (
<ElMenuItem
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
>
{{
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
}}
</ElMenuItem>
)
} else {
return (
<ElSubMenu index={fullPath}>
{{
title: () => renderMenuTitle(meta),
default: () => renderMenuItem(v.children!, fullPath)
}}
</ElSubMenu>
)
}
})
}
return {
renderMenuItem
}
}
@@ -0,0 +1,27 @@
import type { RouteMeta } from 'vue-router'
import { Icon } from '@/components/Icon'
import { useI18n } from '@/hooks/web/useI18n'
export const useRenderMenuTitle = () => {
const renderMenuTitle = (meta: RouteMeta) => {
const { t } = useI18n()
const { title = 'Please set title', icon } = meta
return icon ? (
<>
<Icon icon={meta.icon}></Icon>
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
{t(title as string)}
</span>
</>
) : (
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
{t(title as string)}
</span>
)
}
return {
renderMenuTitle
}
}
+54
View File
@@ -0,0 +1,54 @@
import { ref, unref } from 'vue'
import { findPath } from '@/utils/tree'
type OnlyOneChildType = AppRouteRecordRaw & { noShowingChildren?: boolean }
interface HasOneShowingChild {
oneShowingChild?: boolean
onlyOneChild?: OnlyOneChildType
}
export const getAllParentPath = <T = Recordable>(treeData: T[], path: string) => {
const menuList = findPath(treeData, (n) => n.path === path) as AppRouteRecordRaw[]
return (menuList || []).map((item) => item.path)
}
export const hasOneShowingChild = (
children: AppRouteRecordRaw[] = [],
parent: AppRouteRecordRaw
): HasOneShowingChild => {
const onlyOneChild = ref<OnlyOneChildType>()
const showingChildren = children.filter((v) => {
const meta = v.meta ?? {}
if (meta.hidden) {
return false
} else {
// Temp set(will be used if only has one showing child)
onlyOneChild.value = v
return true
}
})
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return {
oneShowingChild: true,
onlyOneChild: unref(onlyOneChild)
}
}
// Show parent if there are no child router to display
if (!showingChildren.length) {
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
return {
oneShowingChild: true,
onlyOneChild: unref(onlyOneChild)
}
}
return {
oneShowingChild: false,
onlyOneChild: unref(onlyOneChild)
}
}
+3
View File
@@ -0,0 +1,3 @@
import Screenfull from './src/Screenfull.vue'
export { Screenfull }
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { Icon } from '@/components/Icon'
import { useFullscreen } from '@vueuse/core'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('screenfull')
defineProps({
color: propTypes.string.def('')
})
const { toggle, isFullscreen } = useFullscreen()
const toggleFullscreen = () => {
toggle()
}
</script>
<template>
<div :class="prefixCls" @click="toggleFullscreen">
<Icon
:size="18"
:icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'"
:color="color"
/>
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
import { FormSchema, FormSetProps } from '../Form'
import Search from './src/Search.vue'
export type { SearchProps } from './src/types'
export interface SearchExpose {
setValues: (data: Recordable) => void
setProps: (props: Recordable) => void
delSchema: (field: string) => void
addSchema: (formSchema: FormSchema, index?: number) => void
setSchema: (schemaProps: FormSetProps[]) => void
formModel: Recordable
}
export { Search }
+269
View File
@@ -0,0 +1,269 @@
<script setup lang="tsx">
import { Form, FormSchema, FormSetProps } from '@/components/Form'
import { PropType, computed, unref, ref, watch, onMounted } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { useForm } from '@/hooks/web/useForm'
import { findIndex } from '@/utils'
import { cloneDeep, set } from 'lodash-es'
import { initModel } from '@/components/Form/src/helper'
import ActionButton from './components/ActionButton.vue'
import { SearchProps } from './types'
import { FormItemProp } from 'element-plus'
import { isObject, isEmptyVal } from '@/utils/is'
const props = defineProps({
// 生成Form的布局结构数组
schema: {
type: Array as PropType<FormSchema[]>,
default: () => []
},
// 是否需要栅格布局
isCol: propTypes.bool.def(false),
// 表单label宽度
labelWidth: propTypes.oneOfType([String, Number]).def('auto'),
// 操作按钮风格位置
layout: propTypes.string.validate((v: string) => ['inline', 'bottom'].includes(v)).def('inline'),
// 底部按钮的对齐方式
buttonPosition: propTypes.string
.validate((v: string) => ['left', 'center', 'right'].includes(v))
.def('center'),
showSearch: propTypes.bool.def(true),
showReset: propTypes.bool.def(true),
// 是否显示伸缩
showExpand: propTypes.bool.def(false),
// 伸缩的界限字段
expandField: propTypes.string.def(''),
inline: propTypes.bool.def(true),
// 是否去除空值项
removeNoValueItem: propTypes.bool.def(true),
model: {
type: Object as PropType<Recordable>,
default: () => ({})
},
searchLoading: propTypes.bool.def(false),
resetLoading: propTypes.bool.def(false)
})
const emit = defineEmits(['search', 'reset', 'register', 'validate'])
const visible = ref(true)
// 表单数据
const formModel = ref<Recordable>(props.model)
const newSchema = computed(() => {
const propsComputed = unref(getProps)
let schema: FormSchema[] = cloneDeep(propsComputed.schema)
if (propsComputed.showExpand && propsComputed.expandField && !unref(visible)) {
const index = findIndex(schema, (v: FormSchema) => v.field === propsComputed.expandField)
schema.map((v, i) => {
if (i >= index) {
v.hidden = true
} else {
v.hidden = false
}
return v
})
}
if (propsComputed.layout === 'inline') {
schema = schema.concat([
{
field: 'action',
formItemProps: {
labelWidth: '0px',
slots: {
default: () => {
return (
<div>
<ActionButton
showSearch={propsComputed.showSearch}
showReset={propsComputed.showReset}
showExpand={propsComputed.showExpand}
searchLoading={propsComputed.searchLoading}
resetLoading={propsComputed.resetLoading}
visible={visible.value}
onExpand={setVisible}
onReset={reset}
onSearch={search}
/>
</div>
)
},
label: () => {
return <span>&nbsp;</span>
}
}
}
}
])
}
return schema
})
const { formRegister, formMethods } = useForm()
const { getElFormExpose, getFormData, getFormExpose } = formMethods
// useSearch传入的props
const outsideProps = ref<SearchProps>({})
const mergeProps = ref<SearchProps>({})
const getProps = computed(() => {
const propsObj = { ...props }
Object.assign(propsObj, unref(mergeProps))
return propsObj
})
const setProps = (props: SearchProps = {}) => {
mergeProps.value = Object.assign(unref(mergeProps), props)
// @ts-ignore
outsideProps.value = props
}
const schemaRef = ref<FormSchema[]>([])
// 监听表单结构化数组,重新生成formModel
watch(
() => unref(newSchema),
async (schema = []) => {
formModel.value = initModel(schema, unref(formModel))
schemaRef.value = schema
},
{
immediate: true,
deep: true
}
)
const filterModel = async () => {
const model = await getFormData()
if (unref(getProps).removeNoValueItem) {
// 使用reduce过滤空值,并返回一个新对象
return Object.keys(model).reduce((prev, next) => {
const value = model[next]
if (!isEmptyVal(value)) {
if (isObject(value)) {
if (Object.keys(value).length > 0) {
prev[next] = value
}
} else {
prev[next] = value
}
}
return prev
}, {})
}
return model
}
const search = async () => {
const elFormExpose = await getElFormExpose()
await elFormExpose?.validate(async (isValid) => {
if (isValid) {
const model = await filterModel()
emit('search', model)
}
})
}
const reset = async () => {
const elFormExpose = await getElFormExpose()
elFormExpose?.resetFields()
const model = await filterModel()
emit('reset', model)
}
const bottomButtonStyle = computed(() => {
return {
textAlign: unref(getProps).buttonPosition as unknown as 'left' | 'center' | 'right'
}
})
const setVisible = async () => {
visible.value = !unref(visible)
}
const setSchema = (schemaProps: FormSetProps[]) => {
const { schema } = unref(getProps)
for (const v of schema) {
for (const item of schemaProps) {
if (v.field === item.field) {
set(v, item.path, item.value)
}
}
}
}
// 对表单赋值
const setValues = async (data: Recordable = {}) => {
formModel.value = Object.assign(props.model, unref(formModel), data)
const formExpose = await getFormExpose()
formExpose?.setValues(data)
}
const delSchema = (field: string) => {
const { schema } = unref(getProps)
const index = findIndex(schema, (v: FormSchema) => v.field === field)
if (index > -1) {
schema.splice(index, 1)
}
}
const addSchema = (formSchema: FormSchema, index?: number) => {
const { schema } = unref(getProps)
if (index !== void 0) {
schema.splice(index, 0, formSchema)
return
}
schema.push(formSchema)
}
const defaultExpose = {
getElFormExpose,
setProps,
setSchema,
setValues,
delSchema,
addSchema
}
onMounted(() => {
emit('register', defaultExpose)
})
defineExpose(defaultExpose)
const onFormValidate = (prop: FormItemProp, isValid: boolean, message: string) => {
emit('validate', prop, isValid, message)
}
</script>
<template>
<Form
:model="formModel"
:is-custom="false"
:label-width="getProps.labelWidth"
hide-required-asterisk
:inline="getProps.inline"
:is-col="getProps.isCol"
:schema="schemaRef"
@register="formRegister"
@validate="onFormValidate"
/>
<template v-if="layout === 'bottom'">
<div :style="bottomButtonStyle">
<ActionButton
:show-reset="getProps.showReset"
:show-search="getProps.showSearch"
:show-expand="getProps.showExpand"
:search-loading="getProps.searchLoading"
:reset-loading="getProps.resetLoading"
@expand="setVisible"
@reset="reset"
@search="search"
/>
</div>
</template>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ElButton } from 'element-plus'
import { useIcon } from '@/hooks/web/useIcon'
import { propTypes } from '@/utils/propTypes'
import { useI18n } from '@/hooks/web/useI18n'
const emit = defineEmits(['search', 'reset', 'expand'])
const { t } = useI18n()
defineProps({
showSearch: propTypes.bool.def(true),
showReset: propTypes.bool.def(true),
showExpand: propTypes.bool.def(false),
visible: propTypes.bool.def(true),
searchLoading: propTypes.bool.def(false),
resetLoading: propTypes.bool.def(false)
})
const onSearch = () => {
emit('search')
}
const onReset = () => {
emit('reset')
}
const onExpand = () => {
emit('expand')
}
</script>
<template>
<ElButton
v-if="showSearch"
type="primary"
:loading="searchLoading"
:icon="useIcon({ icon: 'ep:search' })"
@click="onSearch"
>
{{ t('common.query') }}
</ElButton>
<ElButton
v-if="showReset"
:loading="resetLoading"
:icon="useIcon({ icon: 'ep:refresh-right' })"
@click="onReset"
>
{{ t('common.reset') }}
</ElButton>
<ElButton
v-if="showExpand"
:icon="useIcon({ icon: visible ? 'ep:arrow-down' : 'ep:arrow-up' })"
text
@click="onExpand"
>
{{ t(visible ? 'common.shrink' : 'common.expand') }}
</ElButton>
</template>
+16
View File
@@ -0,0 +1,16 @@
import { FormSchema } from '@/components/Form'
export interface SearchProps {
schema?: FormSchema[]
isCol?: boolean
labelWidth?: string | number
layout?: 'inline' | 'bottom'
buttonPosition?: 'left' | 'right' | 'center'
showSearch?: boolean
showReset?: boolean
showExpand?: boolean
expandField?: string
inline?: boolean
removeNoValueItem?: boolean
model?: Recordable
}
+3
View File
@@ -0,0 +1,3 @@
import Setting from './src/Setting.vue'
export { Setting }
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ElDrawer, ElDivider, ElMessage } from 'element-plus'
import { ref, unref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { ThemeSwitch } from '@/components/ThemeSwitch'
import { useCssVar } from '@vueuse/core'
import { useAppStore } from '@/store/modules/app'
import { trim, setCssVar, getCssVar } from '@/utils'
import ColorRadioPicker from './components/ColorRadioPicker.vue'
import InterfaceDisplay from './components/InterfaceDisplay.vue'
import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
import { useStorage } from '@/hooks/web/useStorage'
import { useClipboard } from '@vueuse/core'
import { useDesign } from '@/hooks/web/useDesign'
const { clear: storageClear } = useStorage('localStorage')
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('setting')
const appStore = useAppStore()
const { t } = useI18n()
const drawer = ref(false)
// 主题色相关
const systemTheme = ref(appStore.getTheme.elColorPrimary)
const setSystemTheme = (color: string) => {
setCssVar('--el-color-primary', color)
appStore.setTheme({ elColorPrimary: color })
const leftMenuBgColor = useCssVar('--left-menu-bg-color', document.documentElement)
setMenuTheme(trim(unref(leftMenuBgColor)))
}
// 头部主题相关
const headerTheme = ref(appStore.getTheme.topHeaderBgColor || '')
const setHeaderTheme = (color: string) => {
appStore.setHeaderTheme(color)
}
// 菜单主题相关
const menuTheme = ref(appStore.getTheme.leftMenuBgColor || '')
const setMenuTheme = (color: string) => {
appStore.setMenuTheme(color)
}
// 监听layout变化,重置一些主题色
// watch(
// () => layout.value,
// (n) => {
// if (n === 'top' && !appStore.getIsDark) {
// headerTheme.value = '#fff'
// setHeaderTheme('#fff')
// } else {
// setMenuTheme(unref(menuTheme))
// }
// }
// )
// 拷贝
const copyConfig = async () => {
const { copy, copied, isSupported } = useClipboard({
source: `
// 面包屑
breadcrumb: ${appStore.getBreadcrumb},
// 面包屑图标
breadcrumbIcon: ${appStore.getBreadcrumbIcon},
// 折叠图标
hamburger: ${appStore.getHamburger},
// 全屏图标
screenfull: ${appStore.getScreenfull},
// 尺寸图标
size: ${appStore.getSize},
// 多语言图标
locale: ${appStore.getLocale},
// 标签页
tagsView: ${appStore.getTagsView},
// 标签页图标
getTagsViewIcon: ${appStore.getTagsViewIcon},
// logo
logo: ${appStore.getLogo},
// 菜单手风琴
uniqueOpened: ${appStore.getUniqueOpened},
// 固定header
fixedHeader: ${appStore.getFixedHeader},
// 页脚
footer: ${appStore.getFooter},
// 灰色模式
greyMode: ${appStore.getGreyMode},
// layout布局
layout: '${appStore.getLayout}',
// 暗黑模式
isDark: ${appStore.getIsDark},
// 组件尺寸
currentSize: '${appStore.getCurrentSize}',
// 主题相关
theme: {
// 主题色
elColorPrimary: '${appStore.getTheme.elColorPrimary}',
// 左侧菜单边框颜色
leftMenuBorderColor: '${appStore.getTheme.leftMenuBorderColor}',
// 左侧菜单背景颜色
leftMenuBgColor: '${appStore.getTheme.leftMenuBgColor}',
// 左侧菜单浅色背景颜色
leftMenuBgLightColor: '${appStore.getTheme.leftMenuBgLightColor}',
// 左侧菜单选中背景颜色
leftMenuBgActiveColor: '${appStore.getTheme.leftMenuBgActiveColor}',
// 左侧菜单收起选中背景颜色
leftMenuCollapseBgActiveColor: '${appStore.getTheme.leftMenuCollapseBgActiveColor}',
// 左侧菜单字体颜色
leftMenuTextColor: '${appStore.getTheme.leftMenuTextColor}',
// 左侧菜单选中字体颜色
leftMenuTextActiveColor: '${appStore.getTheme.leftMenuTextActiveColor}',
// logo字体颜色
logoTitleTextColor: '${appStore.getTheme.logoTitleTextColor}',
// logo边框颜色
logoBorderColor: '${appStore.getTheme.logoBorderColor}',
// 头部背景颜色
topHeaderBgColor: '${appStore.getTheme.topHeaderBgColor}',
// 头部字体颜色
topHeaderTextColor: '${appStore.getTheme.topHeaderTextColor}',
// 头部悬停颜色
topHeaderHoverColor: '${appStore.getTheme.topHeaderHoverColor}',
// 头部边框颜色
topToolBorderColor: '${appStore.getTheme.topToolBorderColor}'
}
`,
legacy: true
})
if (!isSupported) {
ElMessage.error(t('setting.copyFailed'))
} else {
await copy()
if (unref(copied)) {
ElMessage.success(t('setting.copySuccess'))
}
}
}
// 清空缓存
const clear = () => {
storageClear()
window.location.reload()
}
const themeChange = () => {
const color = getCssVar('--el-bg-color')
setMenuTheme(color)
setHeaderTheme(color)
}
</script>
<template>
<div
:class="prefixCls"
class="fixed top-[45%] right-0 w-40px h-40px flex items-center justify-center bg-[var(--el-color-primary)] cursor-pointer z-10"
@click="drawer = true"
>
<Icon icon="ant-design:setting-outlined" color="#fff" />
</div>
<ElDrawer v-model="drawer" direction="rtl" size="350px" :z-index="4000">
<template #header>
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
</template>
<div class="text-center">
<!-- 主题 -->
<ElDivider>{{ t('setting.theme') }}</ElDivider>
<ThemeSwitch @change="themeChange" />
<!-- 布局 -->
<ElDivider>{{ t('setting.layout') }}</ElDivider>
<LayoutRadioPicker />
<!-- 系统主题 -->
<ElDivider>{{ t('setting.systemTheme') }}</ElDivider>
<ColorRadioPicker
v-model="systemTheme"
:schema="[
'#409eff',
'#009688',
'#536dfe',
'#ff5c93',
'#ee4f12',
'#0096c7',
'#9c27b0',
'#ff9800'
]"
@change="setSystemTheme"
/>
<!-- 头部主题 -->
<ElDivider>{{ t('setting.headerTheme') }}</ElDivider>
<ColorRadioPicker
v-model="headerTheme"
:schema="[
'#fff',
'#151515',
'#5172dc',
'#e74c3c',
'#24292e',
'#394664',
'#009688',
'#383f45'
]"
@change="setHeaderTheme"
/>
<!-- 菜单主题 -->
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
<ColorRadioPicker
v-model="menuTheme"
:schema="[
'#fff',
'#001529',
'#212121',
'#273352',
'#191b24',
'#383f45',
'#001628',
'#344058'
]"
@change="setMenuTheme"
/>
</div>
<!-- 界面显示 -->
<ElDivider>{{ t('setting.interfaceDisplay') }}</ElDivider>
<InterfaceDisplay />
<ElDivider />
<div>
<BaseButton type="primary" class="w-full" @click="copyConfig">{{
t('setting.copy')
}}</BaseButton>
</div>
<div class="mt-5px">
<BaseButton type="danger" class="w-full" @click="clear">
{{ t('setting.clearAndReset') }}
</BaseButton>
</div>
</ElDrawer>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-setting';
.@{prefix-cls} {
border-radius: 6px 0 0 6px;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { PropType, watch, unref, ref } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('color-radio-picker')
const props = defineProps({
schema: {
type: Array as PropType<string[]>,
default: () => []
},
modelValue: propTypes.string.def('')
})
const emit = defineEmits(['update:modelValue', 'change'])
const colorVal = ref(props.modelValue)
watch(
() => props.modelValue,
(val: string) => {
if (val === unref(colorVal)) return
colorVal.value = val
}
)
// 监听
watch(
() => colorVal.value,
(val: string) => {
emit('update:modelValue', val)
emit('change', val)
}
)
</script>
<template>
<div :class="prefixCls" class="flex flex-wrap space-x-14px">
<span
v-for="(item, i) in schema"
:key="`radio-${i}`"
class="w-20px h-20px cursor-pointer rounded-2px border-solid border-gray-300 border-2px text-center leading-20px mb-5px"
:class="{ 'is-active': colorVal === item }"
:style="{
background: item
}"
@click="colorVal = item"
>
<Icon v-if="colorVal === item" color="#fff" icon="ep:check" :size="16" />
</span>
</div>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-color-radio-picker';
.@{prefix-cls} {
.is-active {
border-color: var(--el-color-primary);
}
}
</style>
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ElSwitch, ElMessage } from 'element-plus'
import { useI18n } from '@/hooks/web/useI18n'
import { useAppStore } from '@/store/modules/app'
import { computed, ref, watch } from 'vue'
import { setCssVar } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('interface-display')
const appStore = useAppStore()
const { t } = useI18n()
// 面包屑
const breadcrumb = ref(appStore.getBreadcrumb)
const breadcrumbChange = (show: boolean) => {
appStore.setBreadcrumb(show)
}
// 面包屑图标
const breadcrumbIcon = ref(appStore.getBreadcrumbIcon)
const breadcrumbIconChange = (show: boolean) => {
appStore.setBreadcrumbIcon(show)
}
// 折叠图标
const hamburger = ref(appStore.getHamburger)
const hamburgerChange = (show: boolean) => {
appStore.setHamburger(show)
}
// 全屏图标
const screenfull = ref(appStore.getScreenfull)
const screenfullChange = (show: boolean) => {
appStore.setScreenfull(show)
}
// 尺寸图标
const size = ref(appStore.getSize)
const sizeChange = (show: boolean) => {
appStore.setSize(show)
}
// 多语言图标
const locale = ref(appStore.getLocale)
const localeChange = (show: boolean) => {
appStore.setLocale(show)
}
// 标签页
const tagsView = ref(appStore.getTagsView)
const tagsViewChange = (show: boolean) => {
// 切换标签栏显示时,同步切换标签栏的高度
setCssVar('--tags-view-height', show ? '35px' : '0px')
appStore.setTagsView(show)
}
// 标签页图标
const tagsViewIcon = ref(appStore.getTagsViewIcon)
const tagsViewIconChange = (show: boolean) => {
appStore.setTagsViewIcon(show)
}
// logo
const logo = ref(appStore.getLogo)
const logoChange = (show: boolean) => {
appStore.setLogo(show)
}
// 菜单手风琴
const uniqueOpened = ref(appStore.getUniqueOpened)
const uniqueOpenedChange = (uniqueOpened: boolean) => {
appStore.setUniqueOpened(uniqueOpened)
}
// 固定头部
const fixedHeader = ref(appStore.getFixedHeader)
const fixedHeaderChange = (show: boolean) => {
appStore.setFixedHeader(show)
}
// 灰色模式
const greyMode = ref(appStore.getGreyMode)
const greyModeChange = (show: boolean) => {
appStore.setGreyMode(show)
}
// 动态路由
const dynamicRouter = ref(!!appStore.getDynamicRouter)
const dynamicRouterChange = (show: boolean) => {
ElMessage.info(t('setting.reExperienced'))
appStore.setDynamicRouter(show)
}
// 服务端动态路由
const serverDynamicRouter = ref(appStore.getServerDynamicRouter)
const serverDynamicRouterChange = (show: boolean) => {
ElMessage.info(t('setting.reExperienced'))
appStore.setServerDynamicRouter(show)
}
// 固定菜单
const fixedMenu = ref(appStore.getFixedMenu)
const fixedMenuChange = (show: boolean) => {
appStore.setFixedMenu(show)
}
const layout = computed(() => appStore.getLayout)
watch(
() => layout.value,
(n) => {
if (n === 'top') {
appStore.setCollapse(false)
}
}
)
</script>
<template>
<div :class="prefixCls">
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.breadcrumb') }}</span>
<ElSwitch v-model="breadcrumb" @change="breadcrumbChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.breadcrumbIcon') }}</span>
<ElSwitch v-model="breadcrumbIcon" @change="breadcrumbIconChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.hamburgerIcon') }}</span>
<ElSwitch v-model="hamburger" @change="hamburgerChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.screenfullIcon') }}</span>
<ElSwitch v-model="screenfull" @change="screenfullChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.sizeIcon') }}</span>
<ElSwitch v-model="size" @change="sizeChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.localeIcon') }}</span>
<ElSwitch v-model="locale" @change="localeChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.tagsView') }}</span>
<ElSwitch v-model="tagsView" @change="tagsViewChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.tagsViewIcon') }}</span>
<ElSwitch v-model="tagsViewIcon" @change="tagsViewIconChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.logo') }}</span>
<ElSwitch v-model="logo" @change="logoChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.uniqueOpened') }}</span>
<ElSwitch v-model="uniqueOpened" @change="uniqueOpenedChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.fixedHeader') }}</span>
<ElSwitch v-model="fixedHeader" @change="fixedHeaderChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.greyMode') }}</span>
<ElSwitch v-model="greyMode" @change="greyModeChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.dynamicRouter') }}</span>
<ElSwitch v-model="dynamicRouter" @change="dynamicRouterChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.serverDynamicRouter') }}</span>
<ElSwitch v-model="serverDynamicRouter" @change="serverDynamicRouterChange" />
</div>
<div class="flex justify-between items-center">
<span class="text-14px">{{ t('setting.fixedMenu') }}</span>
<ElSwitch v-model="fixedMenu" @change="fixedMenuChange" />
</div>
</div>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app'
import { computed } from 'vue'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('layout-radio-picker')
const appStore = useAppStore()
const layout = computed(() => appStore.getLayout)
</script>
<template>
<div :class="prefixCls" class="flex flex-wrap space-x-14px">
<div
:class="[
`${prefixCls}__classic`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'classic'
}
]"
@click="appStore.setLayout('classic')"
></div>
<div
:class="[
`${prefixCls}__top-left`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'topLeft'
}
]"
@click="appStore.setLayout('topLeft')"
></div>
<div
:class="[
`${prefixCls}__top`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'top'
}
]"
@click="appStore.setLayout('top')"
></div>
<div
:class="[
`${prefixCls}__cut-menu`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'cutMenu'
}
]"
@click="appStore.setLayout('cutMenu')"
>
<div class="absolute h-full w-[33%] top-0 left-[10%] bg-gray-200"></div>
</div>
</div>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-layout-radio-picker';
.@{prefix-cls} {
&__classic {
border: 2px solid #e5e7eb;
border-radius: 4px;
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 33%;
height: 100%;
background-color: #273352;
border-radius: 4px 0 0 4px;
content: '';
}
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 25%;
background-color: #fff;
border-radius: 4px 4px 0;
content: '';
}
}
&__top-left {
border: 2px solid #e5e7eb;
border-radius: 4px;
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 33%;
background-color: #273352;
border-radius: 4px 4px 0 0;
content: '';
}
&::after {
position: absolute;
top: 0;
left: 0;
width: 33%;
height: 100%;
background-color: #fff;
border-radius: 4px 0 0 4px;
content: '';
}
}
&__top {
border: 2px solid #e5e7eb;
border-radius: 4px;
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 33%;
background-color: #273352;
border-radius: 4px 4px 0 0;
content: '';
}
}
&__cut-menu {
border: 2px solid #e5e7eb;
border-radius: 4px;
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 33%;
background-color: #273352;
border-radius: 4px 4px 0 0;
content: '';
}
&::after {
position: absolute;
top: 0;
left: 0;
width: 10%;
height: 100%;
background-color: #fff;
border-radius: 4px 0 0 4px;
content: '';
}
}
.is-acitve {
border-color: var(--el-color-primary);
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
import SizeDropdown from './src/SizeDropdown.vue'
export { SizeDropdown }
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ComponentSize } from 'element-plus'
import { useAppStore } from '@/store/modules/app'
import { useI18n } from '@/hooks/web/useI18n'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('size-dropdown')
defineProps({
color: propTypes.string.def('')
})
const { t } = useI18n()
const appStore = useAppStore()
const sizeMap = computed(() => appStore.sizeMap)
const setCurrentSize = (size: ComponentSize) => {
appStore.setCurrentSize(size)
}
</script>
<template>
<ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize">
<Icon :size="18" icon="mdi:format-size" :color="color" class="cursor-pointer" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem v-for="item in sizeMap" :key="item" :command="item">
{{ t(`size.${item}`) }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
+3
View File
@@ -0,0 +1,3 @@
import TabMenu from './src/TabMenu.vue'
export { TabMenu }
+246
View File
@@ -0,0 +1,246 @@
<script lang="tsx">
import { usePermissionStore } from '@/store/modules/permission'
import { useAppStore } from '@/store/modules/app'
import { computed, unref, defineComponent, watch, ref, onMounted } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { ElScrollbar } from 'element-plus'
import { Icon } from '@/components/Icon'
import { Menu } from '@/components/Menu'
import { useRouter } from 'vue-router'
import { pathResolve } from '@/utils/routerHelper'
import { cloneDeep } from 'lodash-es'
import { filterMenusPath, initTabMap, tabPathMap } from './helper'
import { useDesign } from '@/hooks/web/useDesign'
import { isUrl } from '@/utils/is'
const { getPrefixCls, variables } = useDesign()
const prefixCls = getPrefixCls('tab-menu')
export default defineComponent({
name: 'TabMenu',
setup() {
const { push, currentRoute } = useRouter()
const { t } = useI18n()
const appStore = useAppStore()
const collapse = computed(() => appStore.getCollapse)
const fixedMenu = computed(() => appStore.getFixedMenu)
const permissionStore = usePermissionStore()
const routers = computed(() => permissionStore.getRouters)
const tabRouters = computed(() => unref(routers).filter((v) => !v?.meta?.hidden))
const setCollapse = () => {
appStore.setCollapse(!unref(collapse))
}
onMounted(() => {
if (unref(fixedMenu)) {
const path = `/${unref(currentRoute).path.split('/')[1]}`
const children = unref(tabRouters).find(
(v) =>
(v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)) &&
v.path === path
)?.children
tabActive.value = path
if (children) {
permissionStore.setMenuTabRouters(
cloneDeep(children).map((v) => {
v.path = pathResolve(unref(tabActive), v.path)
return v
})
)
}
}
})
watch(
() => routers.value,
(routers: AppRouteRecordRaw[]) => {
initTabMap(routers)
filterMenusPath(routers, routers)
},
{
immediate: true,
deep: true
}
)
const showTitle = ref(true)
watch(
() => collapse.value,
(collapse: boolean) => {
if (!collapse) {
setTimeout(() => {
showTitle.value = !collapse
}, 200)
} else {
showTitle.value = !collapse
}
},
{
immediate: true
}
)
// 是否显示菜单
const showMenu = ref(unref(fixedMenu) ? true : false)
// tab高亮
const tabActive = ref('')
// tab点击事件
const tabClick = (item: AppRouteRecordRaw) => {
if (isUrl(item.path)) {
window.open(item.path)
return
}
const newPath = item.children ? item.path : item.path.split('/')[0]
const oldPath = unref(tabActive)
tabActive.value = item.children ? item.path : item.path.split('/')[0]
if (item.children) {
if (newPath === oldPath || !unref(showMenu)) {
showMenu.value = unref(fixedMenu) ? true : !unref(showMenu)
}
if (unref(showMenu)) {
permissionStore.setMenuTabRouters(
cloneDeep(item.children).map((v) => {
v.path = pathResolve(unref(tabActive), v.path)
return v
})
)
}
} else {
push(item.path)
permissionStore.setMenuTabRouters([])
showMenu.value = false
}
}
// 设置高亮
const isActive = (currentPath: string) => {
const { path } = unref(currentRoute)
if (tabPathMap[currentPath].includes(path)) {
return true
}
return false
}
const mouseleave = () => {
if (!unref(showMenu) || unref(fixedMenu)) return
showMenu.value = false
}
return () => (
<div
id={`${variables.namespace}-menu`}
class={[
prefixCls,
'relative bg-[var(--left-menu-bg-color)] top-1px z-3000 layout-border__right',
{
'w-[var(--tab-menu-max-width)]': !unref(collapse),
'w-[var(--tab-menu-min-width)]': unref(collapse)
}
]}
onMouseleave={mouseleave}
>
<ElScrollbar class="!h-[calc(100%-var(--tab-menu-collapse-height)-1px)]">
<div>
{() => {
return unref(tabRouters).map((v) => {
const item = (
v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)
? v
: {
...(v?.children && v?.children[0]),
path: pathResolve(v.path, (v?.children && v?.children[0])?.path as string)
}
) as AppRouteRecordRaw
return (
<div
class={[
`${prefixCls}__item`,
'text-center text-12px relative py-12px cursor-pointer',
{
'is-active': isActive(v.path)
}
]}
onClick={() => {
tabClick(item)
}}
>
<div>
<Icon icon={item?.meta?.icon}></Icon>
</div>
{!unref(showTitle) ? undefined : (
<p class="break-words mt-5px px-2px">{t(item.meta?.title)}</p>
)}
</div>
)
})
}}
</div>
</ElScrollbar>
<div
class={[
`${prefixCls}--collapse`,
'text-center h-[var(--tab-menu-collapse-height)] leading-[var(--tab-menu-collapse-height)] cursor-pointer'
]}
onClick={setCollapse}
>
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon>
</div>
<Menu
class={[
'!absolute top-0 z-3000',
{
'!left-[var(--tab-menu-min-width)]': unref(collapse),
'!left-[var(--tab-menu-max-width)]': !unref(collapse),
'!w-[var(--left-menu-max-width)] border-r-1 border-r-solid border-[var(--el-border-color)]':
unref(showMenu) || unref(fixedMenu),
'!w-0': !unref(showMenu) && !unref(fixedMenu)
}
]}
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
></Menu>
</div>
)
}
})
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-tab-menu';
.@{prefix-cls} {
transition: all var(--transition-time-02);
&__item {
color: var(--left-menu-text-color);
transition: all var(--transition-time-02);
&:hover {
color: var(--left-menu-text-active-color);
// background-color: var(--left-menu-bg-active-color);
}
}
&--collapse {
color: var(--left-menu-text-color);
background-color: var(--left-menu-bg-light-color);
}
.is-active {
color: var(--left-menu-text-active-color);
background-color: var(--left-menu-bg-active-color);
}
}
</style>
+52
View File
@@ -0,0 +1,52 @@
import { getAllParentPath } from '@/components/Menu/src/helper'
import type { RouteMeta } from 'vue-router'
import { isUrl } from '@/utils/is'
import { cloneDeep } from 'lodash-es'
import { reactive } from 'vue'
export type TabMapTypes = {
[key: string]: string[]
}
export const tabPathMap = reactive<TabMapTypes>({})
export const initTabMap = (routes: AppRouteRecordRaw[]) => {
for (const v of routes) {
const meta = (v.meta ?? {}) as RouteMeta
if (!meta?.hidden) {
tabPathMap[v.path] = []
}
}
}
export const filterMenusPath = (
routes: AppRouteRecordRaw[],
allRoutes: AppRouteRecordRaw[]
): AppRouteRecordRaw[] => {
const res: AppRouteRecordRaw[] = []
for (const v of routes) {
let data: Nullable<AppRouteRecordRaw> = null
const meta = (v.meta ?? {}) as RouteMeta
if (!meta.hidden || meta.canTo) {
const allParentPath = getAllParentPath<AppRouteRecordRaw>(allRoutes, v.path)
const fullPath = isUrl(v.path) ? v.path : allParentPath.join('/')
data = cloneDeep(v)
data.path = fullPath
if (v.children && data) {
data.children = filterMenusPath(v.children, allRoutes)
}
if (data) {
res.push(data)
}
if (allParentPath.length && Reflect.has(tabPathMap, allParentPath[0])) {
tabPathMap[allParentPath[0]].push(fullPath)
}
}
}
return res
}
+21
View File
@@ -0,0 +1,21 @@
import Table from './src/Table.vue'
import { ElTable } from 'element-plus'
import { TableColumn, TableSetProps } from './src/types'
export type {
TableColumn,
TableSlotDefault,
Pagination,
TableSetProps,
TableProps
} from './src/types'
export interface TableExpose {
setProps: (props: Recordable) => void
setColumn: (columnProps: TableSetProps[]) => void
addColumn: (column: TableColumn, index?: number) => void
delColumn: (field: string) => void
elTableRef: ComponentRef<typeof ElTable>
}
export { Table }
+588
View File
@@ -0,0 +1,588 @@
<script lang="tsx">
import {
ElTable,
ElTableColumn,
ElPagination,
ComponentSize,
ElTooltipProps,
ElImage,
ElEmpty,
ElCard
} from 'element-plus'
import { defineComponent, PropType, ref, computed, unref, watch, onMounted } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { setIndex } from './helper'
import type { TableProps, TableColumn, Pagination, TableSetProps } from './types'
import { set, get } from 'lodash-es'
import { CSSProperties } from 'vue'
import { getSlot } from '@/utils/tsxHelper'
import TableActions from './components/TableActions.vue'
import { createVideoViewer } from '@/components/VideoPlayer'
import { Icon } from '@/components/Icon'
import { BaseButton } from '@/components/Button'
export default defineComponent({
name: 'Table',
props: {
pageSize: propTypes.number.def(10),
currentPage: propTypes.number.def(1),
// 是否展示表格的工具栏
showAction: propTypes.bool.def(false),
// 是否所有的超出隐藏,优先级低于schema中的showOverflowTooltip,
showOverflowTooltip: propTypes.bool.def(true),
// 表头
columns: {
type: Array as PropType<TableColumn[]>,
default: () => []
},
// 是否展示分页
pagination: {
type: Object as PropType<Pagination>,
default: (): Pagination | undefined => undefined
},
// 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key)
reserveSelection: propTypes.bool.def(false),
// 加载状态
loading: propTypes.bool.def(false),
// 是否叠加索引
reserveIndex: propTypes.bool.def(false),
// 对齐方式
align: propTypes.string
.validate((v: string) => ['left', 'center', 'right'].includes(v))
.def('left'),
// 表头对齐方式
headerAlign: propTypes.string
.validate((v: string) => ['left', 'center', 'right'].includes(v))
.def('left'),
data: {
type: Array as PropType<Recordable[]>,
default: () => []
},
// 图片自动预览字段数组
imagePreview: {
type: Array as PropType<string[]>,
default: () => []
},
// 视频自动预览字段数组
videoPreview: {
type: Array as PropType<string[]>,
default: () => []
},
height: propTypes.oneOfType([Number, String]),
maxHeight: propTypes.oneOfType([Number, String]),
stripe: propTypes.bool.def(false),
border: propTypes.bool.def(true),
size: {
type: String as PropType<ComponentSize>,
validator: (v: ComponentSize) => ['medium', 'small', 'mini'].includes(v)
},
fit: propTypes.bool.def(true),
showHeader: propTypes.bool.def(true),
highlightCurrentRow: propTypes.bool.def(false),
currentRowKey: propTypes.oneOfType([Number, String]),
// row-class-name, 类型为 (row: Recordable, rowIndex: number) => string | string
rowClassName: {
type: [Function, String] as PropType<(row: Recordable, rowIndex: number) => string | string>,
default: ''
},
rowStyle: {
type: [Function, Object] as PropType<
(row: Recordable, rowIndex: number) => Recordable | CSSProperties
>,
default: () => undefined
},
cellClassName: {
type: [Function, String] as PropType<
(row: Recordable, column: any, rowIndex: number) => string | string
>,
default: ''
},
cellStyle: {
type: [Function, Object] as PropType<
(row: Recordable, column: any, rowIndex: number) => Recordable | CSSProperties
>,
default: () => undefined
},
headerRowClassName: {
type: [Function, String] as PropType<(row: Recordable, rowIndex: number) => string | string>,
default: ''
},
headerRowStyle: {
type: [Function, Object] as PropType<
(row: Recordable, rowIndex: number) => Recordable | CSSProperties
>,
default: () => undefined
},
headerCellClassName: {
type: [Function, String] as PropType<
(row: Recordable, column: any, rowIndex: number) => string | string
>,
default: ''
},
headerCellStyle: {
type: [Function, Object] as PropType<
(row: Recordable, column: any, rowIndex: number) => Recordable | CSSProperties
>,
default: () => undefined
},
rowKey: propTypes.string.def('id'),
emptyText: propTypes.string.def('No Data'),
defaultExpandAll: propTypes.bool.def(false),
expandRowKeys: {
type: Array as PropType<string[]>,
default: () => []
},
defaultSort: {
type: Object as PropType<{ prop: string; order: string }>,
default: () => ({})
},
tooltipEffect: {
type: String as PropType<'dark' | 'light'>,
default: 'dark'
},
tooltipOptions: {
type: Object as PropType<
Pick<
ElTooltipProps,
| 'effect'
| 'enterable'
| 'hideAfter'
| 'offset'
| 'placement'
| 'popperClass'
| 'popperOptions'
| 'showAfter'
| 'showArrow'
>
>,
default: () => ({
enterable: true,
placement: 'top',
showArrow: true,
hideAfter: 200,
popperOptions: { strategy: 'fixed' }
})
},
showSummary: propTypes.bool.def(false),
sumText: propTypes.string.def('Sum'),
summaryMethod: {
type: Function as PropType<(param: { columns: any[]; data: any[] }) => any[]>,
default: () => undefined
},
spanMethod: {
type: Function as PropType<
(param: { row: any; column: any; rowIndex: number; columnIndex: number }) => any[]
>,
default: () => undefined
},
selectOnIndeterminate: propTypes.bool.def(true),
indent: propTypes.number.def(16),
lazy: propTypes.bool.def(false),
load: {
type: Function as PropType<(row: Recordable, treeNode: any, resolve: Function) => void>,
default: () => undefined
},
treeProps: {
type: Object as PropType<{ hasChildren?: string; children?: string; label?: string }>,
default: () => ({ hasChildren: 'hasChildren', children: 'children', label: 'label' })
},
tableLayout: {
type: String as PropType<'auto' | 'fixed'>,
default: 'fixed'
},
scrollbarAlwaysOn: propTypes.bool.def(false),
flexible: propTypes.bool.def(false),
// 自定义内容
customContent: propTypes.bool.def(false),
cardBodyStyle: {
type: Object as PropType<CSSProperties>,
default: () => ({})
},
cardBodyClass: {
type: String as PropType<string>,
default: ''
},
cardWrapStyle: {
type: Object as PropType<CSSProperties>,
default: () => ({})
},
cardWrapClass: {
type: String as PropType<string>,
default: ''
}
},
emits: ['update:pageSize', 'update:currentPage', 'register', 'refresh'],
setup(props, { attrs, emit, slots, expose }) {
const elTableRef = ref<ComponentRef<typeof ElTable>>()
// 注册
onMounted(() => {
const tableRef = unref(elTableRef)
emit('register', tableRef?.$parent, unref(elTableRef))
})
const pageSizeRef = ref(props.pageSize)
const currentPageRef = ref(props.currentPage)
// useTable传入的props
const outsideProps = ref<TableProps>({})
const mergeProps = ref<TableProps>({})
const getProps = computed(() => {
const propsObj = { ...props }
Object.assign(propsObj, unref(mergeProps))
return propsObj
})
const setProps = (props: TableProps = {}) => {
mergeProps.value = Object.assign(unref(mergeProps), props)
outsideProps.value = { ...props } as any
}
const setColumn = (columnProps: TableSetProps[], columnsChildren?: TableColumn[]) => {
const { columns } = unref(getProps)
for (const v of columnsChildren || columns) {
for (const item of columnProps) {
if (v.field === item.field) {
set(v, item.path, item.value)
} else if (v.children?.length) {
setColumn(columnProps, v.children)
}
}
}
}
const addColumn = (column: TableColumn, index?: number) => {
const { columns } = unref(getProps)
if (index !== void 0) {
columns.splice(index, 0, column)
} else {
columns.push(column)
}
}
const delColumn = (field: string) => {
const { columns } = unref(getProps)
const index = columns.findIndex((item) => item.field === field)
if (index > -1) {
columns.splice(index, 1)
}
}
const refresh = () => {
emit('refresh')
}
const changSize = (size: ComponentSize) => {
setProps({ size })
}
const confirmSetColumn = (columns: TableColumn[]) => {
setProps({ columns })
}
expose({
setProps,
setColumn,
delColumn,
addColumn,
elTableRef
})
const pagination = computed(() => {
return Object.assign(
{
small: false,
background: false,
pagerCount: 7,
layout: 'sizes, prev, pager, next, jumper, ->, total',
pageSizes: [10, 20, 30, 40, 50, 100],
disabled: false,
hideOnSinglePage: false,
total: 10
},
unref(getProps).pagination
)
})
watch(
() => unref(getProps).pageSize,
(val: number) => {
pageSizeRef.value = val
}
)
watch(
() => unref(getProps).currentPage,
(val: number) => {
currentPageRef.value = val
}
)
watch(
() => pageSizeRef.value,
(val: number) => {
emit('update:pageSize', val)
}
)
watch(
() => currentPageRef.value,
(val: number) => {
emit('update:currentPage', val)
}
)
const getBindValue = computed(() => {
const bindValue: Recordable = { ...attrs, ...unref(getProps) }
delete bindValue.columns
delete bindValue.data
delete bindValue.align
return bindValue
})
const renderTreeTableColumn = (columnsChildren: TableColumn[]) => {
const { align, headerAlign, showOverflowTooltip, imagePreview, videoPreview } =
unref(getProps)
return columnsChildren.map((v) => {
if (v.hidden) return null
const props = { ...v } as any
if (props.children) delete props.children
const children = v.children
const slots = {
default: (...args: any[]) => {
const data = args[0]
let isPreview = false
isPreview =
imagePreview.some((item) => (item as string) === v.field) ||
videoPreview.some((item) => (item as string) === v.field)
return children && children.length
? renderTreeTableColumn(children)
: props?.slots?.default
? props.slots.default(...args)
: v?.formatter
? v?.formatter?.(data.row, data.column, get(data.row, v.field), data.$index)
: isPreview
? renderPreview(get(data.row, v.field), v.field)
: get(data.row, v.field)
}
}
if (props?.slots?.header) {
slots['header'] = (...args: any[]) => props.slots.header(...args)
}
return (
<ElTableColumn
showOverflowTooltip={showOverflowTooltip}
align={align}
headerAlign={headerAlign}
{...props}
prop={v.field}
>
{slots}
</ElTableColumn>
)
})
}
const renderPreview = (url: string, field: string) => {
const { imagePreview, videoPreview } = unref(getProps)
return (
<div class="flex items-center">
{imagePreview.includes(field) ? (
<ElImage
src={url}
fit="cover"
class="w-[100%]"
lazy
preview-src-list={[url]}
preview-teleported
/>
) : videoPreview.includes(field) ? (
<BaseButton
type="primary"
icon={<Icon icon="ep:video-play" />}
onClick={() => {
createVideoViewer({
url
})
}}
>
预览
</BaseButton>
) : null}
</div>
)
}
const renderTableColumn = (columnsChildren?: TableColumn[]) => {
const {
columns,
reserveIndex,
pageSize,
currentPage,
align,
headerAlign,
showOverflowTooltip,
reserveSelection,
imagePreview,
videoPreview
} = unref(getProps)
return (columnsChildren || columns).map((v) => {
if (v.hidden) return null
if (v.type === 'index') {
return (
<ElTableColumn
type="index"
index={
v.index ? v.index : (index) => setIndex(reserveIndex, index, pageSize, currentPage)
}
align={v.align || align}
headerAlign={v.headerAlign || headerAlign}
label={v.label}
fixed={v.fixed}
width="65px"
></ElTableColumn>
)
} else if (v.type === 'selection') {
return (
<ElTableColumn
type="selection"
reserveSelection={reserveSelection}
align={align}
headerAlign={headerAlign}
selectable={v.selectable}
width="50"
></ElTableColumn>
)
} else {
const props = { ...v } as any
if (props.children) delete props.children
const children = v.children
const slots = {
default: (...args: any[]) => {
const data = args[0]
let isPreview = false
isPreview =
imagePreview.some((item) => (item as string) === v.field) ||
videoPreview.some((item) => (item as string) === v.field)
return children && children.length
? renderTreeTableColumn(children)
: props?.slots?.default
? props.slots.default(...args)
: v?.formatter
? v?.formatter?.(data.row, data.column, get(data.row, v.field), data.$index)
: isPreview
? renderPreview(get(data.row, v.field), v.field)
: get(data.row, v.field)
}
}
if (props?.slots?.header) {
slots['header'] = (...args: any[]) => props.slots.header(...args)
}
return (
<ElTableColumn
showOverflowTooltip={showOverflowTooltip}
align={align}
headerAlign={headerAlign}
{...props}
prop={v.field}
>
{slots}
</ElTableColumn>
)
}
})
}
return () => {
const tableSlots = {}
if (getSlot(slots, 'empty')) {
tableSlots['empty'] = (...args: any[]) => getSlot(slots, 'empty', args)
}
if (getSlot(slots, 'append')) {
tableSlots['append'] = (...args: any[]) => getSlot(slots, 'append', args)
}
return (
<div v-loading={unref(getProps).loading}>
{unref(getProps).customContent ? (
<div class="flex flex-wrap">
{unref(getProps)?.data?.length ? (
unref(getProps)?.data.map((item) => {
const cardSlots = {
default: () => {
return getSlot(slots, 'content', item)
}
}
if (getSlot(slots, 'content-header')) {
cardSlots['header'] = () => {
return getSlot(slots, 'content-header', item)
}
}
if (getSlot(slots, 'content-footer')) {
cardSlots['footer'] = () => {
return getSlot(slots, 'content-footer', item)
}
}
return (
<ElCard
shadow="hover"
class={unref(getProps).cardWrapClass}
style={unref(getProps).cardWrapStyle}
bodyClass={unref(getProps).cardBodyClass}
bodyStyle={unref(getProps).cardBodyStyle}
>
{cardSlots}
</ElCard>
)
})
) : (
<div class="flex flex-1 justify-center">
<ElEmpty description="暂无数据" />
</div>
)}
</div>
) : (
<>
{unref(getProps).showAction && !unref(getProps).customContent ? (
<TableActions
columns={unref(getProps).columns}
onChangSize={changSize}
onRefresh={refresh}
onConfirm={confirmSetColumn}
/>
) : null}
<ElTable ref={elTableRef} data={unref(getProps).data} {...unref(getBindValue)}>
{{
default: () => renderTableColumn(),
...tableSlots
}}
</ElTable>
</>
)}
{unref(getProps).pagination ? (
<ElPagination
v-model:pageSize={pageSizeRef.value}
v-model:currentPage={currentPageRef.value}
class="mt-10px"
{...unref(pagination)}
></ElPagination>
) : undefined}
</div>
)
}
}
})
</script>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import {
ElDrawer,
ElCheckbox,
ElCheckboxGroup,
ElText,
ElRadioButton,
ElRadioGroup
} from 'element-plus'
import { TableColumn } from '../types'
import { PropType, ref, watch, unref } from 'vue'
import { cloneDeep } from 'lodash-es'
import { DEFAULT_FILTER_COLUMN } from '@/constants'
import { VueDraggable } from 'vue-draggable-plus'
const modelValue = defineModel<boolean>()
const props = defineProps({
columns: {
type: Array as PropType<TableColumn[]>,
default: () => []
}
})
const emit = defineEmits(['confirm'])
const oldColumns = ref<TableColumn[]>()
const settingColumns = ref<TableColumn[]>()
// 存储不要的列
const hiddenColumns = ref<TableColumn[]>([])
const defaultCheckColumns = ref<string[]>([])
const checkColumns = ref<string[]>([])
const checkAll = ref(false)
const isIndeterminate = ref(true)
const handleCheckAllChange = (val: boolean) => {
checkColumns.value = val ? unref(defaultCheckColumns) : []
isIndeterminate.value = false
}
const handleCheckedColumnsChange = (value: string[]) => {
const checkedCount = value.length
checkAll.value = checkedCount === unref(defaultCheckColumns)?.length
isIndeterminate.value = checkedCount > 0 && checkedCount < unref(defaultCheckColumns)?.length
}
const confirm = () => {
const newColumns = cloneDeep(unref(settingColumns))?.map((item) => {
const fixed = unref(settingColumns)?.find((col) => col.field === item.field)?.fixed
item.hidden = !!!unref(checkColumns)?.includes(item.field)
item.fixed = fixed ? fixed : undefined
return item
})
emit('confirm', [...unref(hiddenColumns), ...(newColumns || [])])
modelValue.value = false
}
const restore = () => {
initColumns([...unref(hiddenColumns), ...(unref(oldColumns) || [])], true)
}
const initColumns = (columns: TableColumn[], isReStore = false) => {
const newColumns = columns?.filter((item) => {
if (!isReStore) {
item.fixed = item.fixed !== void 0 ? item.fixed : undefined
}
return (item.type && !DEFAULT_FILTER_COLUMN.includes(item.type)) || !item.type
})
if (!unref(oldColumns)?.length) {
oldColumns.value = cloneDeep(newColumns)
}
settingColumns.value = cloneDeep(newColumns)
hiddenColumns.value = cloneDeep(
columns?.filter((item) => item.type && DEFAULT_FILTER_COLUMN.includes(item.type))
)
defaultCheckColumns.value = unref(settingColumns)?.map((item) => item.field) || []
checkColumns.value =
unref(settingColumns)
?.filter((item) => !item.hidden)
?.map((item) => item.field) || []
if (unref(checkColumns)?.length === unref(defaultCheckColumns)?.length) {
checkAll.value = true
isIndeterminate.value = false
}
}
watch(
() => props.columns,
(columns) => {
initColumns(columns)
},
{
immediate: true,
deep: true
}
)
</script>
<template>
<ElDrawer v-model="modelValue" title="列设置" size="350px">
<div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between">
<ElCheckbox
v-model="checkAll"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
/>
<ElText class="ml-8px!">{{ checkColumns.length }} / {{ settingColumns?.length }}</ElText>
</div>
<ElText>固定 / 排序</ElText>
</div>
<div v-if="settingColumns?.length">
<VueDraggable
v-model="settingColumns"
target=".el-checkbox-group"
handle=".handle"
:animation="150"
>
<ElCheckboxGroup
ref="draggableWrap"
v-model="checkColumns"
@change="handleCheckedColumnsChange"
>
<div
v-for="item in settingColumns"
:key="item.field"
class="flex items-center justify-between mt-12px"
>
<ElCheckbox :label="item.field">
{{ item.label }}
</ElCheckbox>
<div class="flex items-center">
<ElRadioGroup size="small" v-model="item.fixed">
<ElRadioButton label="left">
<Icon icon="ep:arrow-left" />
</ElRadioButton>
<ElRadioButton :label="undefined">
<Icon icon="ep:close" />
</ElRadioButton>
<ElRadioButton label="right">
<Icon icon="ep:arrow-right" />
</ElRadioButton>
</ElRadioGroup>
<div class="ml-12px cursor-move handle"><Icon icon="ep:rank" /></div>
</div>
</div>
</ElCheckboxGroup>
</VueDraggable>
</div>
</div>
<template #footer>
<div>
<BaseButton @click="restore">还原</BaseButton>
<BaseButton type="primary" @click="confirm">确定</BaseButton>
</div>
</template>
</ElDrawer>
</template>
@@ -0,0 +1,105 @@
<script lang="tsx">
import { defineComponent, unref, computed, PropType, ref } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ComponentSize } from 'element-plus'
import { Icon } from '@/components/Icon'
import { useI18n } from '@/hooks/web/useI18n'
import { useAppStore } from '@/store/modules/app'
import { TableColumn } from '../types'
import ColumnSetting from './ColumnSetting.vue'
export default defineComponent({
name: 'TableActions',
components: {
ColumnSetting
},
props: {
columns: {
type: Array as PropType<TableColumn[]>,
default: () => []
}
},
emits: ['refresh', 'changSize', 'confirm'],
setup(props, { emit }) {
const appStore = useAppStore()
const { t } = useI18n()
const sizeMap = computed(() => appStore.sizeMap)
const showSetting = ref(false)
const refresh = () => {
emit('refresh')
}
const changSize = (size: ComponentSize) => {
emit('changSize', size)
}
const confirm = (columns: TableColumn[]) => {
emit('confirm', columns)
}
const showColumnSetting = () => {
showSetting.value = true
}
return () => (
<>
<div class="text-right h-28px flex items-center justify-end">
<div title="刷新" class="w-30px h-20px flex items-center justify-end" onClick={refresh}>
<Icon
icon="ant-design:sync-outlined"
class="cursor-pointer"
hover-color="var(--el-color-primary)"
/>
</div>
<ElDropdown trigger="click" onCommand={changSize}>
{{
default: () => {
return (
<div title="尺寸" class="w-30px h-20px flex items-center justify-end">
<Icon
icon="ant-design:column-height-outlined"
class="cursor-pointer"
hover-color="var(--el-color-primary)"
/>
</div>
)
},
dropdown: () => {
return (
<ElDropdownMenu>
{{
default: () => {
return unref(sizeMap).map((v) => {
return (
<ElDropdownItem key={v} command={v}>
{t(`size.${v}`)}
</ElDropdownItem>
)
})
}
}}
</ElDropdownMenu>
)
}
}}
</ElDropdown>
<div
title="列设置"
class="w-30px h-20px flex items-center justify-end"
onClick={showColumnSetting}
>
<Icon
icon="ant-design:setting-outlined"
class="cursor-pointer"
hover-color="var(--el-color-primary)"
/>
</div>
</div>
<ColumnSetting v-model={showSetting.value} columns={props.columns} onConfirm={confirm} />
</>
)
}
})
</script>
+8
View File
@@ -0,0 +1,8 @@
export const setIndex = (reserveIndex: boolean, index: number, size: number, current: number) => {
const newIndex = index + 1
if (reserveIndex) {
return size * (current - 1) + newIndex
} else {
return newIndex
}
}
+97
View File
@@ -0,0 +1,97 @@
import { TableProps as ElTableProps } from 'element-plus'
export interface TableColumn {
field: string
label?: string
type?: string
/**
* 是否隐藏
*/
hidden?: boolean
children?: TableColumn[]
slots?: {
default?: (...args: any[]) => JSX.Element | JSX.Element[] | null
header?: (...args: any[]) => JSX.Element | null
}
index?: number | ((index: number) => number)
columnKey?: string
width?: string | number
minWidth?: string | number
fixed?: boolean | 'left' | 'right'
renderHeader?: (...args: any[]) => JSX.Element | null
// sortable?: boolean
sortMethod?: (...args: any[]) => number
sortBy?: string | string[] | ((...args: any[]) => string | string[])
sortOrders?: (string | null)[]
resizable?: boolean
formatter?: (...args: any[]) => any
showOverflowTooltip?: boolean
align?: 'left' | 'center' | 'right'
headerAlign?: 'left' | 'center' | 'right'
className?: string
labelClassName?: string
selectable?: (...args: any[]) => boolean
reserveSelection?: boolean
filters?: Array<{ text: string; value: string }>
filterPlacement?: string
filterMultiple?: boolean
filterMethod?: (...args: any[]) => boolean
filteredValue?: string[]
[key: string]: any
}
export interface TableSlotDefault {
row: Recordable
column: TableColumn
$index: number
[key: string]: any
}
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 TableSetProps {
field: string
path: string
value: any
}
export interface TableProps extends Omit<Partial<ElTableProps<any[]>>, 'data'> {
pageSize?: number
currentPage?: number
showAction?: boolean
// 是否所有的超出隐藏,优先级低于schema中的showOverflowTooltip,
showOverflowTooltip?: boolean
// 表头
columns?: TableColumn[]
// 是否展示分页
pagination?: Pagination | undefined
// 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key)
reserveSelection?: boolean
// 加载状态
loading?: boolean
// 是否叠加索引
reserveIndex?: boolean
// 对齐方式
align?: 'left' | 'center' | 'right'
// 表头对齐方式
headerAlign?: 'left' | 'center' | 'right'
preview?: string[]
sortable?: boolean
data?: Recordable
}
+3
View File
@@ -0,0 +1,3 @@
import TagsView from './src/TagsView.vue'
export { TagsView }
+585
View File
@@ -0,0 +1,585 @@
<script setup lang="ts">
import { onMounted, watch, computed, unref, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouterLinkProps } from 'vue-router'
import { usePermissionStore } from '@/store/modules/permission'
import { useTagsViewStore } from '@/store/modules/tagsView'
import { useAppStore } from '@/store/modules/app'
import { useI18n } from '@/hooks/web/useI18n'
import { filterAffixTags } from './helper'
import { ContextMenu, ContextMenuExpose } from '@/components/ContextMenu'
import { useDesign } from '@/hooks/web/useDesign'
import { useTemplateRefsList } from '@vueuse/core'
import { ElScrollbar } from 'element-plus'
import { useScrollTo } from '@/hooks/event/useScrollTo'
import { useTagsView } from '@/hooks/web/useTagsView'
import { cloneDeep } from 'lodash-es'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('tags-view')
const { t } = useI18n()
const { currentRoute, push } = useRouter()
const { closeAll, closeLeft, closeRight, closeOther, closeCurrent, refreshPage } = useTagsView()
const permissionStore = usePermissionStore()
const routers = computed(() => permissionStore.getRouters)
const tagsViewStore = useTagsViewStore()
const visitedViews = computed(() => tagsViewStore.getVisitedViews)
const affixTagArr = ref<RouteLocationNormalizedLoaded[]>([])
const selectedTag = computed(() => tagsViewStore.getSelectedTag)
const setSelectTag = tagsViewStore.setSelectedTag
const appStore = useAppStore()
const tagsViewIcon = computed(() => appStore.getTagsViewIcon)
const isDark = computed(() => appStore.getIsDark)
// 初始化tag
const initTags = () => {
affixTagArr.value = filterAffixTags(unref(routers))
for (const tag of unref(affixTagArr)) {
// Must have tag name
if (tag.name) {
tagsViewStore.addVisitedView(cloneDeep(tag))
}
}
}
// 新增tag
const addTags = () => {
const { name } = unref(currentRoute)
if (name) {
setSelectTag(unref(currentRoute))
tagsViewStore.addView(unref(currentRoute))
}
}
// 关闭选中的tag
const closeSelectedTag = (view: RouteLocationNormalizedLoaded) => {
closeCurrent(view, () => {
if (isActive(view)) {
toLastView()
}
})
}
// 去最后一个
const toLastView = () => {
const visitedViews = tagsViewStore.getVisitedViews
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
push(latestView)
} else {
if (
unref(currentRoute).path === permissionStore.getAddRouters[0].path ||
unref(currentRoute).path === permissionStore.getAddRouters[0].redirect
) {
addTags()
return
}
// You can set another route
push(permissionStore.getAddRouters[0].path)
}
}
// 关闭全部
const closeAllTags = () => {
closeAll(() => {
toLastView()
})
}
// 关闭其它
const closeOthersTags = () => {
closeOther()
}
// 重新加载
const refreshSelectedTag = async (view?: RouteLocationNormalizedLoaded) => {
refreshPage(view)
}
// 关闭左侧
const closeLeftTags = () => {
closeLeft()
}
// 关闭右侧
const closeRightTags = () => {
closeRight()
}
// 滚动到选中的tag
const moveToCurrentTag = async () => {
await nextTick()
for (const v of unref(visitedViews)) {
if (v.fullPath === unref(currentRoute).path) {
moveToTarget(v)
if (v.fullPath !== unref(currentRoute).fullPath) {
tagsViewStore.updateVisitedView(unref(currentRoute))
}
break
}
}
}
const tagLinksRefs = useTemplateRefsList<RouterLinkProps>()
const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
const wrap$ = unref(scrollbarRef)?.wrapRef
let firstTag: Nullable<RouterLinkProps> = null
let lastTag: Nullable<RouterLinkProps> = null
const tagList = unref(tagLinksRefs)
// find first tag and last tag
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if ((firstTag?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath) {
// 直接滚动到0的位置
const { start } = useScrollTo({
el: wrap$!,
position: 'scrollLeft',
to: 0,
duration: 500
})
start()
} else if ((lastTag?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath) {
// 滚动到最后的位置
const { start } = useScrollTo({
el: wrap$!,
position: 'scrollLeft',
to: wrap$!.scrollWidth - wrap$!.offsetWidth,
duration: 500
})
start()
} else {
// find preTag and nextTag
const currentIndex: number = tagList.findIndex(
(item) => (item?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath
)
const tgsRefs = document.getElementsByClassName(`${prefixCls}__item`)
const prevTag = tgsRefs[currentIndex - 1] as HTMLElement
const nextTag = tgsRefs[currentIndex + 1] as HTMLElement
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + 4
// the tag's offsetLeft before of prevTag
const beforePrevTagOffsetLeft = prevTag.offsetLeft - 4
if (afterNextTagOffsetLeft > unref(scrollLeftNumber) + wrap$!.offsetWidth) {
const { start } = useScrollTo({
el: wrap$!,
position: 'scrollLeft',
to: afterNextTagOffsetLeft - wrap$!.offsetWidth,
duration: 500
})
start()
} else if (beforePrevTagOffsetLeft < unref(scrollLeftNumber)) {
const { start } = useScrollTo({
el: wrap$!,
position: 'scrollLeft',
to: beforePrevTagOffsetLeft,
duration: 500
})
start()
}
}
}
// 是否是当前tag
const isActive = (route: RouteLocationNormalizedLoaded): boolean => {
return route.path === unref(currentRoute).path
}
// 所有右键菜单组件的元素
const itemRefs = useTemplateRefsList<ComponentRef<typeof ContextMenu & ContextMenuExpose>>()
// 右键菜单状态改变的时候
const visibleChange = (visible: boolean, tagItem: RouteLocationNormalizedLoaded) => {
if (visible) {
for (const v of unref(itemRefs)) {
const elDropdownMenuRef = v.elDropdownMenuRef
if (tagItem.fullPath !== v.tagItem.fullPath) {
elDropdownMenuRef?.handleClose()
setSelectTag(tagItem)
}
}
}
}
// elscroll 实例
const scrollbarRef = ref<ComponentRef<typeof ElScrollbar>>()
// 保存滚动位置
const scrollLeftNumber = ref(0)
const scroll = ({ scrollLeft }) => {
scrollLeftNumber.value = scrollLeft as number
}
// 移动到某个位置
const move = (to: number) => {
const wrap$ = unref(scrollbarRef)?.wrapRef
const { start } = useScrollTo({
el: wrap$!,
position: 'scrollLeft',
to: unref(scrollLeftNumber) + to,
duration: 500
})
start()
}
onMounted(() => {
initTags()
addTags()
})
watch(
() => currentRoute.value,
() => {
addTags()
moveToCurrentTag()
}
)
</script>
<template>
<div
:id="prefixCls"
:class="prefixCls"
class="flex w-full relative bg-[#fff] dark:bg-[var(--el-bg-color)]"
>
<span
:class="`${prefixCls}__tool ${prefixCls}__tool--first`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="move(-200)"
>
<Icon
icon="ep:d-arrow-left"
color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/>
</span>
<div class="overflow-hidden flex-1">
<ElScrollbar ref="scrollbarRef" class="h-full" @scroll="scroll">
<div class="flex h-full">
<ContextMenu
:ref="itemRefs.set"
:schema="[
{
icon: 'ant-design:sync-outlined',
label: t('common.reload'),
disabled: selectedTag?.fullPath !== item.fullPath,
command: () => {
refreshSelectedTag(item)
}
},
{
icon: 'ant-design:close-outlined',
label: t('common.closeTab'),
disabled: !!visitedViews?.length && selectedTag?.meta.affix,
command: () => {
closeSelectedTag(item)
}
},
{
divided: true,
icon: 'ant-design:vertical-right-outlined',
label: t('common.closeTheLeftTab'),
disabled:
!!visitedViews?.length &&
(item.fullPath === visitedViews[0].fullPath ||
selectedTag?.fullPath !== item.fullPath),
command: () => {
closeLeftTags()
}
},
{
icon: 'ant-design:vertical-left-outlined',
label: t('common.closeTheRightTab'),
disabled:
!!visitedViews?.length &&
(item.fullPath === visitedViews[visitedViews.length - 1].fullPath ||
selectedTag?.fullPath !== item.fullPath),
command: () => {
closeRightTags()
}
},
{
divided: true,
icon: 'ant-design:tag-outlined',
label: t('common.closeOther'),
disabled: selectedTag?.fullPath !== item.fullPath,
command: () => {
closeOthersTags()
}
},
{
icon: 'ant-design:line-outlined',
label: t('common.closeAll'),
command: () => {
closeAllTags()
}
}
]"
v-for="item in visitedViews"
:key="item.fullPath"
:tag-item="item"
:class="[
`${prefixCls}__item`,
item?.meta?.affix ? `${prefixCls}__item--affix` : '',
{
'is-active': isActive(item)
}
]"
@visible-change="visibleChange"
>
<div>
<router-link :ref="tagLinksRefs.set" :to="{ ...item }" custom v-slot="{ navigate }">
<div
@click="navigate"
class="h-full flex justify-center items-center whitespace-nowrap pl-15px"
>
<Icon
v-if="
item?.matched &&
item?.matched[1] &&
item?.matched[1]?.meta?.icon &&
tagsViewIcon
"
:icon="item?.matched[1]?.meta?.icon"
:size="12"
class="mr-5px"
/>
{{ t(item?.meta?.title as string) }}
<Icon
:class="`${prefixCls}__item--close`"
color="#333"
icon="ant-design:close-outlined"
:size="12"
@click.prevent.stop="closeSelectedTag(item)"
/>
</div>
</router-link>
</div>
</ContextMenu>
</div>
</ElScrollbar>
</div>
<span
:class="`${prefixCls}__tool`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="move(200)"
>
<Icon
icon="ep:d-arrow-right"
color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/>
</span>
<span
:class="`${prefixCls}__tool`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="refreshSelectedTag(selectedTag)"
>
<Icon
icon="ant-design:reload-outlined"
color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/>
</span>
<ContextMenu
trigger="click"
:schema="[
{
icon: 'ant-design:sync-outlined',
label: t('common.reload'),
command: () => {
refreshSelectedTag(selectedTag)
}
},
{
icon: 'ant-design:close-outlined',
label: t('common.closeTab'),
disabled: !!visitedViews?.length && selectedTag?.meta.affix,
command: () => {
closeSelectedTag(selectedTag!)
}
},
{
divided: true,
icon: 'ant-design:vertical-right-outlined',
label: t('common.closeTheLeftTab'),
disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath,
command: () => {
closeLeftTags()
}
},
{
icon: 'ant-design:vertical-left-outlined',
label: t('common.closeTheRightTab'),
disabled:
!!visitedViews?.length &&
selectedTag?.fullPath === visitedViews[visitedViews.length - 1].fullPath,
command: () => {
closeRightTags()
}
},
{
divided: true,
icon: 'ant-design:tag-outlined',
label: t('common.closeOther'),
command: () => {
closeOthersTags()
}
},
{
icon: 'ant-design:line-outlined',
label: t('common.closeAll'),
command: () => {
closeAllTags()
}
}
]"
>
<span
:class="`${prefixCls}__tool`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer block"
>
<Icon
icon="ant-design:setting-outlined"
color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/>
</span>
</ContextMenu>
</div>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-tags-view';
.@{prefix-cls} {
:deep(.@{elNamespace}-scrollbar__view) {
height: 100%;
}
&__tool {
position: relative;
&::before {
position: absolute;
top: 1px;
left: 0;
width: 100%;
height: calc(~'100% - 1px');
border-left: 1px solid var(--el-border-color);
content: '';
}
&--first {
&::before {
position: absolute;
top: 1px;
left: 0;
width: 100%;
height: calc(~'100% - 1px');
border-right: 1px solid var(--el-border-color);
border-left: none;
content: '';
}
}
}
&__item {
position: relative;
top: 2px;
height: calc(~'100% - 6px');
padding-right: 25px;
margin-left: 4px;
font-size: 12px;
cursor: pointer;
border: 1px solid #d9d9d9;
border-radius: 2px;
&--close {
position: absolute;
top: 50%;
right: 5px;
display: none;
transform: translate(0, -50%);
}
&:not(.@{prefix-cls}__item--affix):hover {
.@{prefix-cls}__item--close {
display: block;
}
}
}
&__item:not(.is-active) {
&:hover {
color: var(--el-color-primary);
}
}
&__item.is-active {
color: var(--el-color-white);
background-color: var(--el-color-primary);
border: 1px solid var(--el-color-primary);
.@{prefix-cls}__item--close {
:deep(svg) {
color: var(--el-color-white) !important;
}
}
}
}
.dark {
.@{prefix-cls} {
&__tool {
&--first {
&::after {
display: none;
}
}
}
&__item {
border: 1px solid var(--el-border-color);
}
&__item:not(.is-active) {
&:hover {
color: var(--el-color-primary);
}
}
&__item.is-active {
color: var(--el-color-white);
background-color: var(--el-color-primary);
border: 1px solid var(--el-color-primary);
.@{prefix-cls}__item--close {
:deep(svg) {
color: var(--el-color-white) !important;
}
}
}
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import type { RouteMeta, RouteLocationNormalizedLoaded } from 'vue-router'
import { pathResolve } from '@/utils/routerHelper'
export const filterAffixTags = (routes: AppRouteRecordRaw[], parentPath = '') => {
let tags: RouteLocationNormalizedLoaded[] = []
routes.forEach((route) => {
const meta = route.meta as RouteMeta
const tagPath = pathResolve(parentPath, route.path)
if (meta?.affix) {
tags.push({ ...route, path: tagPath, fullPath: tagPath } as RouteLocationNormalizedLoaded)
}
if (route.children) {
const tempTags: RouteLocationNormalizedLoaded[] = filterAffixTags(route.children, tagPath)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
}
+3
View File
@@ -0,0 +1,3 @@
import ThemeSwitch from './src/ThemeSwitch.vue'
export { ThemeSwitch }
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { ElSwitch } from 'element-plus'
import { useIcon } from '@/hooks/web/useIcon'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls } = useDesign()
const emit = defineEmits(['change'])
const prefixCls = getPrefixCls('theme-switch')
const Sun = useIcon({ icon: 'emojione-monotone:sun', color: '#fde047' })
const CrescentMoon = useIcon({ icon: 'emojione-monotone:crescent-moon', color: '#fde047' })
const appStore = useAppStore()
// 初始化获取是否是暗黑主题
const isDark = ref(appStore.getIsDark)
// 设置switch的背景颜色
const blackColor = 'var(--el-color-black)'
const themeChange = (val: boolean) => {
appStore.setIsDark(val)
emit('change', val)
}
</script>
<template>
<ElSwitch
:class="prefixCls"
v-model="isDark"
inline-prompt
:border-color="blackColor"
:inactive-color="blackColor"
:active-color="blackColor"
:active-icon="Sun"
:inactive-icon="CrescentMoon"
@change="themeChange"
/>
</template>
<style lang="less" scoped>
:deep(.el-switch__core .el-switch__inner .is-icon) {
overflow: visible;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
import UserInfo from './src/UserInfo.vue'
export { UserInfo }
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { useI18n } from '@/hooks/web/useI18n'
import { useDesign } from '@/hooks/web/useDesign'
import LockDialog from './components/LockDialog.vue'
import { ref, computed } from 'vue'
import LockPage from './components/LockPage.vue'
import { useLockStore } from '@/store/modules/lock'
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
const lockStore = useLockStore()
const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('user-info')
const { t } = useI18n()
const loginOut = () => {
userStore.logoutConfirm()
}
const dialogVisible = ref<boolean>(false)
// 锁定屏幕
const lockScreen = () => {
dialogVisible.value = true
}
</script>
<template>
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
<div class="flex items-center">
<img
src="@/assets/imgs/avatar.jpg"
alt=""
class="w-[calc(var(--logo-height)-25px)] rounded-[50%]"
/>
<span class="<lg:hidden text-14px pl-[5px] text-[var(--top-header-text-color)]">{{
userStore.getUserInfo?.username
}}</span>
</div>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
</ElDropdownItem>
<ElDropdownItem>
<div @click="loginOut">{{ t('common.loginOut') }}</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<LockDialog v-if="dialogVisible" v-model="dialogVisible" />
<teleport to="body">
<transition name="fade-bottom" mode="out-in">
<LockPage v-if="getIsLock" />
</transition>
</teleport>
</template>
<style scoped lang="less">
.fade-bottom-enter-active,
.fade-bottom-leave-active {
transition:
opacity 0.25s,
transform 0.3s;
}
.fade-bottom-enter-from {
opacity: 0;
transform: translateY(-10%);
}
.fade-bottom-leave-to {
opacity: 0;
transform: translateY(10%);
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useI18n } from '@/hooks/web/useI18n'
import { ref } from 'vue'
import { Dialog } from '@/components/Dialog'
import { Form } from '@/components/Form'
import { useForm } from '@/hooks/web/useForm'
import { reactive, computed } from 'vue'
import { useValidator } from '@/hooks/web/useValidator'
import { FormSchema } from '@/components/Form'
import { ElButton } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign'
import { useLockStore } from '@/store/modules/lock'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('lock-dialog')
const { required } = useValidator()
const { t } = useI18n()
const lockStore = useLockStore()
const props = defineProps({
modelValue: {
type: Boolean
}
})
const emit = defineEmits(['update:modelValue'])
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => {
console.log('set: ', val)
emit('update:modelValue', val)
}
})
const dialogTitle = ref(t('lock.lockScreen'))
const rules = reactive({
password: [required()]
})
const schema: FormSchema[] = reactive([
{
label: t('lock.lockPassword'),
field: 'password',
component: 'Input',
componentProps: {
type: 'password',
showPassword: true
}
}
])
const { formRegister, formMethods } = useForm()
const { getFormData, getElFormExpose } = formMethods
const handleLock = async () => {
const formExpose = await getElFormExpose()
formExpose?.validate(async (valid) => {
if (valid) {
dialogVisible.value = false
const formData = await getFormData()
lockStore.setLockInfo({
isLock: true,
...formData
})
}
})
}
</script>
<template>
<Dialog
v-model="dialogVisible"
width="500px"
max-height="170px"
:class="prefixCls"
:title="dialogTitle"
>
<div class="flex flex-col items-center">
<img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
<span class="text-14px my-10px text-[var(--top-header-text-color)]">Archer</span>
</div>
<Form :is-col="false" :schema="schema" :rules="rules" @register="formRegister" />
<template #footer>
<ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton>
</template>
</Dialog>
</template>
<style lang="less" scoped>
:global(.v-lock-dialog) {
@media (width <= 767px) {
max-width: calc(100vw - 16px);
}
}
</style>
@@ -0,0 +1,272 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { ElInput, ElButton } from 'element-plus'
import { resetRouter } from '@/router'
import { useRouter } from 'vue-router'
import { useStorage } from '@/hooks/web/useStorage'
import { useLockStore } from '@/store/modules/lock'
import { useI18n } from '@/hooks/web/useI18n'
import { useNow } from '@/hooks/web/useNow'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@/components/Icon'
import { loginOutApi } from '@/api/login'
import { useTagsViewStore } from '@/store/modules/tagsView'
const tagsViewStore = useTagsViewStore()
const { clear } = useStorage()
const { replace } = useRouter()
const password = ref('')
const loading = ref(false)
const errMsg = ref(false)
const showDate = ref(true)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('lock-page')
const lockStore = useLockStore()
const { hour, month, minute, meridiem, year, day, week } = useNow(true)
const { t } = useI18n()
// 解锁
async function unLock() {
if (!password.value) {
return
}
let pwd = password.value
try {
loading.value = true
const res = await lockStore.unLock(pwd)
errMsg.value = !res
} finally {
loading.value = false
}
}
// 返回登录
async function goLogin() {
const res = await loginOutApi().catch(() => {})
if (res) {
clear()
tagsViewStore.delAllViews()
resetRouter() // 重置静态路由表
lockStore.resetLockInfo()
replace('/login')
}
}
function handleShowForm(show = false) {
showDate.value = show
}
</script>
<template>
<div
:class="prefixCls"
class="fixed inset-0 flex h-screen w-screen bg-black items-center justify-center"
>
<div
:class="`${prefixCls}__unlock`"
class="absolute top-0 left-1/2 flex pt-5 h-16 items-center justify-center sm:text-md xl:text-xl text-white flex-col cursor-pointer transform translate-x-1/2"
@click="handleShowForm(false)"
v-show="showDate"
>
<Icon icon="ep:lock" />
<span>{{ t('lock.unlock') }}</span>
</div>
<div class="flex w-screen h-screen justify-center items-center">
<div :class="`${prefixCls}__hour`" class="relative mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5">
<span>{{ hour }}</span>
<span class="meridiem absolute left-5 top-5 text-md xl:text-xl" v-show="showDate">
{{ meridiem }}
</span>
</div>
<div :class="`${prefixCls}__minute w-2/5 h-2/5 md:h-4/5 `">
<span> {{ minute }}</span>
</div>
</div>
<transition name="fade-slide">
<div :class="`${prefixCls}-entry`" v-show="!showDate">
<div :class="`${prefixCls}-entry-content`">
<div class="flex flex-col items-center">
<img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
<span class="text-14px my-10px text-[var(--logo-title-text-color)]">Archer</span>
</div>
<ElInput
type="password"
:placeholder="t('lock.placeholder')"
class="enter-x"
v-model="password"
/>
<span :class="`text-14px ${prefixCls}-entry__err-msg enter-x`" v-if="errMsg">
{{ t('lock.message') }}
</span>
<div :class="`${prefixCls}-entry__footer enter-x`">
<ElButton
type="primary"
size="small"
class="mt-2 mr-2 enter-x"
link
:disabled="loading"
@click="handleShowForm(true)"
>
{{ t('common.back') }}
</ElButton>
<ElButton
type="primary"
size="small"
class="mt-2 mr-2 enter-x"
link
:disabled="loading"
@click="goLogin"
>
{{ t('lock.backToLogin') }}
</ElButton>
<ElButton
type="primary"
class="mt-2"
size="small"
link
@click="unLock()"
:disabled="loading"
>
{{ t('lock.entrySystem') }}
</ElButton>
</div>
</div>
</div>
</transition>
<div class="absolute bottom-5 w-full text-gray-300 xl:text-xl 2xl:text-3xl text-center enter-y">
<div class="text-5xl mb-4 enter-x" v-show="!showDate">
{{ hour }}:{{ minute }} <span class="text-3xl">{{ meridiem }}</span>
</div>
<div class="text-2xl">{{ year }}/{{ month }}/{{ day }} {{ week }}</div>
</div>
</div>
</template>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-lock-page';
// Small screen / tablet
@screen-sm: 576px;
// Medium screen / desktop
@screen-md: 768px;
// Large screen / wide desktop
@screen-lg: 992px;
// Extra large screen / full hd
@screen-xl: 1200px;
// Extra extra large screen / large desktop
@screen-2xl: 1600px;
@error-color: #ed6f6f;
.@{prefix-cls} {
z-index: 3000;
&__unlock {
transform: translate(-50%, 0);
}
&__hour,
&__minute {
display: flex;
font-weight: 700;
color: #bababa;
background-color: #141313;
border-radius: 30px;
justify-content: center;
align-items: center;
@media screen and (max-width: @screen-md) {
span:not(.meridiem) {
font-size: 160px;
}
}
@media screen and (min-width: @screen-md) {
span:not(.meridiem) {
font-size: 160px;
}
}
@media screen and (max-width: @screen-sm) {
span:not(.meridiem) {
font-size: 90px;
}
}
@media screen and (min-width: @screen-lg) {
span:not(.meridiem) {
font-size: 220px;
}
}
@media screen and (min-width: @screen-xl) {
span:not(.meridiem) {
font-size: 260px;
}
}
@media screen and (min-width: @screen-2xl) {
span:not(.meridiem) {
font-size: 320px;
}
}
}
&-entry {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(8px);
justify-content: center;
align-items: center;
&-content {
width: 260px;
}
&__header {
text-align: center;
&-img {
width: 70px;
margin: 0 auto;
border-radius: 50%;
}
&-name {
margin-top: 5px;
font-weight: 500;
color: #bababa;
}
}
&__err-msg {
display: inline-block;
margin-top: 10px;
color: @error-color;
}
&__footer {
display: flex;
justify-content: space-between;
}
}
}
</style>
+27
View File
@@ -0,0 +1,27 @@
import { VNode, createVNode, render } from 'vue'
import VideoPlayer from './src/VideoPlayer.vue'
import { isClient } from '@/utils/is'
import { VideoPlayerViewer } from '@/components/VideoPlayerViewer'
import { toAnyString } from '@/utils'
export { VideoPlayer }
let instance: Nullable<VNode> = null
export function createVideoViewer(options: { url: string; poster?: string; show?: boolean }) {
if (!isClient) return
const { url, poster } = options
const propsData: Partial<{ url: string; poster?: string; show?: boolean; id?: string }> = {}
const container = document.createElement('div')
const id = toAnyString()
container.id = id
propsData.url = url
propsData.poster = poster
propsData.show = true
propsData.id = id
document.body.appendChild(container)
instance = createVNode(VideoPlayerViewer, propsData)
render(instance, container)
}
@@ -0,0 +1,59 @@
<script setup lang="ts">
import Player from 'xgplayer'
import { ref, unref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
import 'xgplayer/dist/index.min.css'
const props = defineProps({
url: {
type: String,
default: '',
required: true
},
poster: {
type: String,
default: ''
}
})
const playerRef = ref<Player>()
const videoEl = ref<HTMLDivElement>()
const intiPlayer = () => {
if (!unref(videoEl)) return
new Player({
autoplay: false,
...props,
el: unref(videoEl)
})
}
onMounted(() => {
intiPlayer()
})
watch(
() => props,
async (newProps) => {
await nextTick()
if (newProps) {
unref(playerRef)?.setConfig(newProps)
}
},
{
deep: true
}
)
onBeforeUnmount(() => {
unref(playerRef)?.destroy()
})
defineExpose({
playerExpose: () => unref(playerRef)
})
</script>
<template>
<div ref="videoEl"></div>
</template>
@@ -0,0 +1,3 @@
import VideoPlayerViewer from './src/VideoPlayerViewer.vue'
export { VideoPlayerViewer }
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { VideoPlayer } from '@/components/VideoPlayer'
import { ElOverlay } from 'element-plus'
import { ref, nextTick } from 'vue'
import { Icon } from '@/components/Icon'
const props = defineProps({
show: {
type: Boolean,
default: false
},
url: {
type: String,
default: '',
required: true
},
poster: {
type: String,
default: ''
},
id: {
type: String,
default: ''
}
})
const visible = ref(props.show)
const close = async () => {
visible.value = false
await nextTick()
const wrap = document.getElementById(props.id)
if (!wrap) return
document.body.removeChild(wrap)
}
</script>
<template>
<ElOverlay v-show="visible" @click="close">
<div class="w-full h-full flex justify-center items-center relative" @click="close">
<div
class="w-44px h-44px color-[#fff] bg-[var(--el-text-color-regular)] rounded-full border-[#fff] flex justify-center items-center cursor-pointer absolute top-40px right-40px"
@click="close"
>
<Icon icon="ep:close" :size="24" />
</div>
<VideoPlayer :url="url" :poster="poster" />
</div>
</ElOverlay>
</template>
+8
View File
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import { Icon } from './Icon'
import { BaseButton } from './Button'
export const setupGlobCom = (app: App<Element>): void => {
app.component('Icon', Icon)
app.component('BaseButton', BaseButton)
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 请求成功状态码
*/
export const SUCCESS_CODE = 0
/**
* 请求contentType
*/
export const CONTENT_TYPE: AxiosContentType = 'application/json'
/**
* 请求超时时间
*/
export const REQUEST_TIMEOUT = 60000
/**
* 不重定向白名单
*/
export const NO_REDIRECT_WHITE_LIST = ['/login']
/**
* 不重置路由白名单
*/
export const NO_RESET_WHITE_LIST = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root']
/**
* 表格默认过滤列设置字段
*/
export const DEFAULT_FILTER_COLUMN = ['expand', 'selection']
/**
* 是否根据headers->content-type自动转换数据格式
*/
export const TRANSFORM_REQUEST_DATA = true
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more