初始化

This commit is contained in:
2026-03-26 20:18:20 +08:00
commit 120a5b4dfd
368 changed files with 35926 additions and 0 deletions

46
src/App.vue Normal file
View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { WatermarkProps } from 'element-plus';
import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme';
import { useAuthStore } from './store/modules/auth';
import { UILocales } from './locales/ui';
defineOptions({ name: 'App' });
const appStore = useAppStore();
const themeStore = useThemeStore();
const authStore = useAuthStore();
const locale = computed(() => {
return UILocales[appStore.locale];
});
const watermarkProps = computed<WatermarkProps>(() => {
const content =
themeStore.watermark.enableUserName && authStore.userInfo.userName
? authStore.userInfo.userName
: themeStore.watermark.text;
return {
content: themeStore.watermark.visible ? content : '',
cross: true,
fontSize: 16,
lineHeight: 16,
gap: [100, 120],
rotate: -15,
zIndex: 9999
};
});
</script>
<template>
<ElConfigProvider :locale="locale">
<AppProvider>
<ElWatermark class="h-full" v-bind="watermarkProps">
<RouterView class="bg-layout" />
</ElWatermark>
</AppProvider>
</ElConfigProvider>
</template>
<style scoped></style>

BIN
src/assets/imgs/soybean.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>

After

Width:  |  Height:  |  Size: 202 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-3.92 7.94"/></svg>

After

Width:  |  Height:  |  Size: 315 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-cast"><path d="M2 16.1A5 5 0 015.9 20M2 12.05A9 9 0 019.95 20M2 8V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-6M2 20h.01"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><path d="M21.17 8H12M3.95 6.06L8.54 14m2.34 7.94L15.46 14"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24"><path fill="currentColor" d="M19 10c0 1.38-2.12 2.5-3.5 2.5s-2.75-1.12-2.75-2.5h-1.5c0 1.38-1.37 2.5-2.75 2.5S5 11.38 5 10h-.75c-.16.64-.25 1.31-.25 2a8 8 0 008 8 8 8 0 008-8c0-.69-.09-1.36-.25-2H19m-7-6C9.04 4 6.45 5.61 5.07 8h13.86C17.55 5.61 14.96 4 12 4m10 8a10 10 0 01-10 10A10 10 0 012 12 10 10 0 0112 2a10 10 0 0110 10m-10 5.23c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81z"/></svg>

After

Width:  |  Height:  |  Size: 544 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><path d="M81.28 55.9c-.1-11.67-2.93-22.55-9.37-32.38-1-1.5-2.14-2.86-2.5-4.71a8.1 8.1 0 014-8.61 7.89 7.89 0 019.3 1.23 35.999 35.999 0 015.9 8.83 75.18 75.18 0 018.44 28.58 83.211 83.211 0 01-5.23 36.74 102.983 102.983 0 01-3 7.28 1.2 1.2 0 000 1.41c9.58 13.3 21.76 23 37.85 27.24a54.37 54.37 0 0019.68 1.57 7.72 7.72 0 018.36 6.9 7.903 7.903 0 01-6.7 9 64.744 64.744 0 01-23-1.33 77.68 77.68 0 01-36.93-19.88 93.628 93.628 0 01-11.91-13.71 2.18 2.18 0 00-2.3-1.06 72.744 72.744 0 00-27.38 7.55c-11.6 6-20.67 14.58-26.4 26.45a10.134 10.134 0 01-3.7 4.7 8 8 0 01-9.19-.7 7.86 7.86 0 01-2.36-9.28 60.324 60.324 0 018.72-14.52c12.2-15.43 28.21-24.59 47.32-28.57A85.085 85.085 0 0173.07 87c.524.015 1-.307 1.18-.8a76.06 76.06 0 006.53-22.3c.351-2.652.518-5.325.5-8z" fill="currentColor"/><path d="M136.26 108.34a44.742 44.742 0 01-11.13-2.87 46.108 46.108 0 01-19.66-13.76 8 8 0 015.72-13.22 7.93 7.93 0 016.54 2.93 33.27 33.27 0 0018.87 10.75c1.546.155 3.058.553 4.48 1.18a8.08 8.08 0 013.84 9.21c-.92 3.52-4.13 5.81-8.66 5.78zm-80.6-75.02a7.61 7.61 0 016.64 5 49.139 49.139 0 013.64 17 46.33 46.33 0 01-2.46 17.28c-2 5.77-8.24 7.79-12.89 4.15a8.1 8.1 0 01-2.39-9 31.679 31.679 0 001.68-12.36 35.77 35.77 0 00-2.43-11c-2.1-5.45 1.75-11.07 8.21-11.07zm22.26 93.25a8 8 0 01-6.68 7.86 32.88 32.88 0 00-19.7 12.19 8.13 8.13 0 01-11.21 1.62 8 8 0 01-1.41-11.58A51.043 51.043 0 0154 123.81a45.842 45.842 0 0114-5.1c5.35-1.04 9.91 2.56 9.92 7.86z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,3 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 10C149.706 10 190 50.2944 190 100C190 108.029 188.949 115.812 186.976 123.219L136.254 39.4666H136.195C131.638 32.531 123.789 27.9518 114.87 27.9518C105.952 27.9518 98.1024 32.531 93.5456 39.4666H93.4853L92.8532 40.5821C92.731 40.7907 92.6117 41.0012 92.4953 41.2136L36.9057 139.312C34.7053 143.082 33.4445 147.467 33.4445 152.147C33.4445 156.134 34.3599 159.908 35.9919 163.269C19.9218 147.012 10 124.665 10 100C10 50.2944 50.2944 10 100 10ZM53.4722 177.056C55.2341 177.441 57.0641 177.644 58.9415 177.644C73.0231 177.644 84.4384 166.228 84.4384 152.147C84.4384 147.63 83.2642 143.388 81.2043 139.709L81.2172 139.704L68.7932 118.202C62.0989 106.608 66.0716 91.7816 77.6663 85.0874C89.261 78.3932 104.087 82.3658 110.781 93.9605L154.607 168.236L154.625 168.229C155.082 168.971 155.575 169.689 156.103 170.378C140.717 182.659 121.216 190 100 190C82.9725 190 67.0494 185.271 53.4722 177.056Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wind"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"></path></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -0,0 +1,37 @@
<script setup lang="ts" generic="T extends Record<string, unknown>, K = never">
import { VueDraggable } from 'vue-draggable-plus';
import { $t } from '@/locales';
defineOptions({ name: 'TableColumnSetting' });
const columns = defineModel<UI.TableColumnCheck[]>('columns', {
required: true
});
</script>
<template>
<ElPopover placement="bottom-end" trigger="click">
<template #reference>
<ElButton>
<template #icon>
<icon-ant-design-setting-outlined class="text-icon" />
</template>
{{ $t('common.columnSetting') }}
</ElButton>
</template>
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable" class="h-[200px] overflow-y-auto">
<div
v-for="item in columns"
:key="item.prop"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
>
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
<ElTooltip :content="item.label as string" :disabled="item.label.length < 10" placement="top">
<ElCheckbox v-model="item.checked" class="none_draggable flex-1 overflow-hidden">{{ item.label }}</ElCheckbox>
</ElTooltip>
</div>
</VueDraggable>
</ElPopover>
</template>
<style scoped></style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({ name: 'TableHeaderOperation' });
interface Props {
disabledDelete?: boolean;
loading?: boolean;
}
defineProps<Props>();
interface Emits {
(e: 'add'): void;
(e: 'delete'): void;
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const columns = defineModel<UI.TableColumnCheck[]>('columns', {
default: () => []
});
function add() {
emit('add');
}
function batchDelete() {
emit('delete');
}
function refresh() {
emit('refresh');
}
</script>
<template>
<ElSpace direction="horizontal" wrap justify="end" class="lt-sm:w-200px">
<slot name="prefix"></slot>
<slot name="default">
<ElButton plain type="primary" @click="add">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
{{ $t('common.add') }}
</ElButton>
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="batchDelete">
<template #reference>
<ElButton type="danger" plain :disabled="disabledDelete">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
{{ $t('common.batchDelete') }}
</ElButton>
</template>
</ElPopconfirm>
</slot>
<ElButton @click="refresh">
<template #icon>
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
</template>
{{ $t('common.refresh') }}
</ElButton>
<TableColumnSetting v-model:columns="columns" />
<slot name="suffix"></slot>
</ElSpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { createTextVNode, defineComponent } from 'vue';
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus';
defineOptions({ name: 'AppProvider' });
const ContextHolder = defineComponent({
name: 'ContextHolder',
setup() {
function register() {
window.$notification = ElNotification;
window.$messageBox = ElMessageBox;
window.$message = ElMessage;
}
register();
return () => createTextVNode();
}
});
</script>
<template>
<div class="h-full">
<ContextHolder />
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
defineOptions({ name: 'DarkModeContainer' });
interface Props {
inverted?: boolean;
}
defineProps<Props>();
</script>
<template>
<div class="bg-container text-base-text transition-300" :class="{ 'bg-inverted text-#1f1f1f': inverted }">
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
defineOptions({ name: 'ExceptionBase' });
type ExceptionType = '403' | '404' | '500';
interface Props {
/**
* Exception type
*
* - 403: no permission
* - 404: not found
* - 500: service error
*/
type: ExceptionType;
}
const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
const iconMap: Record<ExceptionType, string> = {
'403': 'no-permission',
'404': 'not-found',
'500': 'service-error'
};
const icon = computed(() => iconMap[props.type]);
</script>
<template>
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
<div class="flex text-400px text-primary">
<SvgIcon :local-icon="icon" />
</div>
<ElButton type="primary" @click="routerPushByKey('root')">{{ $t('common.backToHome') }}</ElButton>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({ name: 'FullScreen' });
interface Props {
full?: boolean;
}
defineProps<Props>();
</script>
<template>
<ButtonIcon :key="String(full)" :tooltip-content="full ? $t('icon.fullscreenExit') : $t('icon.fullscreen')">
<icon-gridicons-fullscreen-exit v-if="full" />
<icon-gridicons-fullscreen v-else />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({ name: 'LangSwitch' });
interface Props {
/** Current language */
lang: App.I18n.LangType;
/** Language options */
langOptions: App.I18n.LangOption[];
/** Show tooltip */
showTooltip?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showTooltip: true
});
type Emits = {
(e: 'changeLang', lang: App.I18n.LangType): void;
};
const emit = defineEmits<Emits>();
const tooltipContent = computed(() => {
if (!props.showTooltip) return '';
return $t('icon.lang');
});
function changeLang(lang: App.I18n.LangType) {
emit('changeLang', lang);
}
</script>
<template>
<ElDropdown trigger="click">
<div>
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
<SvgIcon icon="heroicons:language" />
</ButtonIcon>
</div>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="{ key, label } in langOptions"
:key="key"
:value="key"
class="mx-4px my-1px"
:class="{ 'is-active': key === lang }"
@click="changeLang(key)"
>
{{ label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
<style scoped>
:deep(.el-dropdown-menu__item) {
border-radius: 6px;
}
:deep(.is-active) {
background-color: var(--el-dropdown-menuItem-hover-fill);
color: var(--el-dropdown-menuItem-hover-color);
}
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({ name: 'MenuToggler' });
interface Props {
/** Show collapsed icon */
collapsed?: boolean;
/** Arrow style icon */
arrowIcon?: boolean;
zIndex?: number;
}
const props = withDefaults(defineProps<Props>(), {
arrowIcon: false,
zIndex: 98
});
type NumberBool = 0 | 1;
const icon = computed(() => {
const icons: Record<NumberBool, Record<NumberBool, string>> = {
0: {
0: 'line-md:menu-fold-left',
1: 'line-md:menu-fold-right'
},
1: {
0: 'ph-caret-double-left-bold',
1: 'ph-caret-double-right-bold'
}
};
const arrowIcon = Number(props.arrowIcon || false) as NumberBool;
const collapsed = Number(props.collapsed || false) as NumberBool;
return icons[arrowIcon][collapsed];
});
</script>
<template>
<ButtonIcon
:key="String(collapsed)"
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
tooltip-placement="bottom-start"
:z-index="zIndex"
>
<SvgIcon :icon="icon" />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({ name: 'PinToggler' });
interface Props {
pin?: boolean;
}
const props = defineProps<Props>();
const icon = computed(() => (props.pin ? 'mdi-pin-off' : 'mdi-pin'));
</script>
<template>
<ButtonIcon
:tooltip-content="pin ? $t('icon.unpin') : $t('icon.pin')"
tooltip-placement="bottom-start"
:z-index="100"
>
<SvgIcon :icon="icon" />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({ name: 'ReloadButton' });
interface Props {
loading?: boolean;
}
defineProps<Props>();
</script>
<template>
<ButtonIcon :tooltip-content="$t('icon.reload')">
<icon-ant-design-reload-outlined :class="{ 'animate-spin animate-duration-750': loading }" />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import systemLogo from '@/assets/svg-icon/logo.png';
defineOptions({ name: 'SystemLogo' });
</script>
<template>
<img :src="systemLogo" alt="logo" class="inline-block h-1em w-1em object-contain align-middle" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Placement } from 'element-plus';
import { $t } from '@/locales';
defineOptions({ name: 'ThemeSchemaSwitch' });
interface Props {
/** Theme schema */
themeSchema: UnionKey.ThemeScheme;
/** Show tooltip */
showTooltip?: boolean;
/** Tooltip placement */
tooltipPlacement?: Placement;
}
const props = withDefaults(defineProps<Props>(), {
showTooltip: true,
tooltipPlacement: 'bottom'
});
interface Emits {
(e: 'switch'): void;
}
const emit = defineEmits<Emits>();
function handleSwitch() {
emit('switch');
}
const icons: Record<UnionKey.ThemeScheme, string> = {
light: 'material-symbols:sunny',
dark: 'material-symbols:nightlight-rounded',
auto: 'material-symbols:hdr-auto'
};
const icon = computed(() => icons[props.themeSchema]);
const tooltipContent = computed(() => {
if (!props.showTooltip) return '';
return $t('icon.themeSchema');
});
</script>
<template>
<ButtonIcon
:icon="icon"
:tooltip-content="tooltipContent"
:tooltip-placement="tooltipPlacement"
@click="handleSwitch"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import BScroll from '@better-scroll/core';
import type { Options } from '@better-scroll/core';
defineOptions({ name: 'BetterScroll' });
interface Props {
/**
* BetterScroll options
*
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
*/
options: Options;
}
const props = defineProps<Props>();
const bsWrapper = ref<HTMLElement>();
const bsContent = ref<HTMLElement>();
const { width: wrapWidth } = useElementSize(bsWrapper);
const { width, height } = useElementSize(bsContent);
const instance = ref<BScroll>();
const isScrollY = computed(() => Boolean(props.options.scrollY));
function initBetterScroll() {
if (!bsWrapper.value) return;
instance.value = new BScroll(bsWrapper.value, props.options);
}
// refresh BS when scroll element size changed
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
instance.value?.refresh();
});
onMounted(() => {
initBetterScroll();
});
defineExpose({ instance });
</script>
<template>
<div ref="bsWrapper" class="h-full text-left">
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
<slot></slot>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({
name: 'BusinessFormDialog',
inheritAttrs: false
});
type DialogPreset = 'sm' | 'md' | 'lg';
interface Props {
title: string;
preset?: DialogPreset;
width?: string | number;
loading?: boolean;
confirmLoading?: boolean;
confirmDisabled?: boolean;
showFooter?: boolean;
scrollbar?: boolean;
maxBodyHeight?: string | number;
cancelText?: string;
confirmText?: string;
}
interface Emits {
(e: 'cancel'): void;
(e: 'confirm'): void;
}
const props = withDefaults(defineProps<Props>(), {
preset: 'md',
width: undefined,
loading: false,
confirmLoading: false,
confirmDisabled: false,
showFooter: true,
scrollbar: true,
maxBodyHeight: '70vh',
cancelText: '',
confirmText: ''
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>({
default: false
});
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
sm: '520px',
md: '640px',
lg: '720px'
};
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
const resolvedCancelText = computed(() => props.cancelText || $t('common.cancel'));
const resolvedConfirmText = computed(() => props.confirmText || $t('common.confirm'));
function handleCancel() {
visible.value = false;
emit('cancel');
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<ElDialog
v-model="visible"
class="business-form-dialog"
:title="props.title"
:width="dialogWidth"
:close-on-click-modal="false"
destroy-on-close
align-center
header-class="business-form-dialog__header"
body-class="business-form-dialog__body"
footer-class="business-form-dialog__footer"
v-bind="$attrs"
>
<ElScrollbar
v-if="props.scrollbar"
:max-height="props.maxBodyHeight"
class="business-form-dialog__scrollbar"
view-class="business-form-dialog__scrollbar-view"
>
<div v-loading="props.loading" class="business-form-dialog__content">
<slot />
</div>
</ElScrollbar>
<div v-else v-loading="props.loading" class="business-form-dialog__content">
<slot />
</div>
<template v-if="props.showFooter" #footer>
<slot name="footer" :close="handleCancel">
<ElSpace :size="16">
<ElButton @click="handleCancel">{{ resolvedCancelText }}</ElButton>
<ElButton
type="primary"
:loading="props.confirmLoading"
:disabled="props.confirmDisabled"
@click="handleConfirm"
>
{{ resolvedConfirmText }}
</ElButton>
</ElSpace>
</slot>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({
name: 'BusinessFormDrawer',
inheritAttrs: false
});
type DrawerPreset = 'md' | 'lg' | 'xl';
interface Props {
title: string;
preset?: DrawerPreset;
size?: string | number;
loading?: boolean;
confirmLoading?: boolean;
confirmDisabled?: boolean;
showFooter?: boolean;
scrollbar?: boolean;
bodyHeight?: string | number;
cancelText?: string;
confirmText?: string;
}
interface Emits {
(e: 'cancel'): void;
(e: 'confirm'): void;
}
const props = withDefaults(defineProps<Props>(), {
preset: 'lg',
size: undefined,
loading: false,
confirmLoading: false,
confirmDisabled: false,
showFooter: true,
scrollbar: true,
bodyHeight: '100%',
cancelText: '',
confirmText: ''
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>({
default: false
});
const DRAWER_SIZE_MAP: Record<DrawerPreset, string> = {
md: '480px',
lg: '720px',
xl: '960px'
};
const drawerSize = computed(() => props.size ?? DRAWER_SIZE_MAP[props.preset]);
const resolvedCancelText = computed(() => props.cancelText || $t('common.cancel'));
const resolvedConfirmText = computed(() => props.confirmText || $t('common.confirm'));
function handleCancel() {
visible.value = false;
emit('cancel');
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<ElDrawer
v-model="visible"
class="business-form-drawer"
:title="props.title"
:size="drawerSize"
:close-on-click-modal="false"
destroy-on-close
header-class="business-form-drawer__header"
body-class="business-form-drawer__body"
footer-class="business-form-drawer__footer"
v-bind="$attrs"
>
<ElScrollbar
v-if="props.scrollbar"
:height="props.bodyHeight"
class="business-form-drawer__scrollbar"
view-class="business-form-drawer__scrollbar-view"
>
<div v-loading="props.loading" class="business-form-drawer__content">
<slot />
</div>
</ElScrollbar>
<div v-else v-loading="props.loading" class="business-form-drawer__content">
<slot />
</div>
<template v-if="props.showFooter" #footer>
<slot name="footer" :close="handleCancel">
<ElSpace :size="16">
<ElButton @click="handleCancel">{{ resolvedCancelText }}</ElButton>
<ElButton
type="primary"
:loading="props.confirmLoading"
:disabled="props.confirmDisabled"
@click="handleConfirm"
>
{{ resolvedConfirmText }}
</ElButton>
</ElSpace>
</slot>
</template>
</ElDrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
defineOptions({
name: 'BusinessFormSection'
});
interface Props {
title: string;
}
defineProps<Props>();
</script>
<template>
<section class="business-form-section">
<h4 class="business-form-section__title">{{ title }}</h4>
<slot />
</section>
</template>
<style scoped></style>

View File

@@ -0,0 +1,111 @@
import { computed, defineComponent, ref } from 'vue';
import type { PropType } from 'vue';
import { ElButton, ElPopover } from 'element-plus';
import { $t } from '@/locales';
export type BusinessTableAction = {
key: string;
label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
disabled?: boolean;
onClick: () => void | Promise<void>;
};
export default defineComponent({
name: 'BusinessTableActionCell',
props: {
actions: {
type: Array as PropType<BusinessTableAction[]>,
required: true
}
},
setup(props) {
const popoverVisible = ref(false);
const directActions = computed(() => {
if (props.actions.length <= 2) {
return props.actions;
}
return props.actions.slice(0, 1);
});
const moreActions = computed(() => {
if (props.actions.length <= 2) {
return [];
}
return props.actions.slice(1);
});
async function handleAction(action: BusinessTableAction) {
if (action.disabled) {
return;
}
popoverVisible.value = false;
await action.onClick();
}
return () => (
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
{directActions.value.map(action => (
<ElButton
key={action.key}
plain
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-button"
onClick={() => handleAction(action)}
>
{action.label}
</ElButton>
))}
{moreActions.value.length > 0 && (
<ElPopover
v-model:visible={popoverVisible.value}
placement="bottom-end"
trigger="click"
width={120}
show-arrow={false}
>
{{
reference: () => (
<ElButton
plain
size="small"
class="business-table-action-button"
onClick={event => event.stopPropagation()}
>
<span class="inline-flex items-center gap-4px">
{$t('common.more')}
<icon-mdi-chevron-down class="text-14px" />
</span>
</ElButton>
),
default: () => (
<div class="business-table-action-menu">
{moreActions.value.map(action => (
<ElButton
key={action.key}
plain
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-menu__button"
onClick={() => handleAction(action)}
>
{action.label}
</ElButton>
))}
</div>
)
}}
</ElPopover>
)}
</div>
);
}
});

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { Placement } from 'element-plus';
import { twMerge } from 'tailwind-merge';
defineOptions({
name: 'ButtonIcon',
inheritAttrs: false
});
interface Props {
/** Button class */
class?: string;
/** Iconify icon name */
icon?: string;
/** Tooltip content */
tooltipContent?: string;
/** Tooltip placement */
tooltipPlacement?: Placement;
zIndex?: number;
}
const props = withDefaults(defineProps<Props>(), {
class: '',
icon: '',
tooltipContent: '',
tooltipPlacement: 'bottom',
zIndex: 98
});
const DEFAULT_CLASS = 'h-[36px] text-icon';
</script>
<template>
<ElTooltip :placement="tooltipPlacement" :content="tooltipContent" :z-index="zIndex" :disabled="!tooltipContent">
<ElButton text quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
<div class="flex-center gap-8px text-lg">
<slot>
<SvgIcon :icon="icon" />
</slot>
</div>
</ElButton>
</ElTooltip>
</template>
<style scoped></style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { TransitionPresets, useTransition } from '@vueuse/core';
defineOptions({
name: 'CountTo'
});
interface Props {
startValue?: number;
endValue?: number;
duration?: number;
autoplay?: boolean;
decimals?: number;
prefix?: string;
suffix?: string;
separator?: string;
decimal?: string;
useEasing?: boolean;
transition?: keyof typeof TransitionPresets;
}
const props = withDefaults(defineProps<Props>(), {
startValue: 0,
endValue: 2021,
duration: 1500,
autoplay: true,
decimals: 0,
prefix: '',
suffix: '',
separator: ',',
decimal: '.',
useEasing: true,
transition: 'linear'
});
const source = ref(props.startValue);
const transition = computed(() => (props.useEasing ? TransitionPresets[props.transition] : undefined));
const outputValue = useTransition(source, {
disabled: false,
duration: props.duration,
transition: transition.value
});
const value = computed(() => formatValue(outputValue.value));
function formatValue(num: number) {
const { decimals, decimal, separator, suffix, prefix } = props;
let number = num.toFixed(decimals);
number = String(number);
const x = number.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, `$1${separator}$2`);
}
}
return prefix + x1 + x2 + suffix;
}
async function start() {
await nextTick();
source.value = props.endValue;
}
watch(
[() => props.startValue, () => props.endValue],
() => {
if (props.autoplay) {
start();
}
},
{ immediate: true }
);
</script>
<template>
<span>{{ value }}</span>
</template>
<style scoped></style>

View File

@@ -0,0 +1,239 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
defineOptions({ name: 'CustomIconSelect' });
interface IconGroup {
label: string;
value: string;
icons: string[];
}
interface Props {
value: string;
icons?: string[];
iconGroups?: IconGroup[];
emptyIcon?: string;
title?: string;
placeholder?: string;
emptyDescription?: string;
pageSize?: number;
}
const props = withDefaults(defineProps<Props>(), {
icons: () => [],
iconGroups: () => [],
emptyIcon: 'mdi:apps',
title: '选择图标',
placeholder: '点击选择图标',
emptyDescription: '没有找到可选图标',
pageSize: 48
});
interface Emits {
(e: 'update:value', val: string): void;
}
const emit = defineEmits<Emits>();
const dialogVisible = ref(false);
const activeGroup = ref('all');
const currentPage = ref(1);
const modelValue = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
const selectedIcon = computed(() => modelValue.value || props.emptyIcon);
const normalizedGroups = computed<IconGroup[]>(() => {
if (props.iconGroups.length) {
const groups = props.iconGroups.map(group => ({
...group,
icons: Array.from(new Set(group.icons))
}));
return [
{
label: '全部',
value: 'all',
icons: Array.from(new Set(groups.flatMap(group => group.icons)))
},
...groups
];
}
return [
{
label: '全部',
value: 'all',
icons: Array.from(new Set(props.icons))
}
];
});
const currentGroup = computed(
() => normalizedGroups.value.find(group => group.value === activeGroup.value) ?? normalizedGroups.value[0]
);
const groupIcons = computed(() => currentGroup.value?.icons ?? []);
const pagedIcons = computed(() => {
const startIndex = (currentPage.value - 1) * props.pageSize;
return groupIcons.value.slice(startIndex, startIndex + props.pageSize);
});
watch(
normalizedGroups,
groups => {
if (!groups.some(group => group.value === activeGroup.value)) {
activeGroup.value = groups[0]?.value ?? 'all';
}
},
{ immediate: true }
);
watch(activeGroup, () => {
currentPage.value = 1;
});
function openDialog() {
activeGroup.value = normalizedGroups.value[0]?.value ?? 'all';
currentPage.value = 1;
dialogVisible.value = true;
}
function handleChange(iconItem: string) {
modelValue.value = iconItem;
dialogVisible.value = false;
}
</script>
<template>
<div class="custom-icon-select">
<div class="custom-icon-select__trigger" @click="openDialog">
<ElInput :model-value="modelValue" readonly :placeholder="placeholder">
<template #suffix>
<SvgIcon :icon="selectedIcon" class="text-22px" />
</template>
</ElInput>
</div>
<BusinessFormDialog
v-model="dialogVisible"
:title="title"
preset="lg"
width="720px"
:show-footer="false"
:scrollbar="false"
>
<div class="custom-icon-select__content">
<ElTabs v-model="activeGroup" class="custom-icon-select__tabs">
<ElTabPane
v-for="group in normalizedGroups"
:key="group.value"
:label="`${group.label} (${group.icons.length})`"
:name="group.value"
/>
</ElTabs>
<div v-if="pagedIcons.length" class="custom-icon-select__grid">
<button
v-for="iconItem in pagedIcons"
:key="iconItem"
type="button"
class="custom-icon-select__item"
:class="{ 'custom-icon-select__item--active': modelValue === iconItem }"
:title="iconItem"
@click="handleChange(iconItem)"
>
<SvgIcon :icon="iconItem" class="text-24px" />
<span class="custom-icon-select__name">{{ iconItem }}</span>
</button>
</div>
<ElEmpty v-else :description="emptyDescription" class="py-24px" />
<div v-if="groupIcons.length > props.pageSize" class="custom-icon-select__pagination">
<ElPagination
v-model:current-page="currentPage"
background
layout="prev, pager, next"
:page-size="props.pageSize"
:total="groupIcons.length"
/>
</div>
</div>
</BusinessFormDialog>
</div>
</template>
<style lang="scss" scoped>
.custom-icon-select__trigger {
cursor: pointer;
}
.custom-icon-select__content {
display: flex;
flex-direction: column;
gap: 16px;
}
.custom-icon-select__grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
min-height: 360px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.custom-icon-select__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-fill-color-blank);
padding: 12px 8px;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
}
.custom-icon-select__item:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
transform: translateY(-1px);
}
.custom-icon-select__item--active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
}
.custom-icon-select__name {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.custom-icon-select__pagination {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import WebSiteLink from './web-site-link.vue';
defineOptions({ name: 'GithubLink' });
interface Props {
/** github link */
link: string;
}
defineProps<Props>();
</script>
<template>
<WebSiteLink label="github地址" :link="link" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({ name: 'LookForward' });
</script>
<template>
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
<div class="flex text-400px text-primary">
<SvgIcon local-icon="expectation" />
</div>
<slot>
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3>
</slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
defineOptions({ name: 'SoybeanAvatar' });
</script>
<template>
<div class="size-72px flex-center rd-1/2 bg-primary text-24px text-white font-600">CN</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Icon } from '@iconify/vue';
defineOptions({ name: 'SvgIcon', inheritAttrs: false });
/**
* Props
*
* - Support iconify and local svg icon
* - If icon and localIcon are passed at the same time, localIcon will be rendered first
*/
interface Props {
/** Iconify icon name */
icon?: string;
/** Local svg icon name */
localIcon?: string;
}
const props = defineProps<Props>();
const attrs = useAttrs();
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}));
const symbolId = computed(() => {
const { VITE_ICON_LOCAL_PREFIX: prefix } = import.meta.env;
const defaultLocalIcon = 'no-icon';
const icon = props.localIcon || defaultLocalIcon;
return `#${prefix}-${icon}`;
});
/** If localIcon is passed, render localIcon first */
const renderLocalIcon = computed(() => props.localIcon || !props.icon);
</script>
<template>
<template v-if="renderLocalIcon">
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
<use :xlink:href="symbolId" fill="currentColor" />
</svg>
</template>
<template v-else>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
</template>
</template>
<style scoped></style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import type { FormInstance } from 'element-plus';
import { $t } from '@/locales';
defineOptions({ name: 'TableSearchPanel' });
interface Props {
model: object;
rules?: Record<string, App.Global.FormRule | App.Global.FormRule[]>;
formRef?: ((instance: FormInstance | null) => void) | null;
labelWidth?: string | number;
labelPosition?: 'left' | 'right' | 'top';
gutter?: number;
actionColLg?: number;
actionColMd?: number;
actionColSm?: number;
disabled?: boolean;
defaultExpanded?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rules: undefined,
formRef: null,
labelWidth: 80,
labelPosition: 'right',
gutter: 24,
actionColLg: 6,
actionColMd: 24,
actionColSm: 24,
disabled: false,
defaultExpanded: false
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const slots = useSlots();
const expanded = ref(props.defaultExpanded);
const hasExtra = computed(() => Boolean(slots.extra));
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
function toggleExpanded() {
if (!hasExtra.value) {
return;
}
expanded.value = !expanded.value;
}
function handleFormRef(instance: Element | ComponentPublicInstance | null) {
props.formRef?.(instance as FormInstance | null);
}
</script>
<template>
<ElCard class="card-wrapper">
<ElForm
:ref="handleFormRef"
:model="props.model"
:rules="props.rules"
:label-position="props.labelPosition"
:label-width="props.labelWidth"
@submit.prevent
@keyup.enter="handleSearch"
>
<ElRow :gutter="props.gutter">
<slot />
<ElCol
v-if="!hasExtra"
class="table-search-panel__action-col"
:lg="props.actionColLg"
:md="props.actionColMd"
:sm="props.actionColSm"
>
<ElSpace class="w-full justify-end" alignment="end">
<ElButton :disabled="props.disabled" @click="handleReset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</ElButton>
<ElButton type="primary" plain :disabled="props.disabled" @click="handleSearch">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</ElButton>
</ElSpace>
</ElCol>
</ElRow>
<ElRow v-if="hasExtra && expanded" :gutter="props.gutter">
<slot name="extra" />
<ElCol
class="table-search-panel__action-col"
:lg="props.actionColLg"
:md="props.actionColMd"
:sm="props.actionColSm"
>
<ElSpace class="w-full justify-end" alignment="end">
<ElButton circle :disabled="props.disabled" @click="toggleExpanded">
<icon-mdi-chevron-double-up class="text-16px" />
</ElButton>
<ElButton :disabled="props.disabled" @click="handleReset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</ElButton>
<ElButton type="primary" plain :disabled="props.disabled" @click="handleSearch">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</ElButton>
</ElSpace>
</ElCol>
</ElRow>
<div v-if="hasExtra && !expanded" class="flex justify-end">
<ElSpace>
<ElButton circle :disabled="props.disabled" @click="toggleExpanded">
<icon-mdi-chevron-double-down class="text-16px" />
</ElButton>
<ElButton :disabled="props.disabled" @click="handleReset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</ElButton>
<ElButton type="primary" plain :disabled="props.disabled" @click="handleSearch">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</ElButton>
</ElSpace>
</div>
</ElForm>
</ElCard>
</template>
<style scoped>
.table-search-panel__action-col {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,648 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getPaletteColorByNumber } from '@sa/color';
defineOptions({ name: 'WaveBg' });
interface Props {
/** Theme color */
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
const accentColor = computed(() => getPaletteColorByNumber(props.themeColor, 300));
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
const deepColor = computed(() => getPaletteColorByNumber(props.themeColor, 700));
</script>
<template>
<div
class="power-bg pointer-events-none absolute-lt z-1 size-full overflow-hidden"
:style="{
'--power-light': lightColor,
'--power-accent': accentColor,
'--power-dark': darkColor,
'--power-deep': deepColor
}"
>
<div class="power-bg__base"></div>
<div class="power-bg__aurora power-bg__aurora--left"></div>
<div class="power-bg__aurora power-bg__aurora--right"></div>
<div class="power-bg__curtain power-bg__curtain--left"></div>
<div class="power-bg__curtain power-bg__curtain--right"></div>
<div class="power-bg__grid"></div>
<div class="power-bg__scan"></div>
<div class="power-bg__halo power-bg__halo--top"></div>
<div class="power-bg__halo power-bg__halo--bottom"></div>
<div class="power-bg__particles">
<span class="power-bg__particle power-bg__particle--1"></span>
<span class="power-bg__particle power-bg__particle--2"></span>
<span class="power-bg__particle power-bg__particle--3"></span>
<span class="power-bg__particle power-bg__particle--4"></span>
<span class="power-bg__particle power-bg__particle--5"></span>
<span class="power-bg__particle power-bg__particle--6"></span>
</div>
<div class="power-bg__blob power-bg__blob--top">
<svg height="1337" width="1337" viewBox="0 0 1337 1337">
<defs>
<path
id="power-blob-top"
fill-rule="evenodd"
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 0,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,0 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
/>
<linearGradient id="power-gradient-top" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
<stop offset="0" :stop-color="lightColor" stop-opacity="0.52" />
<stop offset="1" :stop-color="darkColor" stop-opacity="0.16" />
</linearGradient>
</defs>
<use xlink:href="#power-blob-top" fill="url(#power-gradient-top)" />
</svg>
</div>
<div class="power-bg__blob power-bg__blob--bottom">
<svg height="896" width="968" viewBox="0 0 968 896">
<defs>
<path
id="power-blob-bottom"
fill-rule="evenodd"
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 0,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,0 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
/>
<linearGradient id="power-gradient-bottom" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" :stop-color="darkColor" stop-opacity="0.18" />
<stop offset="1" :stop-color="lightColor" stop-opacity="0.5" />
</linearGradient>
</defs>
<use xlink:href="#power-blob-bottom" fill="url(#power-gradient-bottom)" />
</svg>
</div>
<svg class="power-bg__panel" viewBox="0 0 1440 900" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="power-arc-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" :stop-color="lightColor" stop-opacity="0.42" />
<stop offset="45%" :stop-color="accentColor" stop-opacity="0.22" />
<stop offset="100%" :stop-color="deepColor" stop-opacity="0" />
</linearGradient>
<linearGradient id="power-dash-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" :stop-color="lightColor" stop-opacity="0" />
<stop offset="50%" :stop-color="lightColor" stop-opacity="0.52" />
<stop offset="100%" :stop-color="accentColor" stop-opacity="0" />
</linearGradient>
<linearGradient id="power-skyline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="lightColor" stop-opacity="0.16" />
<stop offset="100%" :stop-color="deepColor" stop-opacity="0.02" />
</linearGradient>
</defs>
<g class="power-bg__panel-arcs">
<path d="M-120 770 C180 570, 430 562, 706 650 S1162 890, 1568 778" />
<path d="M90 120 C368 -4, 706 -2, 988 124 S1362 368, 1536 314" />
<path d="M804 -80 C1012 92, 1130 236, 1160 442 S1110 786, 948 990" />
<path d="M28 520 C214 444, 380 426, 592 468 S950 618, 1188 592 1380 510, 1502 438" />
</g>
<g class="power-bg__panel-dashes">
<path d="M104 214 H324" />
<path d="M104 242 H248" />
<path d="M1078 180 H1298" />
<path d="M1078 208 H1368" />
<path d="M1118 724 H1328" />
</g>
<g class="power-bg__skyline">
<path
d="M0 900 V758 H94 V724 H142 V686 H196 V730 H258 V654 H318 V698 H364 V622 H422 V746 H486 V670 H554 V714 H612 V636 H682 V728 H746 V592 H804 V656 H852 V618 H910 V744 H978 V668 H1042 V710 H1100 V632 H1166 V692 H1236 V606 H1296 V744 H1362 V694 H1410 V760 H1440 V900 Z"
/>
</g>
<g class="power-bg__panel-rings">
<circle cx="1148" cy="642" r="96" />
<circle cx="1148" cy="642" r="156" />
</g>
</svg>
<svg class="power-bg__network" viewBox="0 0 1440 900" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="power-line-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" :stop-color="deepColor" stop-opacity="0" />
<stop offset="35%" :stop-color="accentColor" stop-opacity="0.16" />
<stop offset="65%" :stop-color="lightColor" stop-opacity="0.32" />
<stop offset="100%" :stop-color="deepColor" stop-opacity="0" />
</linearGradient>
<linearGradient id="power-flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" :stop-color="accentColor" stop-opacity="0" />
<stop offset="50%" :stop-color="lightColor" stop-opacity="0.7" />
<stop offset="100%" :stop-color="accentColor" stop-opacity="0" />
</linearGradient>
<filter id="power-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g class="power-bg__lines">
<path d="M50 620 C220 590, 260 520, 420 530 S710 610, 890 540 1170 430, 1390 470" />
<path d="M120 710 C280 660, 390 680, 560 630 S840 500, 1010 530 1240 650, 1410 610" />
<path d="M180 250 C330 210, 420 260, 580 240 S860 150, 1010 180 1210 320, 1370 280" />
<path d="M310 210 L310 576" />
<path d="M780 178 L780 618" />
<path d="M1188 164 L1188 648" />
</g>
<g class="power-bg__flows">
<path d="M50 620 C220 590, 260 520, 420 530 S710 610, 890 540 1170 430, 1390 470" />
<path d="M120 710 C280 660, 390 680, 560 630 S840 500, 1010 530 1240 650, 1410 610" />
<path d="M180 250 C330 210, 420 260, 580 240 S860 150, 1010 180 1210 320, 1370 280" />
</g>
<g class="power-bg__nodes" filter="url(#power-glow)">
<circle cx="240" cy="575" r="4" />
<circle cx="430" cy="530" r="5" />
<circle cx="700" cy="585" r="4" />
<circle cx="1015" cy="530" r="5" />
<circle cx="1180" cy="446" r="4" />
<circle cx="355" cy="230" r="3" />
<circle cx="825" cy="176" r="3" />
<circle cx="310" cy="576" r="4" />
<circle cx="780" cy="618" r="4" />
<circle cx="1188" cy="648" r="6" />
</g>
</svg>
</div>
</template>
<style scoped>
.power-bg {
--power-light: #8fd4ff;
--power-accent: #4da8ff;
--power-dark: #2765d2;
--power-deep: #173a82;
}
.power-bg__base,
.power-bg__aurora,
.power-bg__curtain,
.power-bg__grid,
.power-bg__scan,
.power-bg__halo,
.power-bg__particles,
.power-bg__panel,
.power-bg__network,
.power-bg__blob {
position: absolute;
inset: 0;
}
.power-bg__base {
background:
radial-gradient(circle at 18% 22%, color-mix(in srgb, var(--power-light) 26%, transparent) 0, transparent 32%),
radial-gradient(circle at 82% 16%, color-mix(in srgb, var(--power-accent) 20%, transparent) 0, transparent 28%),
radial-gradient(circle at 74% 72%, color-mix(in srgb, var(--power-dark) 14%, transparent) 0, transparent 34%),
linear-gradient(135deg, rgb(255 255 255 / 20%) 0, transparent 28%, transparent 72%, rgb(255 255 255 / 8%) 100%);
animation: powerPulse 16s ease-in-out infinite alternate;
}
.power-bg__aurora {
border-radius: 50%;
filter: blur(42px);
opacity: 0.42;
mix-blend-mode: screen;
animation: powerAurora 18s ease-in-out infinite alternate;
}
.power-bg__aurora--left {
top: 8%;
left: -10%;
width: 42%;
height: 52%;
background:
linear-gradient(145deg, color-mix(in srgb, var(--power-light) 30%, transparent), transparent 60%),
radial-gradient(circle at 30% 35%, color-mix(in srgb, var(--power-accent) 26%, transparent), transparent 64%);
}
.power-bg__aurora--right {
right: -6%;
bottom: -8%;
width: 44%;
height: 48%;
background:
linear-gradient(200deg, color-mix(in srgb, var(--power-light) 18%, transparent), transparent 62%),
radial-gradient(circle at 52% 42%, color-mix(in srgb, var(--power-dark) 26%, transparent), transparent 68%);
animation-duration: 22s;
}
.power-bg__curtain {
filter: blur(12px);
opacity: 0.48;
mix-blend-mode: screen;
animation: powerCurtain 20s ease-in-out infinite alternate;
}
.power-bg__curtain--left {
left: -8%;
top: 0;
width: 34%;
height: 100%;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--power-light) 24%, transparent) 0,
color-mix(in srgb, var(--power-accent) 16%, transparent) 18%,
transparent 68%
);
mask-image: linear-gradient(180deg, transparent 0, rgb(0 0 0 / 72%) 14%, rgb(0 0 0 / 78%) 88%, transparent 100%);
}
.power-bg__curtain--right {
right: -10%;
top: 6%;
width: 38%;
height: 86%;
background: linear-gradient(
270deg,
color-mix(in srgb, var(--power-light) 22%, transparent) 0,
color-mix(in srgb, var(--power-dark) 14%, transparent) 20%,
transparent 68%
);
mask-image: linear-gradient(180deg, transparent 0, rgb(0 0 0 / 58%) 18%, rgb(0 0 0 / 76%) 84%, transparent 100%);
animation-duration: 24s;
}
.power-bg__grid {
opacity: 0.18;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--power-deep) 10%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--power-deep) 8%, transparent) 1px, transparent 1px);
background-size: 88px 88px;
mask-image: radial-gradient(circle at center, rgb(0 0 0 / 68%) 0, rgb(0 0 0 / 18%) 58%, transparent 100%);
}
.power-bg__scan {
opacity: 0.2;
background: linear-gradient(
118deg,
transparent 0 36%,
color-mix(in srgb, var(--power-light) 7%, transparent) 43%,
color-mix(in srgb, var(--power-light) 16%, transparent) 49%,
color-mix(in srgb, var(--power-light) 7%, transparent) 56%,
transparent 66%
);
transform: translateX(-18%);
animation: powerSweep 18s ease-in-out infinite;
}
.power-bg__particles {
opacity: 0.92;
}
.power-bg__particle {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background: color-mix(in srgb, var(--power-light) 76%, white 24%);
box-shadow: 0 0 22px color-mix(in srgb, var(--power-light) 28%, transparent);
animation: powerParticle 10s ease-in-out infinite;
}
.power-bg__particle::after {
content: '';
position: absolute;
inset: -34px -2px;
background: linear-gradient(180deg, color-mix(in srgb, var(--power-light) 14%, transparent), transparent 68%);
opacity: 0.75;
}
.power-bg__particle--1 {
top: 18%;
left: 16%;
animation-delay: -1s;
}
.power-bg__particle--2 {
top: 28%;
right: 18%;
width: 8px;
height: 8px;
animation-delay: -4s;
}
.power-bg__particle--3 {
top: 58%;
left: 24%;
width: 12px;
height: 12px;
animation-delay: -2s;
}
.power-bg__particle--4 {
top: 68%;
right: 26%;
width: 7px;
height: 7px;
animation-delay: -7s;
}
.power-bg__particle--5 {
top: 40%;
left: 72%;
width: 9px;
height: 9px;
animation-delay: -5s;
}
.power-bg__particle--6 {
top: 78%;
left: 62%;
width: 8px;
height: 8px;
animation-delay: -8s;
}
.power-bg__halo {
border-radius: 999px;
filter: blur(30px);
opacity: 0.26;
}
.power-bg__halo--top {
top: 9%;
left: 18%;
width: 36%;
height: 14%;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--power-light) 24%, transparent), transparent);
}
.power-bg__halo--bottom {
right: 8%;
bottom: 10%;
width: 32%;
height: 16%;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--power-accent) 22%, transparent),
transparent 72%
);
}
.power-bg__blob {
animation: powerFloat 18s ease-in-out infinite;
}
.power-bg__blob--top {
top: -900px;
right: -300px;
}
.power-bg__blob--bottom {
bottom: -400px;
left: -200px;
animation-duration: 22s;
animation-direction: alternate-reverse;
}
.power-bg__panel {
width: 100%;
height: 100%;
opacity: 0.84;
}
.power-bg__panel-arcs path,
.power-bg__panel-dashes path,
.power-bg__skyline path,
.power-bg__panel-rings circle {
fill: none;
stroke-linecap: round;
}
.power-bg__panel-arcs path {
stroke: url(#power-arc-gradient);
stroke-width: 2.2;
opacity: 0.9;
}
.power-bg__panel-dashes path {
stroke: url(#power-dash-gradient);
stroke-width: 1.6;
stroke-dasharray: 10 12;
opacity: 0.72;
}
.power-bg__skyline path {
fill: url(#power-skyline-gradient);
stroke: color-mix(in srgb, var(--power-light) 24%, transparent);
stroke-width: 1;
opacity: 0.42;
}
.power-bg__panel-rings circle {
stroke: color-mix(in srgb, var(--power-light) 16%, transparent);
stroke-width: 1;
opacity: 0.18;
animation: powerRing 15s ease-in-out infinite;
}
.power-bg__panel-rings circle:nth-child(2) {
animation-delay: -4s;
}
.power-bg__network {
width: 100%;
height: 100%;
opacity: 0.92;
}
.power-bg__lines path {
fill: none;
stroke: url(#power-line-gradient);
stroke-width: 2;
opacity: 0.84;
}
.power-bg__flows path {
fill: none;
stroke: url(#power-flow-gradient);
stroke-width: 3.2;
stroke-linecap: round;
stroke-dasharray: 22 246;
animation: powerFlow 13s linear infinite;
}
.power-bg__flows path:nth-child(2) {
animation-duration: 15s;
animation-delay: -4s;
}
.power-bg__flows path:nth-child(3) {
stroke-width: 2.6;
animation-duration: 18s;
animation-delay: -7s;
}
.power-bg__nodes circle {
fill: color-mix(in srgb, var(--power-light) 92%, white 8%);
opacity: 0.78;
animation: powerBlink 7s ease-in-out infinite;
}
.power-bg__nodes circle:nth-child(2n) {
animation-duration: 9s;
}
.power-bg__nodes circle:nth-child(3n) {
animation-delay: -2s;
}
@keyframes powerFloat {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(-18px, 14px, 0) scale(1.03);
}
100% {
transform: translate3d(12px, -10px, 0) scale(0.98);
}
}
@keyframes powerPulse {
0% {
opacity: 0.9;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1.04);
}
}
@keyframes powerAurora {
0%,
100% {
opacity: 0.28;
transform: translate3d(0, 0, 0) scale(0.98);
}
50% {
opacity: 0.48;
transform: translate3d(18px, -12px, 0) scale(1.04);
}
}
@keyframes powerCurtain {
0% {
opacity: 0.32;
transform: translate3d(0, 0, 0);
}
100% {
opacity: 0.52;
transform: translate3d(18px, 0, 0);
}
}
@keyframes powerSweep {
0% {
opacity: 0.06;
transform: translateX(-18%);
}
50% {
opacity: 0.2;
}
100% {
opacity: 0.06;
transform: translateX(18%);
}
}
@keyframes powerParticle {
0%,
100% {
opacity: 0.48;
transform: translate3d(0, 0, 0) scale(0.94);
}
50% {
opacity: 0.95;
transform: translate3d(0, -10px, 0) scale(1.08);
}
}
@keyframes powerFlow {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -268;
}
}
@keyframes powerRing {
0%,
100% {
opacity: 0.08;
transform: scale(0.99);
}
50% {
opacity: 0.22;
transform: scale(1.02);
}
}
@keyframes powerBlink {
0%,
100% {
opacity: 0.35;
transform: scale(0.96);
}
50% {
opacity: 0.82;
transform: scale(1.08);
}
}
@media (max-width: 640px) {
.power-bg__blob--top {
top: -1170px;
right: -100px;
}
.power-bg__blob--bottom {
bottom: -760px;
left: -100px;
}
.power-bg__grid {
background-size: 56px 56px;
}
.power-bg__curtain {
opacity: 0.34;
}
.power-bg__particles {
opacity: 0.7;
}
.power-bg__network {
opacity: 0.68;
}
.power-bg__panel {
opacity: 0.52;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getPaletteColorByNumber } from '@sa/color';
defineOptions({ name: 'WaveBg' });
interface Props {
/** Theme color */
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
</script>
<template>
<div class="absolute-lt z-1 size-full overflow-hidden">
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
<svg height="1337" width="1337">
<defs>
<path
id="path-1"
opacity="1"
fill-rule="evenodd"
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
/>
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
</g>
</svg>
</div>
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
<svg height="896" width="967.8852157128662">
<defs>
<path
id="path-2"
opacity="1"
fill-rule="evenodd"
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
/>
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
</g>
</svg>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
defineOptions({ name: 'WebSiteLink' });
interface Props {
/** Web site name */
label: string;
/** Web site link */
link: string;
}
defineProps<Props>();
</script>
<template>
<p>
<span>{{ label }}</span>
<a class="text-blue-500" :href="link" target="#">
{{ link }}
</a>
</p>
</template>
<style scoped></style>

55
src/constants/app.ts Normal file
View File

@@ -0,0 +1,55 @@
import { transformRecordToOption } from '@/utils/common';
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.themeSchema.light',
dark: 'theme.themeSchema.dark',
auto: 'theme.themeSchema.auto'
};
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> = {
'pwd-login': 'page.login.pwdLogin.title',
'reset-pwd': 'page.login.resetPwd.title'
};
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
vertical: 'theme.layoutMode.vertical',
'vertical-mix': 'theme.layoutMode.vertical-mix',
horizontal: 'theme.layoutMode.horizontal',
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
};
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.scrollMode.wrapper',
content: 'theme.scrollMode.content'
};
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.tab.mode.chrome',
button: 'theme.tab.mode.button'
};
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.page.mode.fade-slide',
fade: 'theme.page.mode.fade',
'fade-bottom': 'theme.page.mode.fade-bottom',
'fade-scale': 'theme.page.mode.fade-scale',
'zoom-fade': 'theme.page.mode.zoom-fade',
'zoom-out': 'theme.page.mode.zoom-out',
none: 'theme.page.mode.none'
};
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
export const DARK_CLASS = 'dark';

69
src/constants/business.ts Normal file
View File

@@ -0,0 +1,69 @@
import { transformRecordToOption } from '@/utils/common';
export const enableStatusRecord: Record<Api.Common.EnableStatus, App.I18n.I18nKey> = {
'1': 'page.system.common.status.enable',
'2': 'page.system.common.status.disable'
};
export const enableStatusOptions = transformRecordToOption(enableStatusRecord);
export const commonStatusRecord: Record<Api.SystemManage.CommonStatus, App.I18n.I18nKey> = {
0: 'page.system.common.status.enable',
1: 'page.system.common.status.disable'
};
export const commonStatusOptions = [
{ value: 0, label: commonStatusRecord[0] },
{ value: 1, label: commonStatusRecord[1] }
] satisfies CommonType.Option<Api.SystemManage.CommonStatus, App.I18n.I18nKey>[];
export const dictStatusRecord: Record<'0' | '1', App.I18n.I18nKey> = {
'0': 'page.system.common.status.enable',
'1': 'page.system.common.status.disable'
};
export const dictStatusOptions = [
{ value: 0, label: dictStatusRecord['0'] },
{ value: 1, label: dictStatusRecord['1'] }
] satisfies CommonType.Option<Api.Dict.DictStatus, App.I18n.I18nKey>[];
export const userGenderRecord: Record<Api.SystemManage.UserGender, App.I18n.I18nKey> = {
0: 'page.system.user.gender.unknown',
1: 'page.system.user.gender.male',
2: 'page.system.user.gender.female'
};
export const userGenderOptions = transformRecordToOption(userGenderRecord);
export const menuTypeRecord: Record<Api.SystemManage.MenuType, App.I18n.I18nKey> = {
1: 'page.system.menu.type.directory',
2: 'page.system.menu.type.menu',
3: 'page.system.menu.type.button'
};
export const menuTypeOptions = [
{ value: 1, label: menuTypeRecord[1] },
{ value: 2, label: menuTypeRecord[2] },
{ value: 3, label: menuTypeRecord[3] }
] satisfies CommonType.Option<Api.SystemManage.MenuType, App.I18n.I18nKey>[];
export const roleTypeRecord: Record<Api.SystemManage.RoleType, App.I18n.I18nKey> = {
1: 'page.system.role.type.system',
2: 'page.system.role.type.custom'
};
export const roleTypeOptions = [
{ value: 1, label: roleTypeRecord[1] },
{ value: 2, label: roleTypeRecord[2] }
] satisfies CommonType.Option<Api.SystemManage.RoleType, App.I18n.I18nKey>[];
export const menuRouteKindRecord: Record<Api.SystemManage.MenuRouteKind, App.I18n.I18nKey> = {
dir: 'page.system.menu.routeKindEnum.directory',
view: 'page.system.menu.routeKindEnum.view',
single: 'page.system.menu.routeKindEnum.single',
iframe: 'page.system.menu.routeKindEnum.iframe',
external: 'page.system.menu.routeKindEnum.external',
redirect: 'page.system.menu.routeKindEnum.redirect'
};
export const menuRouteKindOptions = transformRecordToOption(menuRouteKindRecord);

8
src/constants/common.ts Normal file
View File

@@ -0,0 +1,8 @@
import { transformRecordToOption } from '@/utils/common';
export const yesOrNoRecord: Record<CommonType.YesOrNo, App.I18n.I18nKey> = {
Y: 'common.yesOrNo.yes',
N: 'common.yesOrNo.no'
};
export const yesOrNoOptions = transformRecordToOption(yesOrNoRecord);

8
src/constants/map-sdk.ts Normal file
View File

@@ -0,0 +1,8 @@
/** baidu map sdk url */
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
/** Amap sdk url */
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
/** tencent sdk url */
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';

25
src/constants/reg.ts Normal file
View File

@@ -0,0 +1,25 @@
export const REG_USER_NAME = /^[\u4E00-\u9FA5a-zA-Z0-9_-]{4,16}$/;
/** Phone reg */
export const REG_PHONE =
/^[1](([3][0-9])|([4][01456789])|([5][012356789])|([6][2567])|([7][0-8])|([8][0-9])|([9][012356789]))[0-9]{8}$/;
/**
* Password reg
*
* 6-18 characters, including letters, numbers, and underscores
*/
export const REG_PWD = /^\w{6,18}$/;
/** Email reg */
export const REG_EMAIL = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/;
/** Six digit code reg */
export const REG_CODE_SIX = /^\d{6}$/;
/** Four digit code reg */
export const REG_CODE_FOUR = /^\d{4}$/;
/** Url reg */
export const REG_URL =
/(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/;

5
src/constants/service.ts Normal file
View File

@@ -0,0 +1,5 @@
/** Web 端网关前缀 */
export const WEB_SERVICE_PREFIX = '/admin-api';
/** System 模块接口前缀 */
export const SYSTEM_SERVICE_PREFIX = `${WEB_SERVICE_PREFIX}/system`;

7
src/enum/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export enum SetupStoreId {
App = 'app-store',
Theme = 'theme-store',
Auth = 'auth-store',
Route = 'route-store',
Tab = 'tab-store'
}

View File

@@ -0,0 +1,21 @@
import { useAuthStore } from '@/store/modules/auth';
export function useAuth() {
const authStore = useAuthStore();
function hasAuth(codes: string | string[]) {
if (!authStore.isLogin) {
return false;
}
if (typeof codes === 'string') {
return authStore.userInfo.buttons.includes(codes);
}
return codes.some(code => authStore.userInfo.buttons.includes(code));
}
return {
hasAuth
};
}

View File

@@ -0,0 +1,71 @@
import { computed } from 'vue';
import { useCountDown, useLoading } from '@sa/hooks';
import { REG_PHONE } from '@/constants/reg';
import { $t } from '@/locales';
export function useCaptcha() {
const { loading, startLoading, endLoading } = useLoading();
const { count, start, stop, isCounting } = useCountDown(10);
const label = computed(() => {
let text = $t('page.login.codeLogin.getCode');
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value });
if (loading.value) {
text = '';
}
if (isCounting.value) {
text = countingLabel;
}
return text;
});
function isPhoneValid(phone: string) {
if (phone.trim() === '') {
window.$message?.error?.($t('form.phone.required'));
return false;
}
if (!REG_PHONE.test(phone)) {
window.$message?.error?.($t('form.phone.invalid'));
return false;
}
return true;
}
async function getCaptcha(phone: string) {
const valid = isPhoneValid(phone);
if (!valid || loading.value) {
return;
}
startLoading();
// request
await new Promise(resolve => {
setTimeout(resolve, 500);
});
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'));
start();
endLoading();
}
return {
label,
start,
stop,
isCounting,
loading,
getCaptcha
};
}

239
src/hooks/common/echarts.ts Normal file
View File

@@ -0,0 +1,239 @@
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import * as echarts from 'echarts/core';
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
import type {
BarSeriesOption,
GaugeSeriesOption,
LineSeriesOption,
PictorialBarSeriesOption,
PieSeriesOption,
RadarSeriesOption,
ScatterSeriesOption
} from 'echarts/charts';
import {
DatasetComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent
} from 'echarts/components';
import type {
DatasetComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
ToolboxComponentOption,
TooltipComponentOption
} from 'echarts/components';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import { useThemeStore } from '@/store/modules/theme';
export type ECOption = echarts.ComposeOption<
| BarSeriesOption
| LineSeriesOption
| PieSeriesOption
| ScatterSeriesOption
| PictorialBarSeriesOption
| RadarSeriesOption
| GaugeSeriesOption
| TitleComponentOption
| LegendComponentOption
| TooltipComponentOption
| GridComponentOption
| ToolboxComponentOption
| DatasetComponentOption
>;
echarts.use([
TitleComponent,
LegendComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
ToolboxComponent,
BarChart,
LineChart,
PieChart,
ScatterChart,
PictorialBarChart,
RadarChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer
]);
interface ChartHooks {
onRender?: (chart: echarts.ECharts) => void | Promise<void>;
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>;
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>;
}
/**
* use echarts
*
* @param optionsFactory echarts options factory function
* @param darkMode dark mode
*/
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
const scope = effectScope();
const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: echarts.ECharts | null = null;
const chartOptions: T = optionsFactory();
const {
onRender = instance => {
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)';
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)';
instance.showLoading({
color: themeStore.themeColor,
textColor,
fontSize: 14,
maskColor
});
},
onUpdated = instance => {
instance.hideLoading();
},
onDestroy
} = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart);
}
/**
* update chart options
*
* @param callback callback function
*/
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts);
if (isRendered()) {
chart?.clear();
}
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
await onUpdated?.(chart!);
}
function setOptions(options: T) {
chart?.setOption(options);
}
/** render chart */
async function render() {
if (!isRendered()) {
const chartTheme = darkMode.value ? 'dark' : 'light';
await nextTick();
chart = echarts.init(domRef.value, chartTheme);
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' });
await onRender?.(chart);
}
}
/** resize chart */
function resize() {
chart?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart) return;
await onDestroy?.(chart);
chart?.dispose();
chart = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
await render();
await onUpdated?.(chart!);
}
/**
* render chart by size
*
* @param w width
* @param h height
*/
async function renderChartBySize(w: number, h: number) {
initialSize.width = w;
initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
}
// render chart
await render();
if (chart) {
await onUpdated?.(chart);
}
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
});
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef,
updateOptions,
setOptions
};
}

97
src/hooks/common/form.ts Normal file
View File

@@ -0,0 +1,97 @@
import { ref, toValue } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg';
import { $t } from '@/locales';
export function useFormRules() {
const patternRules = {
userName: {
pattern: REG_USER_NAME,
message: $t('form.userName.invalid'),
trigger: 'change'
},
phone: {
pattern: REG_PHONE,
message: $t('form.phone.invalid'),
trigger: 'change'
},
pwd: {
pattern: REG_PWD,
message: $t('form.pwd.invalid'),
trigger: 'change'
},
code: {
pattern: REG_CODE_SIX,
message: $t('form.code.invalid'),
trigger: 'change'
},
email: {
pattern: REG_EMAIL,
message: $t('form.email.invalid'),
trigger: 'change'
}
} satisfies Record<string, App.Global.FormRule>;
const formRules = {
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
code: [createRequiredRule($t('form.code.required')), patternRules.code],
email: [createRequiredRule($t('form.email.required')), patternRules.email]
} satisfies Record<string, App.Global.FormRule[]>;
/** the default required rule */
const defaultRequiredRule = createRequiredRule($t('form.required'));
function createRequiredRule(message: string): App.Global.FormRule {
return {
required: true,
message
};
}
/** create a rule for confirming the password */
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
const confirmPwdRule: App.Global.FormRule[] = [
{ required: true, message: $t('form.confirmPwd.required') },
{
asyncValidator: (rule, value) => {
if (value.trim() !== '' && value !== toValue(pwd)) {
return Promise.reject(rule.message);
}
return Promise.resolve();
},
message: $t('form.confirmPwd.invalid'),
trigger: 'input'
}
];
return confirmPwdRule;
}
return {
patternRules,
formRules,
defaultRequiredRule,
createRequiredRule,
createConfirmPwdRule
};
}
export function useForm() {
const formRef = ref<FormInstance | null>(null);
async function validate() {
await formRef.value?.validate();
}
async function restoreValidation() {
formRef.value?.resetFields();
}
return {
formRef,
validate,
restoreValidation
};
}

10
src/hooks/common/icon.ts Normal file
View File

@@ -0,0 +1,10 @@
import { useSvgIconRender } from '@sa/hooks';
import SvgIcon from '@/components/custom/svg-icon.vue';
export function useSvgIcon() {
const { SvgIconVNode } = useSvgIconRender(SvgIcon);
return {
SvgIconVNode
};
}

120
src/hooks/common/router.ts Normal file
View File

@@ -0,0 +1,120 @@
import { useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { router as globalRouter } from '@/router';
/**
* Router push
*
* Jump to the specified route, it can replace function router.push
*
* @param inSetup Whether is in vue script setup
*/
export function useRouterPush(inSetup = true) {
const router = inSetup ? useRouter() : globalRouter;
const route = globalRouter.currentRoute;
const routerPush = router.push;
const routerBack = router.back;
interface RouterPushOptions {
query?: Record<string, string>;
params?: Record<string, string>;
}
async function routerPushByKey(key: RouteKey, options?: RouterPushOptions) {
const { query, params } = options || {};
const routeLocation: RouteLocationRaw = {
name: key
};
if (Object.keys(query || {}).length) {
routeLocation.query = query;
}
if (Object.keys(params || {}).length) {
routeLocation.params = params;
}
return routerPush(routeLocation);
}
function routerPushByKeyWithMetaQuery(key: RouteKey) {
const allRoutes = router.getRoutes();
const meta = allRoutes.find(item => item.name === key)?.meta || null;
const query: Record<string, string> = {};
meta?.query?.forEach(item => {
query[item.key] = item.value;
});
return routerPushByKey(key, { query });
}
async function toHome() {
return routerPushByKey('root');
}
/**
* Navigate to login page
*
* @param loginModule The login module
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
*/
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
const module = loginModule || 'pwd-login';
const options: RouterPushOptions = {
params: {
module
}
};
const redirect = redirectUrl || route.value.fullPath;
options.query = {
redirect
};
return routerPushByKey('login', options);
}
/**
* Toggle login module
*
* @param module
*/
async function toggleLoginModule(module: UnionKey.LoginModule) {
const query = route.value.query as Record<string, string>;
return routerPushByKey('login', { query, params: { module } });
}
/**
* Redirect from login
*
* @param [needRedirect=true] Whether to redirect after login. Default is `true`
*/
async function redirectFromLogin(needRedirect = true) {
const redirect = route.value.query?.redirect as string;
if (needRedirect && redirect) {
await routerPush(redirect);
} else {
await toHome();
}
}
return {
routerPush,
routerBack,
routerPushByKey,
routerPushByKeyWithMetaQuery,
toLogin,
toggleLoginModule,
redirectFromLogin
};
}

318
src/hooks/common/table.ts Normal file
View File

@@ -0,0 +1,318 @@
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue';
import type { Ref } from 'vue';
import type { PaginationEmits, PaginationProps } from 'element-plus';
import { useBoolean, useTable } from '@sa/hooks';
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
import type { FlatResponseData } from '@sa/axios';
import { jsonClone } from '@sa/utils';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
type RemoveReadonly<T> = {
-readonly [key in keyof T]: T[key];
};
export type UseUITableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
UseTableOptions<ResponseData, ApiData, UI.TableColumn<ApiData>, Pagination>,
'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
*/
getColumnVisible?: (column: UI.TableColumn<ApiData>) => boolean;
};
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const INDEX_KEY = '__index__';
export function useUITable<ResponseData, ApiData>(options: UseUITableOptions<ResponseData, ApiData, false>) {
const scope = effectScope();
const appStore = useAppStore();
const result = useTable<ResponseData, ApiData, UI.TableColumn<ApiData>, false>({
...options,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
});
// calculate the total width of the table this is used for horizontal scrolling
const scrollX = computed(() => {
return result.columns.value.reduce((acc, column) => {
return acc + Number(column.width ?? column.minWidth ?? 120);
}, 0);
});
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
scrollX
};
}
type PaginationParams = Pick<PaginationProps, 'currentPage' | 'pageSize'>;
type UseUIPaginatedTableOptions<ResponseData, ApiData> = UseUITableOptions<ResponseData, ApiData, true> & {
paginationProps?: Partial<Omit<PaginationProps, 'total'>>;
/**
* whether to show the total count of the table
*
* @default true
*/
showTotal?: boolean;
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
};
export function useUIPaginatedTable<ResponseData, ApiData>(options: UseUIPaginatedTableOptions<ResponseData, ApiData>) {
const scope = effectScope();
const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const pagination: Partial<RemoveReadonly<PaginationProps & PaginationEmits>> = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: [10, 15, 20, 25, 30],
'current-change': (page: number) => {
pagination.currentPage = page;
return true;
},
'size-change': (pageSize: number) => {
pagination.currentPage = 1;
pagination.pageSize = pageSize;
return true;
},
...options.paginationProps
}) as PaginationProps;
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: Partial<RemoveReadonly<PaginationProps & PaginationEmits>> = {
...pagination,
pagerCount: isMobile.value ? 3 : 9
};
return p;
});
const paginationParams = computed(() => {
const { currentPage, pageSize } = pagination;
return {
currentPage,
pageSize
};
});
const result = useTable<ResponseData, ApiData, UI.TableColumn<ApiData>, true>({
...options,
pagination: true,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: data => {
pagination.total = data.total;
}
});
async function getDataByPage(page: number = 1) {
if (page !== pagination.currentPage) {
pagination.currentPage = page;
return;
}
await result.getData();
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
watch(paginationParams, async newVal => {
await options.onPaginationParamsChange?.(newVal);
await result.getData();
});
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
getDataByPage,
pagination,
mobilePagination
};
}
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = shallowRef<UI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
openDrawer();
}
/** the editing row data */
const editingData = shallowRef<TableData | null>(null);
function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit';
const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
};
}
export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
): PaginationData<ApiData> {
const { data, error } = response;
if (!error) {
const { records, current, size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
function getColumnChecks<Column extends UI.TableColumn<any>>(
cols: Column[],
getColumnVisible?: (column: Column) => boolean
) {
const checks: TableColumnCheck[] = [];
cols.forEach(column => {
if (column.type === 'selection') {
checks.push({
prop: SELECTION_KEY,
label: $t('common.check'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'expand') {
checks.push({
prop: EXPAND_KEY,
label: $t('common.expandColumn'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'index') {
checks.push({
prop: INDEX_KEY,
label: $t('common.index'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else {
checks.push({
prop: column.prop as string,
label: column.label as string,
checked: true,
visible: getColumnVisible?.(column) ?? true
});
}
});
return checks;
}
function getColumns<Column extends UI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
const columnMap = new Map<string, Column>();
cols.forEach(column => {
if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
} else if (column.type === 'index') {
columnMap.set(INDEX_KEY, column);
} else {
columnMap.set(column.prop as string, column);
}
});
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.prop) as Column);
return filteredColumns;
}

158
src/hooks/common/vchart.ts Normal file
View File

@@ -0,0 +1,158 @@
import { computed, effectScope, onScopeDispose, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import VChart, { registerLiquidChart } from '@visactor/vchart';
import type { ISpec, ITheme } from '@visactor/vchart';
import light from '@visactor/vchart-theme/public/light.json';
import dark from '@visactor/vchart-theme/public/dark.json';
import { useThemeStore } from '@/store/modules/theme';
registerLiquidChart();
// register the theme
VChart.ThemeManager.registerTheme('light', light as ITheme);
VChart.ThemeManager.registerTheme('dark', dark as ITheme);
interface ChartHooks {
onRender?: (chart: VChart) => void | Promise<void>;
onUpdated?: (chart: VChart) => void | Promise<void>;
onDestroy?: (chart: VChart) => void | Promise<void>;
}
export function useVChart<T extends ISpec>(specFactory: () => T, hooks: ChartHooks = {}) {
const scope = effectScope();
const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: VChart | null = null;
const spec: T = specFactory();
const { onRender, onUpdated, onDestroy } = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart);
}
/**
* update chart spec
*
* @param callback callback function
*/
async function updateSpec(callback: (opts: T, optsFactory: () => T) => ISpec = () => spec) {
if (!isRendered()) return;
const updatedOpts = callback(spec, specFactory);
Object.assign(spec, updatedOpts);
// if (isRendered()) {
// chart?.release();
// }
chart?.updateSpec({ ...updatedOpts }, true);
await onUpdated?.(chart!);
}
function setSpec(newSpec: T) {
chart?.updateSpec(newSpec);
}
/** render chart */
async function render() {
if (!isRendered()) {
// apply the theme
if (darkMode.value) {
VChart.ThemeManager.setCurrentTheme('dark');
} else {
VChart.ThemeManager.setCurrentTheme('light');
}
chart = new VChart(spec, { dom: domRef.value as HTMLElement });
chart.renderSync();
await onRender?.(chart);
}
}
/** resize chart */
function resize() {
// chart?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart) return;
await onDestroy?.(chart);
chart?.release();
chart = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
await render();
await onUpdated?.(chart!);
}
/**
* render chart by size
*
* @param w width
* @param h height
*/
async function renderChartBySize(w: number, h: number) {
initialSize.width = w;
initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
}
// render chart
await render();
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
});
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef,
updateSpec,
setSpec
};
}

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue';
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import type { LayoutMode } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import GlobalHeader from '../modules/global-header/index.vue';
import GlobalSider from '../modules/global-sider/index.vue';
import GlobalTab from '../modules/global-tab/index.vue';
import GlobalContent from '../modules/global-content/index.vue';
import GlobalFooter from '../modules/global-footer/index.vue';
import ThemeDrawer from '../modules/theme-drawer/index.vue';
import { setupMixMenuContext } from '../context';
defineOptions({ name: 'BaseLayout' });
const appStore = useAppStore();
const themeStore = useThemeStore();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
const layoutMode = computed(() => {
const vertical: LayoutMode = 'vertical';
const horizontal: LayoutMode = 'horizontal';
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
});
const headerProps = computed(() => {
const { mode, reverseHorizontalMix } = themeStore.layout;
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: {
showLogo: false,
showMenu: false,
showMenuToggler: true
},
'vertical-mix': {
showLogo: false,
showMenu: false,
showMenuToggler: false
},
horizontal: {
showLogo: true,
showMenu: true,
showMenuToggler: false
},
'horizontal-mix': {
showLogo: true,
showMenu: true,
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
}
};
return headerPropsConfig[mode];
});
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
}
return w;
}
function getSiderCollapsedWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
}
return w;
}
</script>
<template>
<AdminLayout
v-model:sider-collapse="appStore.siderCollapse"
:mode="layoutMode"
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
:scroll-mode="themeStore.layout.scrollMode"
:is-mobile="appStore.isMobile"
:full-content="appStore.fullContent"
:fixed-top="themeStore.fixedHeaderAndTab"
:header-height="themeStore.header.height"
:tab-visible="themeStore.tab.visible"
:tab-height="themeStore.tab.height"
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
:sider-visible="siderVisible"
:sider-width="siderWidth"
:sider-collapsed-width="siderCollapsedWidth"
:footer-visible="themeStore.footer.visible"
:footer-height="themeStore.footer.height"
:fixed-footer="themeStore.footer.fixed"
:right-footer="themeStore.footer.right"
>
<template #header>
<GlobalHeader v-bind="headerProps" />
</template>
<template #tab>
<GlobalTab />
</template>
<template #sider>
<GlobalSider />
</template>
<GlobalMenu />
<GlobalContent />
<ThemeDrawer />
<template #footer>
<GlobalFooter />
</template>
</AdminLayout>
</template>
<style lang="scss">
#__SCROLL_EL_ID__ {
@include scrollbar();
}
</style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import GlobalContent from '../modules/global-content/index.vue';
defineOptions({ name: 'BlankLayout' });
</script>
<template>
<GlobalContent :show-padding="false" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,109 @@
import { computed, nextTick, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const childLevelMenus = computed<App.Global.Menu[]>(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
allMenus,
firstLevelMenus,
childLevelMenus,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
};
}
export function useMenu() {
const route = useRoute();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
const selectedKeyDummy = ref(selectedKey.value);
watch(
() => selectedKey.value,
val => {
selectedKeyDummy.value = val;
}
);
function handleSelect(key: RouteKey) {
selectedKeyDummy.value = key;
routerPushByKeyWithMetaQuery(key);
if (key.endsWith('-link')) {
nextTick(() => {
selectedKeyDummy.value = selectedKey.value;
});
}
}
return {
selectedKey,
selectedKeyDummy,
handleSelect
};
}

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core';
import type { RouteKey } from '@elegant-router/types';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
defineOptions({ name: 'GlobalBreadcrumb' });
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
interface BreadcrumbContentProps {
breadcrumb: App.Global.Menu;
}
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>();
function handleClickMenu(key: RouteKey) {
routerPushByKey(key);
}
</script>
<template>
<ElBreadcrumb v-if="themeStore.header.breadcrumb.visible">
<!-- define component start: BreadcrumbContent -->
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
<div class="i-flex-y-center align-middle">
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
{{ breadcrumb.label }}
</div>
</DefineBreadcrumbContent>
<!-- define component end: BreadcrumbContent -->
<ElBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
<ElDropdown v-if="item.options?.length" @command="handleClickMenu">
<BreadcrumbContent :breadcrumb="item" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem v-for="option in item.options" :key="option.key" :command="option.key">
{{ option.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<BreadcrumbContent v-else :breadcrumb="item" />
</ElBreadcrumbItem>
</ElBreadcrumb>
</template>
<style scoped></style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useTabStore } from '@/store/modules/tab';
defineOptions({ name: 'GlobalContent' });
interface Props {
/** Show padding for content */
showPadding?: boolean;
}
withDefaults(defineProps<Props>(), {
showPadding: true
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const tabStore = useTabStore();
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''));
function resetScroll() {
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`);
el?.scrollTo({ left: 0, top: 0 });
}
</script>
<template>
<RouterView v-slot="{ Component, route }">
<Transition
:name="transitionName"
mode="out-in"
@before-leave="appStore.setContentXScrollable(true)"
@after-leave="resetScroll"
@after-enter="appStore.setContentXScrollable(false)"
>
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
<component
:is="Component"
v-if="appStore.reloadFlag"
:key="tabStore.getTabIdByRoute(route)"
:class="{ 'p-16px': showPadding }"
class="flex-grow bg-layout transition-300"
/>
</KeepAlive>
</Transition>
</RouterView>
</template>
<style></style>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineOptions({
name: 'GlobalFooter'
});
</script>
<template>
<DarkModeContainer class="h-full flex-center">
<span>Copyright © 2026 CN-RDMS</span>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
defineOptions({ name: 'ThemeButton' });
const appStore = useAppStore();
</script>
<template>
<ButtonIcon
icon="majesticons:color-swatch-line"
:tooltip-content="$t('icon.themeConfig')"
@click="appStore.openThemeDrawer"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { VNode } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { useSvgIcon } from '@/hooks/common/icon';
import { $t } from '@/locales';
defineOptions({ name: 'UserAvatar' });
const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIcon();
function loginOrRegister() {
toLogin();
}
type DropdownKey = 'user-center' | 'logout';
type DropdownOption = {
key: DropdownKey;
label: string;
icon?: () => VNode;
};
const options = computed(() => {
const opts: DropdownOption[] = [
{
label: $t('common.userCenter'),
key: 'user-center',
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
},
{
label: $t('common.logout'),
key: 'logout',
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
}
];
return opts;
});
function logout() {
window.$messageBox
?.confirm($t('common.logoutConfirm'), $t('common.tip'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning'
})
.then(() => {
authStore.resetStore();
});
}
function handleDropdown(key: DropdownKey) {
if (key === 'logout') {
logout();
} else {
// If your other options are jumps from other routes, they will be directly supported here
routerPushByKey(key);
}
}
</script>
<template>
<ElButton v-if="!authStore.isLogin" text @click="loginOrRegister">
{{ $t('page.login.common.loginOrRegister') }}
</ElButton>
<ElDropdown class="px-14px" trigger="click" @command="handleDropdown">
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="{ key, label, icon } in options"
:key="key"
class="mx-4px my-1px rounded-6px"
:icon="icon"
:command="key"
>
{{ label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
<div class="flex items-center">
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
</div>
</ElDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core';
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue';
import ThemeButton from './components/theme-button.vue';
import UserAvatar from './components/user-avatar.vue';
defineOptions({ name: 'GlobalHeader' });
interface Props {
/** Whether to show the logo */
showLogo?: App.Global.HeaderProps['showLogo'];
/** Whether to show the menu toggler */
showMenuToggler?: App.Global.HeaderProps['showMenuToggler'];
/** Whether to show the menu */
showMenu?: App.Global.HeaderProps['showMenu'];
}
defineProps<Props>();
const appStore = useAppStore();
const themeStore = useThemeStore();
const { isFullscreen, toggle } = useFullscreen();
</script>
<template>
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden"></div>
<div v-else class="h-full flex-y-center flex-1-hidden">
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div>
<div class="h-full flex-y-center justify-end">
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
<div>
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
</div>
<LangSwitch
v-if="themeStore.header.multilingual.visible"
:lang="appStore.locale"
:lang-options="appStore.localeOptions"
@change-lang="appStore.changeLocale"
/>
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<div>
<ThemeButton />
</div>
<UserAvatar />
</div>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({ name: 'GlobalLogo' });
interface Props {
/** Whether to show the title */
showTitle?: boolean;
}
withDefaults(defineProps<Props>(), {
showTitle: true
});
</script>
<template>
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
<SystemLogo class="text-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>
</RouterLink>
</template>
<style scoped></style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color';
defineOptions({ name: 'FirstLevelMenu' });
interface Props {
menus: App.Global.Menu[];
activeMenuKey?: string;
inverted?: boolean;
siderCollapse?: boolean;
darkMode?: boolean;
themeColor: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'select', menu: App.Global.Menu): boolean;
(e: 'toggleSiderCollapse'): void;
}
const emit = defineEmits<Emits>();
interface MixMenuItemProps {
/** Menu item label */
label: App.Global.Menu['label'];
/** Menu item icon */
icon: App.Global.Menu['icon'];
/** Active menu item */
active: boolean;
/** Mini size */
isMini?: boolean;
}
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>();
const selectedBgColor = computed(() => {
const { darkMode, themeColor } = props;
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
return darkMode ? dark : light;
});
function handleClickMixMenu(menu: App.Global.Menu) {
emit('select', menu);
}
function toggleSiderCollapse() {
emit('toggleSiderCollapse');
}
</script>
<template>
<!-- define component: MixMenuItem -->
<DefineMixMenuItem v-slot="{ label, icon, active, isMini }">
<div
class="mx-4px mb-6px flex-col-center cursor-pointer rounded-8px bg-transparent px-4px py-8px transition-300 hover:bg-[rgb(0,0,0,0.08)]"
:class="{
'text-primary selected-mix-menu': active,
'text-white:65 hover:text-white': inverted,
'!text-white !bg-primary': active && inverted
}"
>
<component :is="icon" :class="[isMini ? 'text-icon-small' : 'text-icon-large']" />
<p
class="w-full ellipsis-text text-center text-12px transition-height-300"
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-4px']"
>
{{ label }}
</p>
</div>
</DefineMixMenuItem>
<!-- define component end: MixMenuItem -->
<div class="h-full flex-col-stretch flex-1-hidden">
<slot></slot>
<SimpleScrollbar>
<MixMenuItem
v-for="menu in menus"
:key="menu.key"
:label="menu.label"
:icon="menu.icon"
:active="menu.key === activeMenuKey"
:is-mini="siderCollapse"
@click="handleClickMixMenu(menu)"
/>
</SimpleScrollbar>
<MenuToggler
arrow-icon
:collapsed="siderCollapse"
:z-index="99"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="toggleSiderCollapse"
/>
</div>
</template>
<style scoped>
.selected-mix-menu {
background-color: v-bind(selectedBgColor);
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
interface Props {
item: App.Global.Menu;
}
const { item } = defineProps<Props>();
const hasChildren = item.children && item.children.length > 0;
</script>
<template>
<ElSubMenu v-if="hasChildren" :index="item.key">
<template #title>
<ElIcon>
<component :is="item.icon" />
</ElIcon>
<span class="ib-ellipsis">{{ item.label }}</span>
</template>
<MenuItem v-for="child in item.children" :key="child.key" :item="child" :index="child.key"></MenuItem>
</ElSubMenu>
<ElMenuItem v-else>
<ElIcon>
<component :is="item.icon" />
</ElIcon>
<span class="ib-ellipsis">{{ item.label }}</span>
</ElMenuItem>
</template>
<style scoped>
.ib-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
}
</style>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import HorizontalMenu from './modules/horizontal-menu.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu,
horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
};
return menuMap[themeStore.layout.mode];
});
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile);
</script>
<template>
<component :is="activeMenu" :key="reRenderVertical" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useRouteStore } from '@/store/modules/route';
import { useMenu } from '../../../context';
import MenuItem from '../components/menu-item.vue';
defineOptions({ name: 'HorizontalMenu' });
const routeStore = useRouteStore();
const { selectedKeyDummy, handleSelect } = useMenu();
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"
mode="horizontal"
:default-active="selectedKeyDummy"
@select="val => handleSelect(val as RouteKey)"
>
<MenuItem v-for="item in routeStore.menus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</Teleport>
</template>
<style scoped></style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMenu, useMixMenuContext } from '../../../context';
import MenuItem from '../components/menu-item.vue';
defineOptions({
name: 'HorizontalMixMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { selectedKeyDummy, handleSelect } = useMenu();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"
mode="horizontal"
:default-active="selectedKeyDummy"
@select="val => handleSelect(val as RouteKey)"
>
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
/>
</Teleport>
</template>
<style scoped></style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
// import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../../../context';
import MenuItem from '../components/menu-item.vue';
defineOptions({ name: 'ReversedHorizontalMixMenu' });
const route = useRoute();
const appStore = useAppStore();
// const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const {
firstLevelMenus,
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren
} = useMixMenuContext();
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
function handleSelectMixMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"
mode="horizontal"
:default-active="activeFirstLevelMenuKey"
@select="val => handleSelectMixMenu(val as RouteKey)"
>
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<ElMenu
mode="vertical"
:default-active="selectedKeyDummy"
:collapse="appStore.siderCollapse"
@select="val => handleSelect(val as RouteKey)"
>
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useMenu } from '../../../context';
import MenuItem from '../components/menu-item.vue';
defineOptions({ name: 'VerticalMenu' });
const route = useRoute();
const appStore = useAppStore();
const routeStore = useRouteStore();
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
// const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<ElMenu
mode="vertical"
:default-active="selectedKeyDummy"
:default-openeds="expandedKeys"
:collapse="appStore.siderCollapse"
:collapse-transition="false"
@select="val => handleSelect(val as RouteKey)"
>
<MenuItem v-for="item in routeStore.menus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import { useMenu, useMixMenuContext } from '../../../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
import MenuItem from '../components/menu-item.vue';
defineOptions({
name: 'VerticalMixMenu'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
allMenus,
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
//
} = useMixMenuContext();
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
function handleResetActiveMenu() {
setDrawerVisible(false);
if (!appStore.mixSiderFixed) {
getActiveFirstLevelMenuKey();
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<ElMenu mode="vertical" :default-active="selectedKeyDummy" @select="val => handleSelect(val as RouteKey)">
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { $t } from '@/locales';
defineOptions({ name: 'SearchFooter' });
</script>
<template>
<div class="h-44px flex-y-center gap-14px px-24px">
<span class="flex-y-center">
<icon-mdi-keyboard-return class="operate-shadow operate-item" />
<span>{{ $t('common.confirm') }}</span>
</span>
<span class="flex-y-center">
<icon-mdi-arrow-up-thin class="operate-shadow operate-item" />
<icon-mdi-arrow-down-thin class="operate-shadow operate-item" />
<span>{{ $t('common.switch') }}</span>
</span>
<span class="flex-y-center">
<icon-mdi-keyboard-esc class="operate-shadow operate-item" />
<span>{{ $t('common.close') }}</span>
</span>
</div>
</template>
<style lang="scss" scoped>
.operate-shadow {
box-shadow:
inset 0 -2px #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}
.operate-item {
--uno: mr-6px p-2px text-20px;
}
</style>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed, ref, shallowRef } from 'vue';
import { useRouter } from 'vue-router';
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
import type { InputInstance } from 'element-plus';
import { useRouteStore } from '@/store/modules/route';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import SearchResult from './search-result.vue';
import SearchFooter from './search-footer.vue';
defineOptions({ name: 'SearchModal' });
const router = useRouter();
const appStore = useAppStore();
const routeStore = useRouteStore();
const isMobile = computed(() => appStore.isMobile);
const keyword = ref('');
const activePath = ref('');
const resultOptions = shallowRef<App.Global.Menu[]>([]);
const handleSearch = useDebounceFn(search, 300);
const visible = defineModel<boolean>('show', { required: true });
const searchInput = ref<InputInstance>();
function search() {
resultOptions.value = routeStore.searchMenus.filter(menu => {
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase();
return trimKeyword && title.includes(trimKeyword);
});
activePath.value = resultOptions.value[0]?.routePath ?? '';
}
function handleClose() {
// handle with setTimeout to prevent user from seeing some operations
setTimeout(() => {
visible.value = false;
resultOptions.value = [];
keyword.value = '';
}, 200);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = getActivePathIndex();
if (index === -1) return;
const activeIndex = index === 0 ? length - 1 : index - 1;
activePath.value = resultOptions.value[activeIndex].routePath;
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = getActivePathIndex();
if (index === -1) return;
const activeIndex = index === length - 1 ? 0 : index + 1;
activePath.value = resultOptions.value[activeIndex].routePath;
}
function getActivePathIndex() {
return resultOptions.value.findIndex(item => item.routePath === activePath.value);
}
/** key enter */
function handleEnter() {
if (resultOptions.value?.length === 0 || activePath.value === '') return;
handleClose();
router.push(activePath.value);
}
function registerShortcut() {
onKeyStroke('Escape', handleClose);
onKeyStroke('Enter', handleEnter);
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
}
/** open dialog and set input focus */
function setFocus() {
setTimeout(() => {
searchInput.value?.focus();
});
}
registerShortcut();
</script>
<template>
<ElDialog
v-model="visible"
:show-close="false"
append-to-body
class="search-modal fixed left-0 right-0"
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
@open-auto-focus="setFocus"
@close="handleClose"
>
<ElInput
ref="searchInput"
v-model="keyword"
clearable
:placeholder="$t('common.keywordSearch')"
@input="handleSearch"
>
<template #prefix>
<icon-uil-search class="text-15px" />
</template>
<template v-if="isMobile" #append>
<ElButton type="primary" plain @click="handleClose">{{ $t('common.cancel') }}</ElButton>
</template>
</ElInput>
<div>
<ElEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" :image-size="50" />
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
</div>
<template #footer>
<SearchFooter v-if="!isMobile" />
</template>
</ElDialog>
</template>
<style lang="scss">
.search-modal {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 10px 15px 0;
}
.el-dialog__footer {
border-top-width: 1px;
}
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
defineOptions({ name: 'SearchResult' });
interface Props {
options: App.Global.Menu[];
}
defineProps<Props>();
interface Emits {
(e: 'enter'): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const active = defineModel<string>('path', { required: true });
async function handleMouseEnter(item: App.Global.Menu) {
active.value = item.routePath;
}
function handleTo() {
emit('enter');
}
</script>
<template>
<ElScrollbar>
<div class="pb-12px">
<template v-for="item in options" :key="item.routePath">
<div
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
:style="{
background: item.routePath === active ? theme.themeColor : '',
color: item.routePath === active ? '#fff' : ''
}"
@click="handleTo"
@mouseenter="handleMouseEnter(item)"
>
<component :is="item.icon" />
<span class="ml-5px flex-1">
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
</span>
<icon-ant-design-enter-outlined class="icon mr-3px p-2px text-20px" />
</div>
</template>
</div>
</ElScrollbar>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import { useBoolean } from '@sa/hooks';
import { $t } from '@/locales';
import SearchModal from './components/search-modal.vue';
defineOptions({ name: 'GlobalSearch' });
const { bool: show, toggle } = useBoolean();
</script>
<template>
<ButtonIcon :tooltip-content="$t('common.search')" @click="toggle">
<icon-uil-search />
</ButtonIcon>
<SearchModal v-model:show="show" />
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import GlobalLogo from '../global-logo/index.vue';
defineOptions({ name: 'GlobalSider' });
const appStore = useAppStore();
const themeStore = useThemeStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
</script>
<template>
<DarkModeContainer class="size-full flex-col-stretch shadow-sider" :inverted="darkMenu">
<GlobalLogo
v-if="showLogo"
:show-title="!appStore.siderCollapse"
:style="{ height: themeStore.header.height + 'px' }"
/>
<div :id="GLOBAL_SIDER_MENU_ID" :class="menuWrapperClass"></div>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { VNode } from 'vue';
import type { DropdownInstance } from 'element-plus';
import { useTabStore } from '@/store/modules/tab';
import { useSvgIcon } from '@/hooks/common/icon';
import { $t } from '@/locales';
defineOptions({ name: 'ContextMenu' });
interface Props {
/** ClientX */
x: number;
/** ClientY */
y: number;
tabId: string;
excludeKeys?: App.Global.DropdownKey[];
disabledKeys?: App.Global.DropdownKey[];
}
const props = withDefaults(defineProps<Props>(), {
excludeKeys: () => [],
disabledKeys: () => []
});
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore();
const { SvgIconVNode } = useSvgIcon();
type DropdownOption = {
key: App.Global.DropdownKey;
label: string;
icon?: () => VNode;
disabled?: boolean;
};
const options = computed(() => {
const opts: DropdownOption[] = [
{
key: 'closeCurrent',
label: $t('dropdown.closeCurrent'),
icon: SvgIconVNode({ icon: 'ant-design:close-outlined', fontSize: 18 })
},
{
key: 'closeOther',
label: $t('dropdown.closeOther'),
icon: SvgIconVNode({ icon: 'ant-design:column-width-outlined', fontSize: 18 })
},
{
key: 'closeLeft',
label: $t('dropdown.closeLeft'),
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-left', fontSize: 18 })
},
{
key: 'closeRight',
label: $t('dropdown.closeRight'),
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-right', fontSize: 18 })
},
{
key: 'closeAll',
label: $t('dropdown.closeAll'),
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 })
}
];
const { excludeKeys, disabledKeys } = props;
const result = opts.filter(opt => !excludeKeys.includes(opt.key));
disabledKeys.forEach(key => {
const opt = result.find(item => item.key === key);
if (opt) {
opt.disabled = true;
}
});
return result;
});
const visible = defineModel<boolean>('visible');
const dropdown = ref<DropdownInstance>();
watch(visible, val => {
if (val) {
dropdown.value!.handleOpen();
} else {
dropdown.value!.handleClose();
}
});
function hideDropdown() {
visible.value = false;
dropdown.value!.handleClose();
}
const dropdownAction: Record<App.Global.DropdownKey, () => void> = {
closeCurrent() {
removeTab(props.tabId);
},
closeOther() {
clearTabs([props.tabId]);
},
closeLeft() {
clearLeftTabs(props.tabId);
},
closeRight() {
clearRightTabs(props.tabId);
},
closeAll() {
clearTabs();
}
};
function handleDropdown(optionKey: App.Global.DropdownKey) {
dropdownAction[optionKey]?.();
hideDropdown();
}
</script>
<template>
<div class="absolute" :style="{ top: `${y - 60}px`, left: `${x + 60}px` }">
<ElDropdown ref="dropdown" popper-class="arrow-hide" trigger="click" @command="handleDropdown">
<!-- Avoid waning: [ElOnlyChild] no valid child node found -->
<span></span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="{ key, label, icon, disabled } in options"
:key="key"
class="mx-4px my-1px rounded-6px"
:icon="icon"
:command="key"
:disabled="disabled"
>
{{ label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<style lang="scss">
.arrow-hide {
.el-popper__arrow {
display: none;
}
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useElementBounding } from '@vueuse/core';
import { PageTab } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useTabStore } from '@/store/modules/tab';
import { isPC } from '@/utils/agent';
import BetterScroll from '@/components/custom/better-scroll.vue';
import ContextMenu from './context-menu.vue';
defineOptions({ name: 'GlobalTab' });
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const tabStore = useTabStore();
const bsWrapper = ref<HTMLElement>();
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
const bsScroll = ref<InstanceType<typeof BetterScroll>>();
const tabRef = ref<HTMLElement>();
const isPCFlag = isPC();
const TAB_DATA_ID = 'data-tab-id';
type TabNamedNodeMap = NamedNodeMap & {
[TAB_DATA_ID]: Attr;
};
async function scrollToActiveTab() {
await nextTick();
if (!tabRef.value) return;
const { children } = tabRef.value;
for (let i = 0; i < children.length; i += 1) {
const child = children[i];
const { value: tabId } = (child.attributes as TabNamedNodeMap)[TAB_DATA_ID];
if (tabId === tabStore.activeTabId) {
const { left, width } = child.getBoundingClientRect();
const clientX = left + width / 2;
setTimeout(() => {
scrollByClientX(clientX);
}, 50);
break;
}
}
}
function scrollByClientX(clientX: number) {
const currentX = clientX - bsWrapperLeft.value;
const deltaX = currentX - bsWrapperWidth.value / 2;
if (bsScroll.value?.instance) {
const { maxScrollX, x: leftX, scrollBy } = bsScroll.value.instance;
const rightX = maxScrollX - leftX;
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
scrollBy(update, 0, 300);
}
}
function getContextMenuDisabledKeys(tabId: string) {
const disabledKeys: App.Global.DropdownKey[] = [];
if (tabStore.isTabRetain(tabId)) {
const homeDisable: App.Global.DropdownKey[] = ['closeCurrent', 'closeLeft'];
disabledKeys.push(...homeDisable);
}
return disabledKeys;
}
function handleCloseTab(tab: App.Global.Tab) {
tabStore.removeTab(tab.id);
}
async function refresh() {
appStore.reloadPage(500);
}
interface DropdownConfig {
visible: boolean;
x: number;
y: number;
tabId: string;
}
const dropdown = ref<DropdownConfig>({
visible: false,
x: 0,
y: 0,
tabId: ''
});
function setDropdown(config: Partial<DropdownConfig>) {
Object.assign(dropdown.value, config);
}
let isClickContextMenu = false;
function handleDropdownVisible(visible: boolean | undefined) {
if (!isClickContextMenu) {
setDropdown({ visible });
}
}
async function handleContextMenu(e: MouseEvent, tabId: string) {
e.preventDefault();
const { clientX, clientY } = e;
isClickContextMenu = true;
const DURATION = dropdown.value.visible ? 150 : 0;
setDropdown({ visible: false });
setTimeout(() => {
setDropdown({
visible: true,
x: clientX,
y: clientY,
tabId
});
isClickContextMenu = false;
}, DURATION);
}
function init() {
tabStore.initTabStore(route);
}
function removeFocus() {
(document.activeElement as HTMLElement)?.blur();
}
// watch
watch(
() => route.fullPath,
() => {
tabStore.addTab(route);
}
);
watch(
() => tabStore.activeTabId,
() => {
scrollToActiveTab();
}
);
// init
init();
</script>
<template>
<DarkModeContainer class="size-full flex-y-center px-16px shadow-tab">
<div ref="bsWrapper" class="h-full flex-1-hidden">
<BetterScroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: !isPCFlag }" @click="removeFocus">
<div
ref="tabRef"
class="h-full flex pr-18px"
:class="[themeStore.tab.mode === 'chrome' ? 'items-end' : 'items-center gap-12px']"
>
<PageTab
v-for="tab in tabStore.tabs"
:key="tab.id"
:[TAB_DATA_ID]="tab.id"
:mode="themeStore.tab.mode"
:dark-mode="themeStore.darkMode"
:active="tab.id === tabStore.activeTabId"
:active-color="themeStore.themeColor"
:closable="!tabStore.isTabRetain(tab.id)"
@click="tabStore.switchRouteByTab(tab)"
@close="handleCloseTab(tab)"
@contextmenu="handleContextMenu($event, tab.id)"
>
<template #prefix>
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
</template>
<div class="max-w-240px ellipsis-text">{{ tab.label }}</div>
</PageTab>
</div>
</BetterScroll>
</div>
<div>
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
</div>
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
</DarkModeContainer>
<ContextMenu
:visible="dropdown.visible"
:tab-id="dropdown.tabId"
:disabled-keys="getContextMenuDisabledKeys(dropdown.tabId)"
:x="dropdown.x"
:y="dropdown.y"
@update:visible="handleDropdownVisible"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { Placement } from 'element-plus';
import { themeLayoutModeRecord } from '@/constants/app';
import { $t } from '@/locales';
defineOptions({ name: 'LayoutModeCard' });
interface Props {
/** Layout mode */
mode: UnionKey.ThemeLayoutMode;
/** Disabled */
disabled?: boolean;
}
const props = defineProps<Props>();
interface Emits {
/** Layout mode change */
(e: 'update:mode', mode: UnionKey.ThemeLayoutMode): void;
}
const emit = defineEmits<Emits>();
type LayoutConfig = Record<
UnionKey.ThemeLayoutMode,
{
placement: Placement;
headerClass: string;
menuClass: string;
mainClass: string;
}
>;
const layoutConfig: LayoutConfig = {
vertical: {
placement: 'bottom',
headerClass: '',
menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-mix': {
placement: 'bottom',
headerClass: '',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
horizontal: {
placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4'
},
'horizontal-mix': {
placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
}
};
function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
if (props.disabled) return;
emit('update:mode', mode);
}
</script>
<template>
<div class="flex-center flex-wrap gap-x-32px gap-y-16px">
<div
v-for="(item, key) in layoutConfig"
:key="key"
class="flex cursor-pointer border-2px rounded-6px hover:border-primary"
:class="[mode === key ? 'border-primary' : 'border-transparent']"
@click="handleChangeMode(key)"
>
<ElTooltip :placement="item.placement">
<template #content>
{{ $t(themeLayoutModeRecord[key]) }}
</template>
<div
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5"
:class="[key.includes('vertical') ? 'flex' : 'flex-col']"
>
<slot :name="key"></slot>
</div>
</ElTooltip>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineOptions({ name: 'SettingItem' });
interface Props {
/** Label */
label: string;
}
defineProps<Props>();
</script>
<template>
<div class="w-full flex-y-center justify-between">
<div>
<span class="pr-8px text-base-text">{{ label }}</span>
<slot name="suffix"></slot>
</div>
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import DarkMode from './modules/dark-mode.vue';
import LayoutMode from './modules/layout-mode.vue';
import ThemeColor from './modules/theme-color.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue';
defineOptions({ name: 'ThemeDrawer' });
const appStore = useAppStore();
</script>
<template>
<ElDrawer v-model="appStore.themeDrawerVisible" :title="$t('theme.themeDrawerTitle')" :size="360">
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
<template #footer>
<ConfigOperation />
</template>
</ElDrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import Clipboard from 'clipboard';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
defineOptions({ name: 'ConfigOperation' });
const themeStore = useThemeStore();
const domRef = ref<HTMLElement | null>(null);
function initClipboard() {
if (!domRef.value) return;
const clipboard = new Clipboard(domRef.value);
clipboard.on('success', () => {
window.$message?.success($t('theme.configOperation.copySuccessMsg'));
});
}
function getClipboardText() {
const reg = /"\w+":/g;
const json = themeStore.settingsJson;
return json.replace(reg, match => match.replace(/"/g, ''));
}
function handleReset() {
themeStore.resetStore();
setTimeout(() => {
window.$message?.success($t('theme.configOperation.resetSuccessMsg'));
}, 50);
}
const dataClipboardText = computed(() => getClipboardText());
onMounted(() => {
initClipboard();
});
</script>
<template>
<div class="w-full flex justify-between">
<textarea id="themeConfigCopyTarget" v-model="dataClipboardText" class="absolute opacity-0 -z-1" />
<ElButton type="danger" plain @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</ElButton>
<div ref="domRef" data-clipboard-target="#themeConfigCopyTarget">
<ElButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</ElButton>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({ name: 'DarkMode' });
const themeStore = useThemeStore();
const icons: Record<UnionKey.ThemeScheme, string> = {
light: 'material-symbols:sunny',
dark: 'material-symbols:nightlight-rounded',
auto: 'material-symbols:hdr-auto'
};
function handleSegmentChange(value: string | number) {
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
}
function handleGrayscaleChange(value: boolean) {
themeStore.setGrayscale(value);
}
function handleColourWeaknessChange(value: boolean) {
themeStore.setColourWeakness(value);
}
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
</script>
<template>
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
<div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
<template #label>
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
</template>
</ElTabPane>
</ElTabs>
</div>
<Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<ElSwitch v-model="themeStore.sider.inverted" />
</SettingItem>
</Transition>
<SettingItem :label="$t('theme.grayscale')">
<ElSwitch v-model:model-value="themeStore.grayscale" :update:model-value="handleGrayscaleChange" />
</SettingItem>
<SettingItem :label="$t('theme.colourWeakness')">
<ElSwitch v-model:model-value="themeStore.colourWeakness" :update:model-value="handleColourWeaknessChange" />
</SettingItem>
</div>
</template>
<style lang="scss" scoped>
.sider-inverted-enter-active,
.sider-inverted-leave-active {
--uno: h-22px transition-all-300;
}
.sider-inverted-enter-from,
.sider-inverted-leave-to {
--uno: translate-x-20px opacity-0 h-0;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import LayoutModeCard from '../components/layout-mode-card.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({ name: 'LayoutMode' });
const appStore = useAppStore();
const themeStore = useThemeStore();
function handleReverseHorizontalMixChange(value: boolean | string | number) {
themeStore.setLayoutReverseHorizontalMix(value as boolean);
}
</script>
<template>
<ElDivider>{{ $t('theme.layoutMode.title') }}</ElDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical>
<div class="layout-sider h-full w-18px"></div>
<div class="vertical-wrapper">
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-mix>
<div class="layout-sider h-full w-8px"></div>
<div class="layout-sider h-full w-16px"></div>
<div class="vertical-wrapper">
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
</template>
<template #horizontal>
<div class="layout-header"></div>
<div class="horizontal-wrapper">
<div class="layout-main"></div>
</div>
</template>
<template #horizontal-mix>
<div class="layout-header"></div>
<div class="horizontal-wrapper">
<div class="layout-sider w-18px"></div>
<div class="layout-main"></div>
</div>
</template>
</LayoutModeCard>
<SettingItem
v-if="themeStore.layout.mode === 'horizontal-mix'"
:label="$t('theme.layoutMode.reverseHorizontalMix')"
class="mt-16px"
>
<ElSwitch v-model="themeStore.layout.reverseHorizontalMix" @change="handleReverseHorizontalMixChange" />
</SettingItem>
</template>
<style scoped>
.layout-header {
--uno: h-16px bg-primary rd-4px;
}
.layout-sider {
--uno: bg-primary-300 rd-4px;
}
.layout-main {
--uno: flex-1 bg-primary-200 rd-4px;
}
.vertical-wrapper {
--uno: flex-1 flex-col gap-6px;
}
.horizontal-wrapper {
--uno: flex-1 flex gap-6px;
}
</style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({ name: 'PageFun' });
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<ElDivider>{{ $t('theme.pageFunTitle') }}</ElDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<ElSelect v-model="themeStore.layout.scrollMode" size="small" class="w-120px">
<ElOption
v-for="{ label, value } in translateOptions(themeScrollModeOptions)"
:key="value"
:label="label"
:value="value"
/>
</ElSelect>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<ElSwitch v-model="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<ElSelect v-model="themeStore.page.animateMode" size="small" class="w-120px">
<ElOption
v-for="{ label, value } in translateOptions(themePageAnimationModeOptions)"
:key="value"
:label="label"
:value="value"
/>
</ElSelect>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<ElSwitch v-model="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<ElInputNumber v-model="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<ElSwitch v-model="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<ElSwitch v-model="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<ElSwitch v-model="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<ElSwitch v-model="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<ElInputNumber v-model="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<ElSelect v-model="themeStore.tab.mode" size="small" class="w-120px">
<ElOption
v-for="{ label, value } in translateOptions(themeTabModeOptions)"
:key="value"
:label="label"
:value="value"
/>
</ElSelect>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<ElInputNumber v-model="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<ElInputNumber v-model="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<ElInputNumber v-model="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<ElInputNumber v-model="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<ElInputNumber v-model="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<ElSwitch v-model="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<ElSwitch v-model="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<ElInputNumber v-model="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<ElSwitch v-model="themeStore.footer.right" />
</SettingItem>
<SettingItem key="8" :label="$t('theme.watermark.visible')">
<ElSwitch v-model="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-1" :label="$t('theme.watermark.enableUserName')">
<ElSwitch v-model="themeStore.watermark.enableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-2" :label="$t('theme.watermark.text')">
<ElInput
v-model="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="CN-RDMS"
/>
</SettingItem>
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<ElSwitch v-model="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
<ElSwitch v-model="themeStore.header.globalSearch.visible" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({ name: 'ThemeColor' });
const themeStore = useThemeStore();
function handleUpdateColor(color: string | null, key: App.Theme.ThemeColorKey) {
if (color !== null) {
themeStore.updateThemeColors(key, color);
}
}
const swatches: string[] = [
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#0ea5e9',
'#06b6d4',
'#f43f5e',
'#ef4444',
'#ec4899',
'#d946ef',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#10b981'
];
</script>
<template>
<ElDivider>{{ $t('theme.themeColor.title') }}</ElDivider>
<div class="flex-col-stretch gap-12px">
<ElTooltip placement="top-start">
<template #content>
<p>
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
<br />
<ElButton
text
tag="a"
href="https://uicolors.app/create"
target="_blank"
rel="noopener noreferrer"
class="text-gray"
>
https://uicolors.app/create
</ElButton>
</p>
</template>
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
<ElSwitch v-model="themeStore.recommendColor" />
</SettingItem>
</ElTooltip>
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix>
<ElCheckbox v-model="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }}
</ElCheckbox>
</template>
<ElColorPicker
v-model="themeStore.themeColors[key]"
class="w-40px"
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
:show-alpha="false"
:predefine="swatches"
@change="handleUpdateColor($event, key)"
/>
</SettingItem>
</div>
</template>
<style scoped></style>

20
src/locales/dayjs.ts Normal file
View File

@@ -0,0 +1,20 @@
import { locale } from 'dayjs';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/en';
import { localStg } from '@/utils/storage';
/**
* Set dayjs locale
*
* @param lang
*/
export function setDayjsLocale(lang: App.I18n.LangType = 'zh-CN') {
const localMap = {
'zh-CN': 'zh-cn',
'en-US': 'en'
} satisfies Record<App.I18n.LangType, string>;
const l = lang || localStg.get('lang') || 'zh-CN';
locale(localMap[l]);
}

Some files were not shown because too many files have changed in this diff Show More