@peng_kai/kit 0.3.0-beta.4 → 0.3.0-beta.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/settings.json +2 -2
- package/admin/components/currency/src/CurrencyIcon.vue +37 -33
- package/admin/components/date/PeriodPicker.vue +122 -0
- package/admin/components/date/TimeFieldSelectForLabel.vue +24 -0
- package/admin/components/date/TtaTimeZone.vue +516 -0
- package/admin/components/date/TtaTimeZoneSimple.vue +104 -0
- package/admin/components/date/helpers.ts +250 -0
- package/admin/components/date/index.ts +6 -0
- package/admin/components/date/presetProps.ts +19 -0
- package/admin/components/filter/src/FilterReset.vue +55 -8
- package/admin/components/filter/src/more/TableSetting.vue +95 -0
- package/admin/components/filter/src/useFilterParams.ts +9 -7
- package/admin/components/provider/Admin.vue +17 -0
- package/admin/components/provider/admin-permission.ts +48 -0
- package/admin/components/provider/admin-router.ts +361 -0
- package/admin/components/provider/index.ts +3 -0
- package/admin/components/rich-text/src/RichText.new.vue +19 -6
- package/admin/components/rich-text/src/editorConfig.ts +76 -1
- package/admin/components/settings/index.ts +1 -1
- package/admin/components/settings/src/SchemaForm.vue +40 -6
- package/admin/components/settings/src/Settings.vue +1 -1
- package/admin/components/text/index.ts +2 -0
- package/admin/components/text/src/Amount.v2.vue +131 -0
- package/admin/components/text/src/Datetime.vue +17 -12
- package/admin/components/text/src/IP.vue +18 -4
- package/admin/components/text/src/Num.vue +192 -0
- package/admin/components/upload/src/PictureCardUpload.vue +56 -20
- package/admin/layout/large/Breadcrumb.vue +10 -23
- package/admin/layout/large/Content.vue +9 -6
- package/admin/layout/large/Layout.vue +129 -0
- package/admin/layout/large/Menu.vue +24 -17
- package/admin/layout/large/Notice.vue +168 -0
- package/admin/layout/large/Tabs.vue +183 -0
- package/admin/layout/large/index.ts +61 -1
- package/admin/layout/large/y682.mp3 +0 -0
- package/admin/permission/routerGuard.ts +24 -11
- package/admin/permission/vuePlugin.ts +5 -10
- package/admin/route-guards/index.ts +0 -1
- package/admin/stores/index.ts +1 -0
- package/admin/styles/classCover.scss +1 -1
- package/admin/styles/index.scss +2 -2
- package/antd/hooks/useAntdModal.ts +27 -12
- package/antd/hooks/useAntdTable.ts +10 -7
- package/antd/hooks/useAntdTheme.ts +7 -0
- package/antd/hooks/useTableColumns.ts +83 -0
- package/antd/index.ts +1 -1
- package/libs/bignumber.ts +1 -1
- package/libs/dayjs.ts +16 -2
- package/libs/fingerprintjs.ts +1 -0
- package/package.json +64 -95
- package/request/interceptors/getDeviceInfo.ts +14 -0
- package/utils/LocaleManager.ts +1 -1
- package/utils/index.ts +1 -4
- package/utils/locale/LocaleManager.ts +2 -1
- package/utils/locale/helpers.ts +9 -0
- package/utils/number.ts +8 -10
- package/utils/storage.ts +31 -0
- package/utils/string.ts +1 -2
- package/utils/upload/AwsS3.ts +12 -4
- package/admin/layout/large/PageTab.vue +0 -70
- package/admin/route-guards/collapseMenu.ts +0 -11
- package/libs/a-calc.ts +0 -1
- package/vue/components/test/KitTest.vue +0 -9
- package/vue/components/test/testStore.ts +0 -11
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import { useScroll, useMutationObserver } from '@vueuse/core'
|
|
4
|
+
|
|
5
|
+
const hiddenSiderbar = ref(false);
|
|
6
|
+
const $content = ref<HTMLElement>();
|
|
7
|
+
const { arrivedState, measure } = useScroll($content);
|
|
8
|
+
const contentCssVars = computed(() => {
|
|
9
|
+
const {top: arrivedTop, bottom: arrivedBtm} = arrivedState;
|
|
10
|
+
return {
|
|
11
|
+
'--top-e': arrivedTop ? '0%' : '5px',
|
|
12
|
+
'--btm-s': arrivedBtm ? '100%' : '99.5%',
|
|
13
|
+
// 'margin-top': arrivedTop ? '0' : '-5px',
|
|
14
|
+
};
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
useMutationObserver($content, () => {
|
|
18
|
+
setTimeout(measure, 100)
|
|
19
|
+
}, { childList: true });
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="app-layout" :class="{ 'hidden-siderbar': hiddenSiderbar }">
|
|
24
|
+
<div class="app-logo select-none [grid-area:logo] ">
|
|
25
|
+
<slot name="logo" />
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="app-header select-none [grid-area:header]">
|
|
29
|
+
<!-- <div
|
|
30
|
+
class="text-6 flex items-center h-7 -mr-5 cursor-pointer"
|
|
31
|
+
@click="hiddenSiderbar = !hiddenSiderbar"
|
|
32
|
+
>
|
|
33
|
+
<i class="i-material-symbols:menu-open-rounded" :class="{ 'rotate-180': hiddenSiderbar }" />
|
|
34
|
+
</div> -->
|
|
35
|
+
<slot name="header" />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div v-show="!hiddenSiderbar" class="app-siderbar select-none [grid-area:siderbar]">
|
|
39
|
+
<slot name="siderbar" />
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div ref="$content" class="app-content [grid-area:content]" :style="contentCssVars">
|
|
43
|
+
<slot name="content" />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<style lang="scss" scoped>
|
|
49
|
+
.app-layout {
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template:
|
|
53
|
+
"logo header" 56px
|
|
54
|
+
"siderbar content" 1fr
|
|
55
|
+
/ 180px 1fr;
|
|
56
|
+
column-gap: 8px;
|
|
57
|
+
width: 100vw;
|
|
58
|
+
height: 100vh;
|
|
59
|
+
padding: 0 8px 8px;
|
|
60
|
+
background-color: var(--antd-colorBgLayout);
|
|
61
|
+
|
|
62
|
+
&.hidden-siderbar {
|
|
63
|
+
grid-template:
|
|
64
|
+
"logo header" 56px
|
|
65
|
+
"content content" 1fr
|
|
66
|
+
/ 130px 1fr;
|
|
67
|
+
|
|
68
|
+
.app-logo {
|
|
69
|
+
justify-content: start;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.app-logo {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
font-size: 2rem;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
line-height: 1;
|
|
81
|
+
color: var(--antd-colorPrimary)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.app-siderbar {
|
|
85
|
+
position: relative;
|
|
86
|
+
display: flex;
|
|
87
|
+
min-height: 100%;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
margin-right: -6px;
|
|
90
|
+
overflow-y: auto;
|
|
91
|
+
scrollbar-gutter: stable;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// @property --top-e {
|
|
95
|
+
// syntax: "<percentage>";
|
|
96
|
+
// initial-value: 0%;
|
|
97
|
+
// inherits: false;
|
|
98
|
+
// }
|
|
99
|
+
// @property --btm-s {
|
|
100
|
+
// syntax: "<percentage>";
|
|
101
|
+
// initial-value: 100%;
|
|
102
|
+
// inherits: false;
|
|
103
|
+
// }
|
|
104
|
+
|
|
105
|
+
.app-content {
|
|
106
|
+
--top-s: 0%;
|
|
107
|
+
--top-e: 0%;
|
|
108
|
+
--btm-s: 99%;
|
|
109
|
+
--btm-e: 100%;
|
|
110
|
+
|
|
111
|
+
margin-right: -6px;
|
|
112
|
+
overflow-y: auto;
|
|
113
|
+
scrollbar-gutter: stable;
|
|
114
|
+
mask-image: linear-gradient(
|
|
115
|
+
to bottom,
|
|
116
|
+
#0000 var(--top-s),
|
|
117
|
+
#000 var(--top-e),
|
|
118
|
+
#000 var(--btm-s),
|
|
119
|
+
#0000 var(--btm-e)
|
|
120
|
+
);
|
|
121
|
+
transition: --top-e 0.05s, --btm-s 0.05s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.app-header {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 36px;
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { computed, ref, watch } from 'vue';
|
|
3
3
|
import { Menu as AMenu } from 'ant-design-vue';
|
|
4
4
|
import type { ItemType } from 'ant-design-vue';
|
|
5
|
-
import {
|
|
6
|
-
import type { TMenu } from '../../stores/createUseMenuStore';
|
|
5
|
+
import type { TMenu } from '../../components/provider/admin-router';
|
|
7
6
|
|
|
8
7
|
function formatMenu(menu: TMenu): ItemType {
|
|
9
8
|
return {
|
|
@@ -21,24 +20,19 @@ function formatMenu(menu: TMenu): ItemType {
|
|
|
21
20
|
</script>
|
|
22
21
|
|
|
23
22
|
<script setup lang="ts">
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
path: TMenu[]
|
|
25
|
+
menus: TMenu[]
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const items = computed(() => props.menus.map(formatMenu));
|
|
27
29
|
const openKeys = ref<string[]>([]);
|
|
28
30
|
const selectedKeys = ref<string[]>([]);
|
|
29
31
|
|
|
30
|
-
watch(
|
|
31
|
-
(
|
|
32
|
-
(
|
|
33
|
-
|
|
34
|
-
return;
|
|
35
|
-
|
|
36
|
-
const menuPath = menuStore.getMenuPath(route.name as string);
|
|
37
|
-
openKeys.value = menuPath.map(menu => menu.key);
|
|
38
|
-
selectedKeys.value = [openKeys.value.pop()!];
|
|
39
|
-
},
|
|
40
|
-
{ immediate: true },
|
|
41
|
-
);
|
|
32
|
+
watch(() => props.path, (menuPath) => {
|
|
33
|
+
openKeys.value = menuPath.map(menu => menu.key);
|
|
34
|
+
selectedKeys.value = [openKeys.value.pop()!];
|
|
35
|
+
}, { immediate: true });
|
|
42
36
|
</script>
|
|
43
37
|
|
|
44
38
|
<template>
|
|
@@ -64,5 +58,18 @@ watch(
|
|
|
64
58
|
.ant-menu {
|
|
65
59
|
background-color: transparent;
|
|
66
60
|
}
|
|
61
|
+
|
|
62
|
+
:deep(.ant-menu-submenu-arrow){
|
|
63
|
+
top: 52%;
|
|
64
|
+
inset-inline-end: 8px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
:deep(.ant-menu-item-only-child) {
|
|
68
|
+
padding-left: 43px !important;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
:deep(.ant-menu-sub.ant-menu-inline) {
|
|
72
|
+
background-color: transparent;
|
|
73
|
+
}
|
|
67
74
|
}
|
|
68
75
|
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useEventListener, useIntervalFn, useLocalStorage } from '@vueuse/core';
|
|
3
|
+
import { Empty, Dropdown, Badge, Menu, MenuItem } from 'ant-design-vue';
|
|
4
|
+
import { type UseQueryReturnType } from '@tanstack/vue-query'
|
|
5
|
+
import { ref, computed, customRef, triggerRef } from 'vue'
|
|
6
|
+
import dayjs from '../../../libs/dayjs';
|
|
7
|
+
import { useRouter } from 'vue-router';
|
|
8
|
+
import audioURL from './y682.mp3?url';
|
|
9
|
+
|
|
10
|
+
function useAudioMuted(audio: HTMLAudioElement, cacheKey: string) {
|
|
11
|
+
const audioMutedCache = useLocalStorage(cacheKey, false);
|
|
12
|
+
const audioMuted = customRef<boolean>((track, tracker) => ({
|
|
13
|
+
get: () => {
|
|
14
|
+
track();
|
|
15
|
+
return audio.muted;
|
|
16
|
+
},
|
|
17
|
+
set: (val) => {
|
|
18
|
+
audio.muted = audioMutedCache.value = val;
|
|
19
|
+
tracker();
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
useEventListener(document, 'click', () => {
|
|
24
|
+
audio.muted = audioMutedCache.value;
|
|
25
|
+
triggerRef(audioMuted);
|
|
26
|
+
}, {once: true});
|
|
27
|
+
|
|
28
|
+
return audioMuted;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const noticeAudio = new Audio(audioURL);
|
|
32
|
+
noticeAudio.muted = true;
|
|
33
|
+
noticeAudio.src = audioURL;
|
|
34
|
+
document.body.append(noticeAudio);
|
|
35
|
+
export const audioMuted = useAudioMuted(noticeAudio, 'notice_audio_muted');
|
|
36
|
+
|
|
37
|
+
const faviconEle = document.querySelector('link[rel="icon"]');
|
|
38
|
+
const logoFaviconHref = document.querySelector('link[rel="icon"]')?.getAttribute('href')!;
|
|
39
|
+
const numCanvas = document.createElement('canvas') as (HTMLCanvasElement & { num: number, dataURL: string });
|
|
40
|
+
numCanvas.width = 32;
|
|
41
|
+
numCanvas.height = 32;
|
|
42
|
+
|
|
43
|
+
interface TNotice {
|
|
44
|
+
title: string;
|
|
45
|
+
type: number;
|
|
46
|
+
url: string;
|
|
47
|
+
time: number;
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<script lang="ts" setup>
|
|
52
|
+
const props = defineProps<{
|
|
53
|
+
noticesQry: UseQueryReturnType<{num: number; list: TNotice[]}, Error>
|
|
54
|
+
onOpen?: (item: TNotice) => void;
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const router = useRouter();
|
|
58
|
+
const visible = ref(false);
|
|
59
|
+
const noticeNum = computed(() => props.noticesQry.data.value?.num ?? 0);
|
|
60
|
+
const noticeList = computed(() => props.noticesQry.data.value?.list ?? []);
|
|
61
|
+
|
|
62
|
+
function operate(notice: TNotice) {
|
|
63
|
+
if (props.onOpen) {
|
|
64
|
+
props.onOpen(notice);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (router.currentRoute.value.path !== notice.url) {
|
|
69
|
+
router.push(notice.url);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
useIntervalFn(() => {
|
|
74
|
+
const num = noticeNum.value;
|
|
75
|
+
|
|
76
|
+
if (num) {
|
|
77
|
+
noticeAudio.paused && noticeAudio.play();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!num || !faviconEle || document.visibilityState === 'visible') {
|
|
81
|
+
faviconEle?.setAttribute('href', logoFaviconHref);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isNumIcon = faviconEle.getAttribute('href')?.startsWith('data:image/png');
|
|
86
|
+
|
|
87
|
+
if (isNumIcon) {
|
|
88
|
+
faviconEle.setAttribute('href', logoFaviconHref);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
if (numCanvas.num !== num) {
|
|
92
|
+
numCanvas.num = num;
|
|
93
|
+
const ctx = numCanvas.getContext('2d')!;
|
|
94
|
+
ctx.fillStyle = '#f00';
|
|
95
|
+
ctx.beginPath();
|
|
96
|
+
ctx.arc(16, 16, 16, 0, Math.PI * 2, true);
|
|
97
|
+
ctx.fill();
|
|
98
|
+
ctx.fillStyle = '#fff';
|
|
99
|
+
ctx.font = 'bold 22px sans-serif';
|
|
100
|
+
ctx.textAlign = 'center';
|
|
101
|
+
ctx.textBaseline = 'middle';
|
|
102
|
+
ctx.fillText(num.toString(), 16, 16);
|
|
103
|
+
numCanvas.dataURL = numCanvas.toDataURL();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
faviconEle.setAttribute('href', numCanvas.dataURL);
|
|
107
|
+
}
|
|
108
|
+
}, 500);
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<Badge :count="noticeNum" size="small">
|
|
113
|
+
<Dropdown v-model:open="visible" placement="bottomRight" trigger="click" arrow>
|
|
114
|
+
<div class="h-7 flex cursor-pointer items-center justify-center rounded-1 px-1">
|
|
115
|
+
<i class="i-ri:notification-4-line block text-5" :class="{ shake: !!noticeNum }" />
|
|
116
|
+
</div>
|
|
117
|
+
<template #overlay>
|
|
118
|
+
<Menu v-if="noticeNum" @click="visible = false" class="max-h-100 overflow-y-auto">
|
|
119
|
+
<MenuItem v-for="(item, i) of noticeList" :key="i" @click="operate(item)">
|
|
120
|
+
<div class="min-w-50">
|
|
121
|
+
<div>{{ item.title }}</div>
|
|
122
|
+
<div class="mt-1 text-xs text-$antd-colorTextSecondary">
|
|
123
|
+
{{ dayjs(item.time).format('MM-DD HH:mm:ss') }}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</MenuItem>
|
|
127
|
+
</Menu>
|
|
128
|
+
|
|
129
|
+
<div v-else class="h-40 min-w-50 flex items-center justify-center rounded-2 bg-$antd-colorBgElevated shadow"
|
|
130
|
+
@click="visible = false">
|
|
131
|
+
<Empty description="暂无通知" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
</Dropdown>
|
|
135
|
+
</Badge>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<style lang="scss" scoped>
|
|
139
|
+
@keyframes shake {
|
|
140
|
+
0% {
|
|
141
|
+
transform: rotate(0deg);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
25% {
|
|
145
|
+
transform: rotate(10deg);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
50% {
|
|
149
|
+
transform: rotate(-10deg);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
75% {
|
|
153
|
+
transform: rotate(10deg);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
100% {
|
|
157
|
+
transform: rotate(-10deg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.shake {
|
|
162
|
+
animation: shake 0.5s infinite;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.shadow {
|
|
166
|
+
box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%);
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
import { autoResetRef, useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core';
|
|
4
|
+
import type { TTab } from '../../components/provider/admin-router';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
current: TTab
|
|
8
|
+
tabs: TTab[]
|
|
9
|
+
}>();
|
|
10
|
+
const emits = defineEmits<{
|
|
11
|
+
close: [TTab]
|
|
12
|
+
open: [TTab]
|
|
13
|
+
refresh: [TTab]
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const $tabList = ref<HTMLElement>();
|
|
17
|
+
const refreshAnim = autoResetRef(false, 300);
|
|
18
|
+
const isLeftmost = ref(false);
|
|
19
|
+
const isRightmost = ref(false);
|
|
20
|
+
|
|
21
|
+
useEventListener($tabList, 'wheel', (e) => {
|
|
22
|
+
if ($tabList.value) {
|
|
23
|
+
const deltaY = e.deltaY < 0 ? -100 : 100;
|
|
24
|
+
$tabList.value.scrollLeft += deltaY;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function isScrollEnd() {
|
|
29
|
+
if ($tabList.value) {
|
|
30
|
+
const { scrollLeft, scrollWidth, clientWidth } = $tabList.value;
|
|
31
|
+
isRightmost.value = scrollLeft === 0;
|
|
32
|
+
isLeftmost.value = Math.ceil(Math.abs(scrollLeft) + clientWidth) >= scrollWidth;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function open(tab: TTab) {
|
|
37
|
+
if (props.current.path !== tab.path) {
|
|
38
|
+
emits('open', tab);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function refresh(tab: TTab) {
|
|
43
|
+
if (!refreshAnim.value) {
|
|
44
|
+
refreshAnim.value = true;
|
|
45
|
+
emits('refresh', tab);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEventListener($tabList, 'scroll', isScrollEnd);
|
|
50
|
+
useResizeObserver($tabList, isScrollEnd);
|
|
51
|
+
useMutationObserver($tabList, isScrollEnd, { childList: true });
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="flex items-center">
|
|
56
|
+
<div ref="$tabList" class="tab-list relative ml-auto" :class="{ 'is-leftmost': isLeftmost, 'is-rightmost': isRightmost }">
|
|
57
|
+
<div
|
|
58
|
+
v-for="tab of props.tabs" :key="tab.path"
|
|
59
|
+
class="tab" :class="{ opened: tab.path === props.current?.path }"
|
|
60
|
+
@click="open(tab)"
|
|
61
|
+
>
|
|
62
|
+
<span class="title">{{ tab.title }}</span>
|
|
63
|
+
<span v-if="tab.type !== 2 && props.tabs.length > 1" class="close-btn" @click.stop="emits('close', tab)">
|
|
64
|
+
<i class="i-material-symbols:close-rounded" />
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div v-memo="[refreshAnim]" class="operation-btn ml-2" @click="refresh(props.current)">
|
|
69
|
+
<i class="i-material-symbols:refresh-rounded" :class="{ 'refresh-spin': refreshAnim }" />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<style lang="scss" scoped>
|
|
75
|
+
.tab-list {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 4px;
|
|
78
|
+
align-items: center;
|
|
79
|
+
overflow-x: auto;
|
|
80
|
+
scrollbar-width: none;
|
|
81
|
+
flex: 1;
|
|
82
|
+
mask-image: linear-gradient(to left, #0000 0%, #000 5%, #000 95%, #0000 100%);
|
|
83
|
+
direction: rtl;
|
|
84
|
+
|
|
85
|
+
&.is-leftmost:not(.is-rightmost) {
|
|
86
|
+
mask-image: linear-gradient(to right, #000 0%, #000 5%, #000 95%, #0000 100%);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
&.is-rightmost:not(.is-leftmost) {
|
|
90
|
+
mask-image: linear-gradient(to left, #000 0%, #000 5%, #000 95%, #0000 100%);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
&.is-leftmost.is-rightmost {
|
|
94
|
+
mask-image: none;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.tab {
|
|
99
|
+
position: relative;
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
padding: 0 8px;
|
|
103
|
+
height: 28px;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
border-radius: 4px;
|
|
107
|
+
transition: all 0.2s;
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
line-height: 1em;
|
|
110
|
+
color: var(--antd-colorTextTertiary);
|
|
111
|
+
|
|
112
|
+
&:not(.opened):hover {
|
|
113
|
+
color: var(--antd-colorText);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&.opened {
|
|
117
|
+
color: var(--antd-colorPrimary);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.title {
|
|
121
|
+
transition: transform 0.1s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
&:has(.close-btn):hover .title {
|
|
125
|
+
transform: translateX(-4px);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.close-btn {
|
|
129
|
+
position: absolute;
|
|
130
|
+
right: -6px;
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
width: 1em;
|
|
134
|
+
height: 1em;
|
|
135
|
+
padding: 2px;
|
|
136
|
+
opacity: 0;
|
|
137
|
+
margin-bottom: -1px;
|
|
138
|
+
transition: all 0.2s;
|
|
139
|
+
transform: scale(0);
|
|
140
|
+
color: var(--antd-colorTextTertiary);
|
|
141
|
+
|
|
142
|
+
&:hover {
|
|
143
|
+
color: var(--antd-colorText);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
&:hover .close-btn {
|
|
148
|
+
margin-left: 0;
|
|
149
|
+
opacity: 1;
|
|
150
|
+
transform: scale(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.operation-btn {
|
|
155
|
+
flex: none;
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
justify-content: center;
|
|
159
|
+
height: 28px;
|
|
160
|
+
font-size: 18px;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
|
|
164
|
+
&:hover {
|
|
165
|
+
color: var(--antd-colorPrimaryText);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.refresh-spin {
|
|
170
|
+
& {
|
|
171
|
+
animation: spin 200ms linear;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@keyframes spin {
|
|
175
|
+
0% {
|
|
176
|
+
transform: rotate(0deg);
|
|
177
|
+
}
|
|
178
|
+
100% {
|
|
179
|
+
transform: rotate(360deg);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
export { default as Breadcrumb } from './Breadcrumb.vue';
|
|
2
2
|
export { default as Content } from './Content.vue';
|
|
3
3
|
export { default as Menu } from './Menu.vue';
|
|
4
|
-
export { default as
|
|
4
|
+
export { default as Tabs } from './Tabs.vue';
|
|
5
|
+
export { default as Layout } from './Layout.vue';
|
|
6
|
+
export { default as Notice } from './Notice.vue';
|
|
7
|
+
|
|
8
|
+
/* #B 优化表格水平滚动(测试版方案) */
|
|
9
|
+
const antTableHeaders = new WeakSet<HTMLElement>();
|
|
10
|
+
setInterval(() => {
|
|
11
|
+
const $header = document.querySelector('.antd-cover__table-sticky-pagination .ant-table-header') as HTMLElement;
|
|
12
|
+
|
|
13
|
+
if (!$header || antTableHeaders.has($header))
|
|
14
|
+
return;
|
|
15
|
+
|
|
16
|
+
antTableHeaders.add($header);
|
|
17
|
+
$header.addEventListener('wheel', (ev) => {
|
|
18
|
+
ev.preventDefault();
|
|
19
|
+
const $body = (ev.currentTarget as HTMLElement).parentElement?.querySelector(':scope > .ant-table-body');
|
|
20
|
+
|
|
21
|
+
if ($body) {
|
|
22
|
+
const left = $body.scrollLeft + (ev.deltaY < 0 ? -100 : 100);
|
|
23
|
+
$body.scroll({ left });
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const tipsKey = 'HIDDEN_TABLE_HORIZONTAL_SCROLLING_PROMPT';
|
|
28
|
+
const hiddenTips = localStorage.getItem(tipsKey);
|
|
29
|
+
|
|
30
|
+
if (!hiddenTips) {
|
|
31
|
+
const tips = `将鼠标指针移至表头,通过滚动鼠标滚轮来水平滑动表格(点击可关闭此提示)`;
|
|
32
|
+
const ctn = document.createElement('div');
|
|
33
|
+
ctn.className = `pos-sticky left-0 top-0 w-full h-full bg-$antd-colorPrimary text-center lh-relaxed text-white`;
|
|
34
|
+
ctn.innerHTML = tips;
|
|
35
|
+
ctn.onclick = () => {
|
|
36
|
+
ctn.remove();
|
|
37
|
+
localStorage.setItem(tipsKey, '1');
|
|
38
|
+
};
|
|
39
|
+
$header.appendChild(ctn);
|
|
40
|
+
}
|
|
41
|
+
}, 1000);
|
|
42
|
+
/* #E */
|
|
43
|
+
|
|
44
|
+
/* #B 高亮最后一次操作的表格行 */
|
|
45
|
+
window.addEventListener('click', (ev) => {
|
|
46
|
+
const targetEle = ev.target as HTMLElement;
|
|
47
|
+
const closestRow = targetEle.closest('.ant-table-row');
|
|
48
|
+
|
|
49
|
+
if (closestRow) {
|
|
50
|
+
document.querySelectorAll('.last-clicked-cell').forEach((cell) => {
|
|
51
|
+
cell.classList.remove('ant-table-cell-row-hover');
|
|
52
|
+
cell.classList.remove('last-clicked-cell');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const cells = closestRow.querySelectorAll(':scope > .ant-table-cell');
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
cells.forEach((cell) => {
|
|
58
|
+
cell.classList.add('ant-table-cell-row-hover');
|
|
59
|
+
cell.classList.add('last-clicked-cell');
|
|
60
|
+
});
|
|
61
|
+
}, 300);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
/* #E */
|
|
Binary file
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import type { Router } from 'vue-router';
|
|
2
|
-
import { adminPlugin } from '../adminPlugin';
|
|
3
2
|
import { hasToken } from '../../utils';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
interface TPermission {
|
|
5
|
+
refresh: () => Promise<any>
|
|
6
|
+
has: (code: string) => boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function setupPermissionRouterGuard(
|
|
10
|
+
router: Router,
|
|
11
|
+
permission: TPermission | undefined,
|
|
12
|
+
rouneNames: { index: string, login: string, 403: string },
|
|
13
|
+
) {
|
|
6
14
|
router.beforeEach(async (to, _, next) => {
|
|
7
|
-
|
|
8
|
-
const isLogin = hasToken();
|
|
15
|
+
let isLogin = hasToken();
|
|
9
16
|
const needLogin = Boolean(to.meta?.requireAuth);
|
|
10
17
|
let hasPermission = false;
|
|
11
18
|
|
|
12
19
|
if (isLogin) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
try {
|
|
21
|
+
await permission?.refresh();
|
|
22
|
+
const permissionCode = to.meta?.permissionCode;
|
|
23
|
+
hasPermission = permissionCode ? (permission?.has(permissionCode) ?? true) : true;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error('🤡 获取权限错误:', err);
|
|
27
|
+
isLogin = false;
|
|
28
|
+
hasPermission = false;
|
|
29
|
+
}
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
// 已登录状态跳转登录页,跳转至首页
|
|
20
33
|
if (isLogin && to.name === rouneNames.login)
|
|
21
|
-
return next({ name: rouneNames.index, replace: true });
|
|
34
|
+
return next({ name: rouneNames.index, replace: true, state: { tabType: 2 } });
|
|
22
35
|
|
|
23
36
|
// 不需要登录权限的页面直接通行
|
|
24
37
|
else if (!needLogin)
|
|
@@ -26,7 +39,7 @@ export function setupPermissionRouterGuard(router: Router, rouneNames: { index:
|
|
|
26
39
|
|
|
27
40
|
// 未登录状态进入需要登录权限的页面
|
|
28
41
|
else if (!isLogin && needLogin)
|
|
29
|
-
return next({ name: rouneNames.login,
|
|
42
|
+
return next({ name: rouneNames.login, query: { redirect: to.fullPath }, state: { tabType: 0 }, replace: true });
|
|
30
43
|
|
|
31
44
|
// 登录状态进入需要登录权限的页面,有权限直接通行
|
|
32
45
|
else if (isLogin && needLogin && hasPermission)
|
|
@@ -34,7 +47,7 @@ export function setupPermissionRouterGuard(router: Router, rouneNames: { index:
|
|
|
34
47
|
|
|
35
48
|
// 登录状态进入需要登录权限的页面,无权限,重定向到无权限页面
|
|
36
49
|
else if (isLogin && needLogin && !hasPermission)
|
|
37
|
-
return next({ name: rouneNames[403], replace: true });
|
|
50
|
+
return next({ name: rouneNames[403], state: { tabType: 0 }, replace: true });
|
|
38
51
|
|
|
39
52
|
return next(false);
|
|
40
53
|
});
|