@peng_kai/kit 0.3.0-beta.1 → 0.3.0-beta.11

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.
Files changed (53) hide show
  1. package/.vscode/settings.json +2 -2
  2. package/admin/components/currency/src/CurrencyIcon.vue +35 -33
  3. package/admin/components/date/PeriodPicker.vue +122 -0
  4. package/admin/components/date/TimeFieldSelectForLabel.vue +24 -0
  5. package/admin/components/date/TtaTimeZone.vue +516 -0
  6. package/admin/components/date/helpers.ts +223 -0
  7. package/admin/components/date/index.ts +4 -0
  8. package/admin/components/filter/src/FilterReset.vue +39 -7
  9. package/admin/components/filter/src/more/TableSetting.vue +63 -0
  10. package/admin/components/provider/Admin.vue +17 -0
  11. package/admin/components/provider/admin-permission.ts +48 -0
  12. package/admin/components/provider/admin-router.ts +358 -0
  13. package/admin/components/provider/index.ts +3 -0
  14. package/admin/components/text/index.ts +2 -0
  15. package/admin/components/text/src/Amount.v2.vue +127 -0
  16. package/admin/components/text/src/Datetime.vue +15 -11
  17. package/admin/components/text/src/Num.vue +192 -0
  18. package/admin/components/upload/src/customRequests.ts +1 -1
  19. package/admin/components/upload/src/helpers.ts +1 -0
  20. package/admin/layout/large/Breadcrumb.vue +10 -23
  21. package/admin/layout/large/Content.vue +9 -6
  22. package/admin/layout/large/Layout.vue +129 -0
  23. package/admin/layout/large/Menu.vue +24 -17
  24. package/admin/layout/large/Notice.vue +140 -0
  25. package/admin/layout/large/Tabs.vue +177 -0
  26. package/admin/layout/large/index.ts +61 -1
  27. package/admin/layout/large/y682.mp3 +0 -0
  28. package/admin/permission/routerGuard.ts +15 -8
  29. package/admin/permission/vuePlugin.ts +5 -10
  30. package/admin/route-guards/index.ts +0 -1
  31. package/admin/stores/index.ts +1 -0
  32. package/admin/styles/classCover.scss +1 -1
  33. package/admin/styles/globalCover.scss +4 -0
  34. package/admin/styles/index.scss +2 -2
  35. package/antd/hooks/useAntdForm.helpers.ts +10 -1
  36. package/antd/hooks/useAntdModal.ts +20 -8
  37. package/antd/hooks/useAntdTable.ts +52 -2
  38. package/antd/hooks/useAntdTheme.ts +7 -0
  39. package/antd/index.ts +1 -1
  40. package/libs/bignumber.ts +1 -1
  41. package/libs/dayjs.ts +11 -1
  42. package/libs/fingerprintjs.ts +1 -0
  43. package/package.json +95 -95
  44. package/request/interceptors/getDeviceInfo.ts +9 -0
  45. package/utils/LocaleManager.ts +1 -1
  46. package/utils/locale/LocaleManager.ts +2 -1
  47. package/utils/number.ts +1 -2
  48. package/utils/storage.ts +31 -0
  49. package/utils/string.ts +14 -0
  50. package/utils/upload/AwsS3.ts +10 -3
  51. package/admin/layout/large/PageTab.vue +0 -70
  52. package/admin/route-guards/collapseMenu.ts +0 -11
  53. package/libs/a-calc.ts +0 -1
@@ -0,0 +1,192 @@
1
+ <script lang="ts">
2
+ import bignumber, { type BigNumber } from 'bignumber.js';
3
+ import { computed } from 'vue';
4
+
5
+ export interface NumProps {
6
+ /**
7
+ * 数值
8
+ */
9
+ value: BigNumber.Value
10
+ /**
11
+ * 小数位数
12
+ */
13
+ decimals?: number | [number, number]
14
+ /**
15
+ * 舍入方式
16
+ *
17
+ * - up: 向上取整
18
+ * - down: 向下取整
19
+ * - half-up: 四舍五入
20
+ * - half-even: 银行家算法
21
+ */
22
+ round?: 'up' | 'down' | 'half-up' | 'half-even'
23
+ /**
24
+ * 格式化方式
25
+ *
26
+ * - original: 原始
27
+ * - pad-dec: 自适应补零,即整数位多1位,小数位就少1位
28
+ * - fixed-dec: 固定小数位数
29
+ * - max-dec: 最大小数位数
30
+ * - min-dec: 最小小数位数
31
+ * - clamp-dec: 限制小数位数
32
+ */
33
+ format?: 'original' | 'pad-dec' | 'fixed-dec' | 'max-dec' | 'min-dec' | 'clamp-dec'
34
+ /**
35
+ * 着色
36
+ *
37
+ * - inherit: 继承
38
+ * - neg: 负数
39
+ * - pos: 正数
40
+ * - full: 全部
41
+ */
42
+ colored?: 'inherit' | 'neg' | 'pos' | 'full'
43
+ /**
44
+ * 是否显示正数符号
45
+ */
46
+ showPos?: boolean
47
+ /**
48
+ * 是否使用分组分隔符(千分符)
49
+ */
50
+ grouping?: boolean
51
+ /**
52
+ * 是否弱化无价值填充
53
+ */
54
+ weakPad?: boolean
55
+ }
56
+
57
+ // 匹配数字中无价值的填充(仅支持有小数的), 如 1.2000 => 1.2, 1.000 => 1
58
+ const insignRE = /\.?0+$/;
59
+
60
+ function formatPadDecimals(num: BigNumber.Instance, decimals: number, round: BigNumber.RoundingMode, grouping: boolean) {
61
+ const integerLen = num.integerValue().toString().length;
62
+ const showDecimalLen = Math.max(decimals - integerLen + 1, 1);
63
+ return grouping ? num.toFormat(showDecimalLen, round) : num.toFixed(showDecimalLen, round);
64
+ }
65
+
66
+ function formatFixedDecimals(num: BigNumber.Instance, decimals: number, round: BigNumber.RoundingMode, grouping: boolean) {
67
+ return grouping ? num.toFormat(decimals, round) : num.toFixed(decimals, round);
68
+ }
69
+
70
+ function formatMaxDecimals(num: BigNumber.Instance, decimals: number, round: BigNumber.RoundingMode, grouping: boolean) {
71
+ const showDecimalLen = Math.min(num.decimalPlaces() ?? 0, decimals);
72
+ return grouping ? num.toFormat(showDecimalLen, round) : num.toFixed(showDecimalLen, round);
73
+ }
74
+
75
+ function formatMinDecimals(num: BigNumber.Instance, decimals: number, round: BigNumber.RoundingMode, grouping: boolean) {
76
+ const showDecimalLen = Math.max(num.decimalPlaces() ?? 0, decimals);
77
+ return grouping ? num.toFormat(showDecimalLen, round) : num.toFixed(showDecimalLen, round);
78
+ }
79
+
80
+ function formatClampDecimals(num: BigNumber.Instance, decimals: [number, number], round: BigNumber.RoundingMode, grouping: boolean) {
81
+ const decimalLen = num.decimalPlaces() ?? 0;
82
+ const showDecimalLen = Math.min(Math.max(decimalLen, decimals[0]), decimals[1]);
83
+ return grouping ? num.toFormat(showDecimalLen, round) : num.toFixed(showDecimalLen, round);
84
+ }
85
+
86
+ function formatOriginal(num: BigNumber.Instance, decimals: number, round: BigNumber.RoundingMode, grouping: boolean) {
87
+ const decimalLen = num.decimalPlaces() ?? decimals;
88
+ return grouping ? num.toFormat(decimalLen, round) : num.toString();
89
+ }
90
+ </script>
91
+
92
+ <script setup lang="ts">
93
+ const props = withDefaults(defineProps<NumProps>(), {
94
+ value: () => bignumber(0),
95
+ showPos: false,
96
+ decimals: 2,
97
+ colored: 'inherit',
98
+ round: 'half-up',
99
+ format: 'original',
100
+ grouping: true,
101
+ weakPad: false,
102
+ });
103
+
104
+ const numParts = computed(() => {
105
+ const num = bignumber(props.value).abs();
106
+ const roundMode = ({
107
+ 'up': bignumber.ROUND_UP,
108
+ 'down': bignumber.ROUND_DOWN,
109
+ 'half-up': bignumber.ROUND_HALF_UP,
110
+ 'half-even': bignumber.ROUND_HALF_EVEN,
111
+ })[props.round];
112
+ const isRangeDecimals = ['clamp-dec'].includes(props.format);
113
+ const parts = { full: '', sign: '', value: '', pad: '' };
114
+ let text = '';
115
+
116
+ if (isRangeDecimals) {
117
+ const decLen = Array.isArray(props.decimals)
118
+ ? props.decimals
119
+ : [props.decimals, props.decimals] as [number, number];
120
+ const format = ({
121
+ 'clamp-dec': formatClampDecimals,
122
+ } as Record<string, typeof formatClampDecimals>)[props.format];
123
+
124
+ text = format(num, decLen, roundMode, props.grouping);
125
+ }
126
+ else {
127
+ const decLen = Array.isArray(props.decimals) ? props.decimals[0] : props.decimals;
128
+ const format = ({
129
+ 'pad-dec': formatPadDecimals,
130
+ 'fixed-dec': formatFixedDecimals,
131
+ 'max-dec': formatMaxDecimals,
132
+ 'min-dec': formatMinDecimals,
133
+ } as Record<string, typeof formatPadDecimals>)[props.format] ?? formatOriginal;
134
+
135
+ text = format(num, decLen, roundMode, props.grouping);
136
+ }
137
+
138
+ if (text) {
139
+ const originalNum = bignumber(props.value);
140
+
141
+ parts.sign = originalNum.lt(0) ? '-' : (originalNum.gt(0) && props.showPos) ? '+' : '';
142
+ parts.full = parts.sign + text;
143
+ parts.pad = text.match(insignRE)?.[0] ?? '';
144
+ parts.pad = originalNum.decimalPlaces() || parts.pad[0] === '.' ? parts.pad : '';
145
+ parts.value = text.slice(0, text.length - parts.pad.length);
146
+ }
147
+
148
+ return parts;
149
+ });
150
+ const classList = computed(() => {
151
+ const num = bignumber(props.value);
152
+ const classes = [`colored-${props.colored}`];
153
+
154
+ if (num.lt(0))
155
+ classes.push('sign-neg');
156
+ else if (num.gt(0))
157
+ classes.push('sign-pos');
158
+ else
159
+ classes.push('sign-zero');
160
+
161
+ if (props.weakPad)
162
+ classes.push('weak-pad');
163
+
164
+ return classes;
165
+ });
166
+ </script>
167
+
168
+ <template>
169
+ <span class="num" :class="classList">
170
+ <slot name="signLeft" />{{ numParts.sign }}<slot name="signRight" /><slot name="numLeft" />{{ numParts.value }}<span class="pad">{{ numParts.pad }}</span><slot name="numRight" />
171
+ </span>
172
+ </template>
173
+
174
+ <style lang="scss" scoped>
175
+ .num {
176
+ font-variant-numeric: tabular-nums;
177
+
178
+ &.sign-neg.colored-neg,
179
+ &.sign-neg.colored-full {
180
+ color: var(--neg-color, #ef4444);
181
+ }
182
+
183
+ &.sign-pos.colored-pos,
184
+ &.sign-pos.colored-full {
185
+ color: var(--pos-color, #22c55e);
186
+ }
187
+
188
+ &.weak-pad .pad {
189
+ opacity: 0.5;
190
+ }
191
+ }
192
+ </style>
@@ -7,7 +7,7 @@ export function createAwsS3Request(awsS3: AwsS3, rootPath?: string) {
7
7
  return async (options: UploadRequestOption) => {
8
8
  if (!(options.file instanceof File))
9
9
  throw new Error('options.file 不是 File 对象');
10
-
10
+
11
11
  const { uploader, fileURL } = await awsS3.upload(options.file, rootPath);
12
12
 
13
13
  uploader.on('httpUploadProgress', (progress) => {
@@ -31,6 +31,7 @@ export function urlToUploadFile(urls?: string[] | string) {
31
31
  name: getFileNameByUrl(url),
32
32
  status: 'done',
33
33
  url,
34
+ thumbUrl: url,
34
35
  }));
35
36
 
36
37
  return files;
@@ -3,15 +3,14 @@ import { computed } from 'vue';
3
3
  import type { VNode } from 'vue';
4
4
  import { Breadcrumb as ABreadcrumb } from 'ant-design-vue';
5
5
  import type { Route as AntdBreadcrumbRoute } from 'ant-design-vue/es/breadcrumb/Breadcrumb';
6
- import { injectTTAdmin } from '../../adminPlugin';
7
- import type { IBreadcrumb } from '../../stores/createUsePageStore';
6
+ import type { TBreadcrumb } from '../../components/provider/admin-router';
8
7
 
9
- interface IBreadcrumbRoute extends AntdBreadcrumbRoute {
8
+ interface TBreadcrumbRoute extends AntdBreadcrumbRoute {
10
9
  icon?: VNode | null
11
10
  trigger?: () => void
12
11
  }
13
12
 
14
- function _buildRoute(breadcrumb: IBreadcrumb): IBreadcrumbRoute {
13
+ function _buildRoute(breadcrumb: TBreadcrumb): TBreadcrumbRoute {
15
14
  return {
16
15
  breadcrumbName: breadcrumb.title,
17
16
  path: breadcrumb.title,
@@ -23,28 +22,15 @@ function _buildRoute(breadcrumb: IBreadcrumb): IBreadcrumbRoute {
23
22
  </script>
24
23
 
25
24
  <script setup lang="ts">
26
- const pageStore = injectTTAdmin()!.deps.usePageStore();
27
- const routes = computed(() => {
28
- let breadcrumbs: IBreadcrumbRoute[] = [];
29
-
30
- if (!pageStore.currentPageState)
31
- return breadcrumbs;
32
-
33
- breadcrumbs = pageStore.currentPageState.breadcrumbs.map(breadcrumb => _buildRoute(breadcrumb));
34
-
35
- breadcrumbs.push({
36
- path: pageStore.currentPageState.title,
37
- breadcrumbName: pageStore.currentPageState.title,
38
- icon: pageStore.currentPageState.icon,
39
- });
40
-
41
- return breadcrumbs;
42
- });
25
+ const props = defineProps<{
26
+ breadcrumbs: TBreadcrumb[]
27
+ }>();
28
+ const routes = computed(() => props.breadcrumbs.map(breadcrumb => _buildRoute(breadcrumb)));
43
29
  </script>
44
30
 
45
31
  <template>
46
32
  <ABreadcrumb class="breadcrumb" :routes="routes">
47
- <template #itemRender="{ route }: {route: IBreadcrumbRoute}">
33
+ <template #itemRender="{ route }: {route: TBreadcrumbRoute}">
48
34
  <div @click="route.trigger?.()">
49
35
  <component :is="route.icon" v-if="route.icon" class="mb-0.2em mr-0.2em" />
50
36
  <span>{{ route.breadcrumbName }}</span>
@@ -55,10 +41,11 @@ const routes = computed(() => {
55
41
 
56
42
  <style lang="scss" scoped>
57
43
  .breadcrumb {
58
- font-size: 1rem;
44
+ font-size: 14px;
59
45
 
60
46
  :deep(.ant-dropdown-trigger) {
61
47
  height: 1.6em;
48
+ cursor: pointer;
62
49
  }
63
50
 
64
51
  :deep(.anticon-down) {
@@ -1,16 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { injectTTAdmin } from '../../adminPlugin';
2
+ import { RouterView } from 'vue-router';
3
+ import { KeepAlive, type KeepAliveProps } from 'vue';
3
4
 
4
- const pageStore = injectTTAdmin()!.deps.usePageStore();
5
+ const props = defineProps<{
6
+ cached: KeepAliveProps['include']
7
+ }>();
5
8
  </script>
6
9
 
7
10
  <template>
8
- <RouterView #default="{ Component, route }">
9
- <KeepAlive :include="pageStore.pageCacheList">
11
+ <RouterView #default="{ Component }">
12
+ <KeepAlive :include="props.cached">
10
13
  <Suspense>
11
- <component :is="pageStore.setPage(Component, route)" :key="(Component.type as any).name" />
14
+ <component :is="Component" />
12
15
  <template #fallback>
13
- <div class="flex justify-center items-center h-70">
16
+ <div class="flex justify-center items-center h-full">
14
17
  <div class="flex items-center">
15
18
  <i class="i-svg-spinners:180-ring-with-bg block color-primary scale-125 mr-2" />
16
19
  <span class="text-gray">正在加载...</span>
@@ -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 { injectTTAdmin } from '../../adminPlugin';
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 { router, useMenuStore } = injectTTAdmin()!.deps;
25
- const menuStore = useMenuStore();
26
- const items = computed(() => menuStore.menus.map(formatMenu));
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
- () => router?.currentRoute.value,
32
- (route) => {
33
- if (!route)
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,140 @@
1
+ <script lang="ts">
2
+ import { useIntervalFn } from '@vueuse/core';
3
+ import { Empty, Dropdown, Badge, Menu, MenuItem } from 'ant-design-vue';
4
+ import { type UseQueryReturnType } from '@tanstack/vue-query'
5
+ import { watch, ref, computed } from 'vue'
6
+ import dayjs from '../../../libs/dayjs';
7
+ import { useRouter } from 'vue-router';
8
+ import audioURL from './y682.mp3';
9
+
10
+ const noticeAudio = new Audio(audioURL);
11
+ noticeAudio.src = audioURL;
12
+ noticeAudio.muted = true;
13
+ document.body.append(noticeAudio);
14
+
15
+ const logoFaviconEle = document.querySelector('link[rel="icon"]');
16
+ const numFaviconEle = document.createElement('link');
17
+ numFaviconEle.rel = 'icon';
18
+ const numCanvas = document.createElement('canvas') as (HTMLCanvasElement & { num: number });
19
+ numCanvas.width = 32;
20
+ numCanvas.height = 32;
21
+
22
+ interface TNotice {
23
+ title: string;
24
+ type: number;
25
+ url: string;
26
+ time: number;
27
+ }
28
+ </script>
29
+
30
+ <script lang="ts" setup>
31
+ const props = defineProps<{
32
+ noticesQry: UseQueryReturnType<TNotice[], Error>
33
+ }>()
34
+
35
+ const router = useRouter();
36
+ const visible = ref(false);
37
+ const noticeNum = computed(() => props.noticesQry.data.value?.length ?? 0);
38
+
39
+ function operate(notice: TNotice) {
40
+ if (router.currentRoute.value.path !== notice.url) {
41
+ router.push(notice.url);
42
+ }
43
+ }
44
+
45
+ watch(noticeNum, (num = 0, oldNum = 0) => {
46
+ num > oldNum && noticeAudio.play();
47
+ }, { immediate: true });
48
+
49
+ useIntervalFn(() => {
50
+ const num = noticeNum.value;
51
+
52
+ if (!num || document.visibilityState === 'visible') {
53
+ if (numFaviconEle.parentNode) {
54
+ numFaviconEle.remove();
55
+ logoFaviconEle && document.head.appendChild(logoFaviconEle);
56
+ }
57
+ return;
58
+ }
59
+
60
+ if (logoFaviconEle?.parentNode) {
61
+ if (numCanvas.num !== num) {
62
+ numCanvas.num = num;
63
+ const ctx = numCanvas.getContext('2d')!;
64
+ ctx.fillStyle = '#f00';
65
+ ctx.beginPath();
66
+ ctx.arc(16, 16, 16, 0, Math.PI * 2, true);
67
+ ctx.fill();
68
+ ctx.fillStyle = '#fff';
69
+ ctx.font = 'bold 22px sans-serif';
70
+ ctx.textAlign = 'center';
71
+ ctx.textBaseline = 'middle';
72
+ ctx.fillText(num.toString(), 16, 16);
73
+ }
74
+
75
+ numFaviconEle.href = numCanvas.toDataURL();
76
+ logoFaviconEle.parentNode.replaceChild(numFaviconEle, logoFaviconEle);
77
+ }
78
+ else {
79
+ numFaviconEle.parentNode?.replaceChild(logoFaviconEle!, numFaviconEle);
80
+ }
81
+ }, 500);
82
+ </script>
83
+
84
+ <template>
85
+ <Badge :count="noticeNum" size="small">
86
+ <Dropdown v-model:open="visible" placement="bottomRight" trigger="click" arrow>
87
+ <div class="h-7 flex cursor-pointer items-center justify-center rounded-1 px-1">
88
+ <i class="i-ri:notification-4-line block text-5" :class="{ shake: !!noticeNum }" />
89
+ </div>
90
+ <template #overlay>
91
+ <Menu v-if="noticeNum" @click="visible = false">
92
+ <MenuItem v-for="(item, i) of noticesQry.data.value" :key="i" @click="operate(item)">
93
+ <div class="min-w-50">
94
+ <div>{{ item.title }}</div>
95
+ <div class="mt-1 text-xs text-$antd-colorTextSecondary">
96
+ {{ dayjs().format('MM-DD HH:mm:ss') }}
97
+ </div>
98
+ </div>
99
+ </MenuItem>
100
+ </Menu>
101
+ <div v-else class="h-40 min-w-50 flex items-center justify-center rounded-2 bg-$antd-colorBgElevated shadow"
102
+ @click="visible = false">
103
+ <Empty description="暂无通知" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
104
+ </div>
105
+ </template>
106
+ </Dropdown>
107
+ </Badge>
108
+ </template>
109
+
110
+ <style lang="scss" scoped>
111
+ @keyframes shake {
112
+ 0% {
113
+ transform: rotate(0deg);
114
+ }
115
+
116
+ 25% {
117
+ transform: rotate(10deg);
118
+ }
119
+
120
+ 50% {
121
+ transform: rotate(-10deg);
122
+ }
123
+
124
+ 75% {
125
+ transform: rotate(10deg);
126
+ }
127
+
128
+ 100% {
129
+ transform: rotate(-10deg);
130
+ }
131
+ }
132
+
133
+ .shake {
134
+ animation: shake 0.5s infinite;
135
+ }
136
+
137
+ .shadow {
138
+ 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%);
139
+ }
140
+ </style>