2
0

初始化项目

This commit is contained in:
caiyuchao
2024-11-14 11:06:38 +08:00
parent 988b9e6799
commit 4ffac789e1
320 changed files with 34244 additions and 0 deletions

28
src/App.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ConfigProvider } from 'ant-design-vue';
import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme';
import { antdLocales } from './locales/antd';
defineOptions({
name: 'App'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const antdLocale = computed(() => {
return antdLocales[appStore.locale];
});
</script>
<template>
<ConfigProvider :theme="themeStore.antdTheme" :locale="antdLocale">
<AppProvider>
<RouterView class="bg-layout" />
</AppProvider>
</ConfigProvider>
</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

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

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 @@
<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,39 @@
<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<AntDesign.TableColumnCheck[]>('columns', {
required: true
});
</script>
<template>
<APopover placement="bottomRight" trigger="click">
<AButton size="small">
<div class="flex-y-center gap-8px">
<icon-ant-design-setting-outlined class="text-icon" />
<span>{{ $t('common.columnSetting') }}</span>
</div>
</AButton>
<template #content>
<VueDraggable v-model="columns">
<div
v-for="item in columns"
:key="item.key"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
>
<icon-mdi-drag class="mr-8px cursor-move text-icon" />
<ACheckbox v-model:checked="item.checked">
{{ item.title }}
</ACheckbox>
</div>
</VueDraggable>
</template>
</APopover>
</template>
<style scoped></style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { isShowBtn } from '@/utils/permission';
defineOptions({
name: 'TableHeaderOperation'
});
interface Props {
disabledDelete?: boolean;
loading?: boolean;
tableType?: string;
showDelete?: boolean;
}
defineProps<Props>();
interface Emits {
(e: 'add'): void;
(e: 'delete'): void;
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const columns = defineModel<AntDesign.TableColumnCheck[]>('columns', {
default: () => []
});
function add() {
emit('add');
}
function batchDelete() {
emit('delete');
}
function refresh() {
emit('refresh');
}
</script>
<template>
<div class="flex flex-wrap justify-end gap-x-12px gap-y-8px lt-sm:(w-200px py-12px)">
<slot name="prefix"></slot>
<slot name="default">
<AButton v-if="isShowBtn(`system:${tableType}:add`)" size="small" ghost type="primary" @click="add">
<div class="flex-y-center gap-8px">
<icon-ic-round-plus class="text-icon" />
<span>{{ $t('common.add') }}</span>
</div>
</AButton>
<APopconfirm
v-if="isShowBtn(`system:${tableType}:remove`) && showDelete"
:title="$t('common.confirmDelete')"
:disabled="disabledDelete"
@confirm="batchDelete"
>
<AButton size="small" danger :disabled="disabledDelete">
<div class="flex-y-center gap-8px">
<icon-ic-round-delete class="text-icon" />
<span>{{ $t('common.batchDelete') }}</span>
</div>
</AButton>
</APopconfirm>
</slot>
<AButton size="small" @click="refresh">
<div class="flex-y-center gap-8px">
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
<span>{{ $t('common.refresh') }}</span>
</div>
</AButton>
<TableColumnSetting v-model:columns="columns" />
<slot name="suffix"></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { getRgbOfColor } from '@sa/utils';
import { $t } from '@/locales';
import { localStg } from '@/utils/storage';
const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];
function addThemeColorCssVars() {
const themeColor = localStg.get('themeColor') || '#646cff';
const { r, g, b } = getRgbOfColor(themeColor);
const cssVars = `--primary-color: ${r} ${g} ${b}`;
document.documentElement.style.cssText = cssVars;
}
addThemeColorCssVars();
</script>
<template>
<div class="fixed-center flex-col">
<SystemLogo class="text-128px text-primary" />
<div class="w-56px h-56px my-36px">
<div class="relative h-full animate-spin">
<div
v-for="(item, index) in loadingClasses"
:key="index"
class="absolute w-16px h-16px bg-primary rounded-8px animate-pulse"
:class="item"
></div>
</div>
</div>
<h2 class="text-28px font-500 text-#646464">{{ $t('system.title') }}</h2>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { createTextVNode, defineComponent } from 'vue';
import { App } from 'ant-design-vue';
defineOptions({
name: 'AppProvider'
});
const ContextHolder = defineComponent({
name: 'ContextHolder',
setup() {
return () => createTextVNode();
}
});
</script>
<template>
<App class="h-full">
<ContextHolder />
<slot></slot>
</App>
</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 { $t } from '@/locales';
import { useRouterPush } from '@/hooks/common/router';
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>
<AButton type="primary" @click="routerPushByKey('root')">{{ $t('common.backToHome') }}</AButton>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<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,54 @@
<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>
<ADropdown placement="bottom">
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
<SvgIcon icon="heroicons:language" />
</ButtonIcon>
<template #overlay>
<AMenu :selected-keys="[lang]">
<AMenuItem v-for="option in langOptions" :key="option.key" @click="changeLang(option.key)">
{{ option.label }}
</AMenuItem>
</AMenu>
</template>
</ADropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,48 @@
<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;
}
const props = defineProps<Props>();
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
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
tooltip-placement="bottomLeft"
trigger-parent
>
<SvgIcon :icon="icon" />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<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.pin') : $t('icon.unpin')" tooltip-placement="bottomLeft" trigger-parent>
<SvgIcon :icon="icon" />
</ButtonIcon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,21 @@
<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,9 @@
<script lang="ts" setup>
defineOptions({ name: 'SystemLogo' });
</script>
<template>
<icon-local-logo />
</template>
<style scoped></style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip';
import { $t } from '@/locales';
defineOptions({ name: 'ThemeSchemaSwitch' });
interface Props {
/** Theme schema */
themeSchema: UnionKey.ThemeScheme;
/** Show tooltip */
showTooltip?: boolean;
/** Tooltip placement */
tooltipPlacement?: TooltipPlacement;
}
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,87 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip';
defineOptions({
name: 'ButtonIcon',
inheritAttrs: false
});
interface Props {
/** Button class */
class?: string;
/** Iconify icon name */
icon?: string;
/** Tooltip content */
tooltipContent?: string;
/** Tooltip placement */
tooltipPlacement?: TooltipPlacement;
/** Trigger tooltip on parent */
triggerParent?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
class: 'h-36px text-icon',
icon: '',
tooltipContent: '',
tooltipPlacement: 'bottom',
triggerParent: false
});
interface ButtonProps {
className: string;
}
const [DefineButton, Button] = createReusableTemplate<ButtonProps>();
const cls = computed(() => {
let clsStr = props.class;
if (!clsStr.includes('h-')) {
clsStr += ' h-36px';
}
if (!clsStr.includes('text-')) {
clsStr += ' text-icon';
}
return clsStr;
});
function getPopupContainer(triggerNode: HTMLElement) {
return props.triggerParent ? triggerNode.parentElement! : document.body;
}
</script>
<template>
<!-- define component start: Button -->
<DefineButton v-slot="{ $slots, className }">
<AButton type="text" :class="className">
<div class="flex-center gap-8px">
<component :is="$slots.default" />
</div>
</AButton>
</DefineButton>
<!-- define component end: Button -->
<ATooltip
v-if="tooltipContent"
:placement="tooltipPlacement"
:get-popup-container="getPopupContainer"
:title="tooltipContent"
>
<Button :class-name="cls" v-bind="$attrs">
<slot>
<SvgIcon :icon="icon" />
</slot>
</Button>
</ATooltip>
<Button v-else :class-name="cls" v-bind="$attrs">
<slot>
<SvgIcon :icon="icon" />
</slot>
</Button>
</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,20 @@
<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,13 @@
<script setup lang="ts">
defineOptions({
name: 'SoybeanAvatar'
});
</script>
<template>
<div class="size-72px overflow-hidden rd-1/2">
<img src="@/assets/imgs/soybean.jpg" class="size-full" />
</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,59 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getColorPalette } from '@sa/utils';
interface Props {
/** Theme color */
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getColorPalette(props.themeColor, 3));
const darkColor = computed(() => getColorPalette(props.themeColor, 6));
</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>

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

@@ -0,0 +1,52 @@
import { transformRecordToOption } from '@/utils/common';
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',
'code-login': 'page.login.codeLogin.title',
register: 'page.login.register.title',
'reset-pwd': 'page.login.resetPwd.title',
'bind-wechat': 'page.login.bindWeChat.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);

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

@@ -0,0 +1,15 @@
import { transformRecordToOption } from '@/utils/common';
export const enableStatusRecord: Record<Api.Common.EnableStatus, App.I18n.I18nKey> = {
'0': 'page.manage.common.status.enable',
'1': 'page.manage.common.status.disable'
};
export const enableStatusOptions = transformRecordToOption(enableStatusRecord);
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, App.I18n.I18nKey> = {
'1': 'page.manage.menu.iconType.iconify',
'2': 'page.manage.menu.iconType.local'
};
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord);

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);

View File

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]*))?)$/;

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 { $t } from '@/locales';
import { REG_PHONE } from '@/constants/reg';
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() === '') {
$message?.error?.($t('form.phone.required'));
return false;
}
if (!REG_PHONE.test(phone)) {
$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);
});
$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'));
start();
endLoading();
}
return {
label,
start,
stop,
isCounting,
loading,
getCaptcha
};
}

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

@@ -0,0 +1,230 @@
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
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 { useElementSize } from '@vueuse/core';
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!);
}
/** 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();
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
});
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef,
updateOptions
};
}

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 'ant-design-vue';
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) {
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') },
{
validator: (rule, value) => {
if (value.trim() !== '' && value !== toValue(pwd)) {
return Promise.reject(rule.message);
}
return Promise.resolve();
},
message: $t('form.confirmPwd.invalid'),
trigger: 'change'
}
];
return confirmPwdRule;
}
return {
patternRules,
formRules,
defaultRequiredRule,
createRequiredRule,
createConfirmPwdRule
};
}
export function useAntdForm() {
const formRef = ref<FormInstance | null>(null);
async function validate() {
await formRef.value?.validate();
}
function resetFields() {
formRef.value?.resetFields();
}
return {
formRef,
validate,
resetFields
};
}

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
};
}

View File

@@ -0,0 +1,4 @@
export * from './echarts';
export * from './table';
export * from './form';
export * from './router';

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

@@ -0,0 +1,103 @@
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 (query) {
routeLocation.query = query;
}
if (params) {
routeLocation.params = params;
}
return routerPush(routeLocation);
}
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 */
async function redirectFromLogin() {
const redirect = route.value.query?.redirect as string;
if (redirect) {
routerPush(redirect);
} else {
toHome();
}
}
return {
route,
routerPush,
routerBack,
routerPushByKey,
toLogin,
toggleLoginModule,
redirectFromLogin
};
}

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

@@ -0,0 +1,201 @@
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
import type { Ref } from 'vue';
import type { TablePaginationConfig } from 'ant-design-vue';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
type TableData<T = object> = AntDesign.TableData<T>;
type GetTableData<A extends AntDesign.TableApiFn> = AntDesign.GetTableData<A>;
type TableColumn<T> = AntDesign.TableColumn<T>;
export function useTable<A extends AntDesign.TableApiFn>(config: AntDesign.AntDesignTableConfig<A>) {
const scope = effectScope();
const appStore = useAppStore();
const { apiFn, apiParams, immediate, rowKey } = config;
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<AntDesign.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const { rows = [], total = 0 } = res.data || {};
return {
rows: rows.map((row, index) => ({ ...row, id: rowKey ? row[rowKey] : index })),
total
};
},
getColumnChecks: cols => {
const checks: AntDesign.TableColumnCheck[] = [];
cols.forEach(column => {
if (column.key) {
checks.push({
key: column.key as string,
title: column.title as string,
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (column.key) {
columnMap.set(column.key as string, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
onFetched: async transformed => {
const { total } = transformed;
updatePagination({
total
});
},
immediate
});
const pagination: TablePaginationConfig = reactive({
showSizeChanger: true,
pageSizeOptions: [10, 15, 20, 25, 30],
total: 0,
simple: false,
// size: 'f',
current: 1,
pageSize: 10,
onChange: async (current: number, pageSize: number) => {
pagination.current = current;
updateSearchParams({
pageNum: current,
pageSize
});
getData();
}
});
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: TablePaginationConfig = {
...pagination,
simple: appStore.isMobile
};
return p;
});
function updatePagination(update: Partial<TablePaginationConfig>) {
Object.assign(pagination, update);
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
searchParams,
updateSearchParams,
resetSearchParams
};
}
export function useTableOperate<T extends TableData<{ [key: string]: any }>>(
data: Ref<T[]>,
options: {
getData: () => Promise<void>;
idKey?: string;
}
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<AntDesign.TableOperateType>('add');
const { getData, idKey = 'id' } = options;
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
function handleAdd() {
operateType.value = 'add';
editingData.value = null;
openDrawer();
}
function handleEdit(id: any) {
operateType.value = 'edit';
editingData.value = data.value.find(item => item[idKey] === id) || null;
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = ref<number[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
};
}

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed } 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 layoutMode = computed(() => {
const vertical: LayoutMode = 'vertical';
const horizontal: LayoutMode = 'horizontal';
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
});
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: false
}
};
const headerProps = computed(() => headerPropsConfig[themeStore.layout.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 { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
}
function getSiderCollapsedWidth() {
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
}
setupMixMenuContext();
</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>
<GlobalContent />
<ThemeDrawer />
<template #footer>
<GlobalFooter />
</template>
</AdminLayout>
</template>
<style lang="scss">
#__SCROLL_EL_ID__ {
@include scrollbar();
}
</style>

View File

@@ -0,0 +1,13 @@
<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,4 @@
import { useContext } from '@sa/hooks';
import { useMixMenu } from '../hooks';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);

View File

@@ -0,0 +1,44 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRouteStore } from '@/store/modules/route';
export function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
const [firstLevelRouteName] = routeName.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const menus = computed(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey,
menus
};
}

View File

@@ -0,0 +1,47 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import { useRouteStore } from '@/store/modules/route';
export function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
const [firstLevelRouteName] = routeName.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const menus = computed(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey,
menus
};
}
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useAttrs } from 'vue';
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',
inheritAttrs: false
});
const attrs = useAttrs();
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>
<!-- 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 -->
<ABreadcrumb v-if="themeStore.header.breadcrumb.visible" v-bind="attrs">
<ABreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
<BreadcrumbContent :breadcrumb="item" />
<template v-if="item.children?.length" #overlay>
<AMenu>
<AMenuItem v-for="option in item.children" :key="option.key" @click="handleClickMenu(option.routeKey)">
<BreadcrumbContent :breadcrumb="option" />
</AMenuItem>
</AMenu>
</template>
</ABreadcrumbItem>
</ABreadcrumb>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
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();
</script>
<template>
<RouterView v-slot="{ Component, route }">
<Transition
:name="themeStore.page.animateMode"
mode="out-in"
@before-leave="appStore.setContentXScrollable(true)"
@after-enter="appStore.setContentXScrollable(false)"
>
<KeepAlive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.reloadFlag"
:key="route.path"
:class="{ 'p-16px': showPadding }"
class="flex-grow bg-layout transition-300"
/>
</KeepAlive>
</Transition>
</RouterView>
</template>
<style></style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
defineOptions({
name: 'GlobalFooter'
});
</script>
<template>
<DarkModeContainer class="h-full flex-center">
<a href="https://github.com/honghuangdc/soybean-admin/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
Copyright MIT © 2021 Soybean
</a>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,21 @@
<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')"
trigger-parent
@click="appStore.openThemeDrawer"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { Modal } from 'ant-design-vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
defineOptions({
name: 'UserAvatar'
});
const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
function loginOrRegister() {
toLogin();
}
function logout() {
Modal.confirm({
title: $t('common.tip'),
content: $t('common.logoutConfirm'),
okText: $t('common.confirm'),
cancelText: $t('common.cancel'),
onOk: () => {
authStore.resetStore();
}
});
}
</script>
<template>
<AButton v-if="!authStore.isLogin" @click="loginOrRegister">{{ $t('page.login.common.loginOrRegister') }}</AButton>
<ADropdown v-else placement="bottomRight" trigger="click">
<ButtonIcon>
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.user?.nickName }}</span>
</ButtonIcon>
<template #overlay>
<AMenu>
<AMenuItem @click="routerPushByKey('user-center')">
<div class="flex-center gap-8px">
<SvgIcon icon="ph:user-circle" class="text-icon" />
{{ $t('common.userCenter') }}
</div>
</AMenuItem>
<AMenuDivider />
<AMenuItem @click="logout">
<div class="flex-center gap-8px">
<SvgIcon icon="ph:sign-out" class="text-icon" />
{{ $t('common.logout') }}
</div>
</AMenuItem>
</AMenu>
</template>
</ADropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useFullscreen } from '@vueuse/core';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import HorizontalMenu from '../global-menu/base-menu.vue';
import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import { useMixMenuContext } from '../../context';
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 routeStore = useRouteStore();
const { isFullscreen, toggle } = useFullscreen();
const { menus } = useMixMenuContext();
const headerMenus = computed(() => {
if (themeStore.layout.mode === 'horizontal') {
return routeStore.menus;
}
if (themeStore.layout.mode === 'horizontal-mix') {
return menus.value;
}
return [];
});
</script>
<template>
<DarkModeContainer class="h-full flex-y-center shadow-header">
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
<HorizontalMenu v-if="showMenu" mode="horizontal" :menus="headerMenus" class="px-12px" />
<div v-else class="h-full flex-y-center flex-1-hidden">
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div>
<div class="h-full flex-y-center justify-end">
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
<LangSwitch :lang="appStore.locale" :lang-options="appStore.localeOptions" @change-lang="appStore.changeLocale" />
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<ThemeButton />
<UserAvatar />
</div>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,27 @@
<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,149 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuInfo, MenuMode } from 'ant-design-vue/es/menu/src/interface';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/utils';
import type { RouteKey } from '@elegant-router/types';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
defineOptions({
name: 'BaseMenu'
});
interface Props {
darkTheme?: boolean;
mode?: MenuMode;
menus: App.Global.Menu[];
}
const props = withDefaults(defineProps<Props>(), {
mode: 'inline'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const menuTheme = computed(() => (props.darkTheme ? 'dark' : 'light'));
const isHorizontal = computed(() => props.mode === 'horizontal');
const siderCollapse = computed(() => themeStore.layout.mode === 'vertical' && appStore.siderCollapse);
const inlineCollapsed = computed(() => (props.mode === 'inline' ? siderCollapse.value : undefined));
const selectedKeys = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return [routeName];
});
const openKeys = computed(() => {
if (isHorizontal.value || inlineCollapsed.value) return [];
const [selectedKey] = selectedKeys.value;
if (!selectedKey) return [];
return routeStore.getSelectedMenuKeyPath(selectedKey);
});
const headerHeight = computed(() => `${themeStore.header.height}px`);
const selectedBgColor = computed(() => {
const { darkMode, themeColor } = themeStore;
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
return darkMode ? dark : light;
});
function handleClickMenu(menuInfo: MenuInfo) {
const key = menuInfo.key as RouteKey;
const { query } = routeStore.getSelectedMenuMetaByKey(key) || {};
routerPushByKey(key, { query });
}
</script>
<template>
<SimpleScrollbar class="menu-wrapper" :class="{ 'select-menu': !darkTheme }">
<AMenu
:mode="mode"
:theme="menuTheme"
:items="menus"
:selected-keys="selectedKeys"
:open-keys="openKeys"
:inline-collapsed="inlineCollapsed"
:inline-indent="18"
class="size-full transition-300 border-0!"
:class="{ 'bg-container!': !darkTheme, 'horizontal-menu': isHorizontal }"
@click="handleClickMenu"
/>
</SimpleScrollbar>
</template>
<style lang="scss" scoped>
.menu-wrapper {
:deep(.ant-menu-inline) {
.ant-menu-item {
width: calc(100% - 16px);
margin-inline: 8px;
}
}
:deep(.ant-menu-submenu-title) {
width: calc(100% - 16px);
margin-inline: 8px;
}
:deep(.ant-menu-inline-collapsed) {
> .ant-menu-item {
padding-inline: calc(50% - 14px);
}
.ant-menu-item-icon {
vertical-align: -0.25em;
}
.ant-menu-submenu-title {
padding-inline: calc(50% - 14px);
}
}
:deep(.ant-menu-horizontal) {
.ant-menu-item {
display: flex;
align-items: center;
}
.ant-menu-submenu-title {
display: flex;
align-items: center;
}
}
}
.select-menu {
:deep(.ant-menu-inline) {
.ant-menu-item-selected {
background-color: v-bind(selectedBgColor);
}
}
}
.horizontal-menu {
line-height: v-bind(headerHeight);
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/utils';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({
name: 'FirstLevelMenu'
});
interface Props {
activeMenuKey?: string;
inverted?: boolean;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
interface Emits {
(e: 'select', menu: App.Global.Menu): boolean;
}
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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 } = themeStore;
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);
}
</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-24px pt-4px']"
>
{{ label }}
</p>
</div>
</DefineMixMenuItem>
<!-- template -->
<div class="h-full flex-col-stretch flex-1-hidden">
<slot></slot>
<SimpleScrollbar>
<MixMenuItem
v-for="menu in routeStore.menus"
:key="menu.key"
:label="menu.label"
:icon="menu.icon"
:active="menu.key === activeMenuKey"
:is-mini="appStore.siderCollapse"
@click="handleClickMixMenu(menu)"
/>
</SimpleScrollbar>
<MenuToggler
arrow-icon
:collapsed="appStore.siderCollapse"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleSiderCollapse"
/>
</div>
</template>
<style scoped>
.selected-mix-menu {
background-color: v-bind(selectedBgColor);
}
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenuContext } from '../../context';
import FirstLevelMenu from './first-level-menu.vue';
defineOptions({
name: 'HorizontalMixMenu'
});
const { activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { routerPushByKey } = useRouterPush();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKey(menu.routeKey);
}
}
</script>
<template>
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useBoolean } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenu } from '../../hooks';
import FirstLevelMenu from './first-level-menu.vue';
import BaseMenu from './base-menu.vue';
defineOptions({
name: 'VerticalMixMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const { activeFirstLevelMenuKey, setActiveFirstLevelMenuKey, getActiveFirstLevelMenuKey } = useMixMenu();
const siderInverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const menus = computed(() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []);
const showDrawer = computed(() => (drawerVisible.value && menus.value.length) || appStore.mixSiderFixed);
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKey(menu.routeKey);
}
}
function handleResetActiveMenu() {
getActiveFirstLevelMenuKey();
setDrawerVisible(false);
}
</script>
<template>
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" :inverted="siderInverted" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="siderInverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="pl-8px text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': siderInverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<BaseMenu :dark-theme="siderInverted" :menus="menus" />
</DarkModeContainer>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import GlobalLogo from '../global-logo/index.vue';
import VerticalMenu from '../global-menu/base-menu.vue';
import VerticalMixMenu from '../global-menu/vertical-mix-menu.vue';
import HorizontalMixMenu from '../global-menu/horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalSider'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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);
</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' }"
/>
<VerticalMixMenu v-if="isVerticalMix">
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</VerticalMixMenu>
<HorizontalMixMenu v-else-if="isHorizontalMix" />
<VerticalMenu v-else :dark-theme="darkMenu" :menus="routeStore.menus" />
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Trigger } from 'ant-design-vue/es/dropdown/props';
import { $t } from '@/locales';
import { useTabStore } from '@/store/modules/tab';
defineOptions({
name: 'ContextMenu'
});
interface Props {
tabId: string;
trigger?: Trigger[];
excludeKeys?: App.Global.DropdownKey[];
disabledKeys?: App.Global.DropdownKey[];
}
const props = withDefaults(defineProps<Props>(), {
trigger: () => ['contextmenu'],
excludeKeys: () => [],
disabledKeys: () => []
});
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore();
interface DropdownOption {
key: App.Global.DropdownKey;
label: string;
icon: string;
disabled?: boolean;
}
const options = computed(() => {
const opts: DropdownOption[] = [
{
key: 'closeCurrent',
label: $t('dropdown.closeCurrent'),
icon: 'ant-design:close-outlined'
},
{
key: 'closeOther',
label: $t('dropdown.closeOther'),
icon: 'ant-design:column-width-outlined'
},
{
key: 'closeLeft',
label: $t('dropdown.closeLeft'),
icon: 'mdi:format-horizontal-align-left'
},
{
key: 'closeRight',
label: $t('dropdown.closeRight'),
icon: 'mdi:format-horizontal-align-right'
},
{
key: 'closeAll',
label: $t('dropdown.closeAll'),
icon: 'ant-design:line-outlined'
}
];
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 dropdownAction: Record<App.Global.DropdownKey, () => void> = {
closeCurrent() {
removeTab(props.tabId);
},
closeOther() {
clearTabs([props.tabId]);
},
closeLeft() {
clearLeftTabs(props.tabId);
},
closeRight() {
clearRightTabs(props.tabId);
},
closeAll() {
clearTabs();
}
};
</script>
<template>
<ADropdown :trigger="trigger" placement="bottom" destroy-popup-on-hide>
<slot></slot>
<template #overlay>
<AMenu>
<AMenuItem
v-for="option in options"
:key="option.key"
:disabled="option.disabled"
@click="dropdownAction[option.key]"
>
<div class="flex-y-center gap-12px">
<SvgIcon :icon="option.icon" class="text-icon" />
<span>{{ option.label }}</span>
</div>
</AMenuItem>
</AMenu>
</template>
</ADropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,157 @@
<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 BetterScroll from '@/components/custom/better-scroll.vue';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useTabStore } from '@/store/modules/tab';
import ContextMenu from './context-menu.vue';
defineOptions({
name: 'GlobalTab'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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 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;
}
async function handleCloseTab(tab: App.Global.Tab) {
await tabStore.removeTab(tab.id);
await routeStore.reCacheRoutesByKey(tab.routeKey);
}
async function refresh() {
appStore.reloadPage(500);
}
function init() {
tabStore.initTabStore(route);
}
// 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: appStore.isMobile }">
<div
ref="tabRef"
class="h-full flex pr-18px"
:class="[themeStore.tab.mode === 'chrome' ? 'items-end' : 'items-center gap-12px']"
>
<ContextMenu
v-for="tab in tabStore.tabs"
:key="tab.id"
:tab-id="tab.id"
:disabled-keys="getContextMenuDisabledKeys(tab.id)"
>
<PageTab
:[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)"
>
<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>
</ContextMenu>
</div>
</BetterScroll>
</div>
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip';
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: TooltipPlacement;
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)"
>
<ATooltip :placement="item.placement" :title="$t(themeLayoutModeRecord[key])">
<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>
</ATooltip>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<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,43 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
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>
<ADrawer
:open="appStore.themeDrawerVisible"
:title="$t('theme.themeDrawerTitle')"
:closable="false"
:body-style="{ padding: '0px' }"
@close="appStore.closeThemeDrawer"
>
<template #extra>
<ButtonIcon icon="ant-design:close-outlined" class="h-28px" @click="appStore.closeThemeDrawer" />
</template>
<SimpleScrollbar>
<div class="px-24px pb-24px pt-8px">
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
</div>
</SimpleScrollbar>
<template #footer>
<ConfigOperation />
</template>
</ADrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Clipboard from 'clipboard';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({
name: 'ConfigOperation'
});
const themeStore = useThemeStore();
const domRef = ref<HTMLElement | null>(null);
function initClipboard() {
if (!domRef.value) return;
const clipboard = new Clipboard(domRef.value, {
text: () => getClipboardText()
});
clipboard.on('success', () => {
$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(() => {
$message?.success($t('theme.configOperation.resetSuccessMsg'));
}, 50);
}
onMounted(() => {
initClipboard();
});
</script>
<template>
<div class="flex justify-between">
<AButton danger @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</AButton>
<div ref="domRef">
<AButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</AButton>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { SegmentedOption } from 'ant-design-vue/es/segmented/src/segmented';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
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 getSegmentOptions() {
const opts: SegmentedOption[] = Object.keys(themeSchemaRecord).map(item => {
const key = item as UnionKey.ThemeScheme;
return {
value: item,
payload: {
icon: icons[key]
}
};
});
return opts;
}
const options = computed(() => getSegmentOptions());
function handleSegmentChange(value: string | number) {
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
}
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
</script>
<template>
<ADivider>{{ $t('theme.themeSchema.title') }}</ADivider>
<div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<ASegmented :value="themeStore.themeScheme" :options="options" class="bg-layout" @change="handleSegmentChange">
<template #label="{ payload }">
<ButtonIcon :icon="payload.icon" class="h-28px text-icon-small" />
</template>
</ASegmented>
</div>
<Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<ASwitch v-model:checked="themeStore.sider.inverted" />
</SettingItem>
</Transition>
</div>
</template>
<style 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,69 @@
<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';
defineOptions({
name: 'LayoutMode'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
</script>
<template>
<ADivider>{{ $t('theme.layoutMode.title') }}</ADivider>
<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>
</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,118 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
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>
<ADivider>{{ $t('theme.pageFunTitle') }}</ADivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<ASelect v-model:value="themeStore.layout.scrollMode" class="w-120px">
<ASelectOption v-for="option in themeScrollModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<ASwitch v-model:checked="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<ASelect v-model:value="themeStore.page.animateMode" class="w-120px">
<ASelectOption v-for="option in themePageAnimationModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<ASwitch v-model:checked="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<AInputNumber v-model:value="themeStore.header.height" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<ASwitch v-model:checked="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<ASwitch v-model:checked="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<ASwitch v-model:checked="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<ASwitch v-model:checked="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<AInputNumber v-model:value="themeStore.tab.height" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<ASelect v-model:value="themeStore.tab.mode" class="w-120px">
<ASelectOption v-for="option in themeTabModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<AInputNumber v-model:value="themeStore.sider.width" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<AInputNumber v-model:value="themeStore.sider.collapsedWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<AInputNumber v-model:value="themeStore.sider.mixWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<AInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<AInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<ASwitch v-model:checked="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<ASwitch v-model:checked="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<AInputNumber v-model:value="themeStore.footer.height" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<ASwitch v-model:checked="themeStore.footer.right" />
</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,36 @@
<script setup lang="ts">
import { ColorPicker } from '@sa/materials';
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, key: App.Theme.ThemeColorKey) {
themeStore.updateThemeColors(key, color);
}
</script>
<template>
<ADivider>{{ $t('theme.themeColor.title') }}</ADivider>
<div class="flex-col-stretch gap-12px">
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix>
<ACheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }}
</ACheckbox>
</template>
<ColorPicker
:color="themeStore.themeColors[key]"
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
@update:color="handleUpdateColor($event, key)"
/>
</SettingItem>
</div>
</template>
<style scoped></style>

8
src/locales/antd.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Locale } from 'ant-design-vue/es/locale';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import enUS from 'ant-design-vue/es/locale/en_US';
export const antdLocales: Record<App.I18n.LangType, Locale> = {
'zh-CN': zhCN,
'en-US': enUS
};

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]);
}

26
src/locales/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { App } from 'vue';
import { createI18n } from 'vue-i18n';
import { localStg } from '@/utils/storage';
import messages from './locale';
const i18n = createI18n({
locale: localStg.get('lang') || 'zh-CN',
fallbackLocale: 'en',
messages,
legacy: false
});
/**
* Setup plugin i18n
*
* @param app
*/
export function setupI18n(app: App) {
app.use(i18n);
}
export const $t = i18n.global.t as App.I18n.$T;
export function setLocale(locale: App.I18n.LangType) {
i18n.global.locale.value = locale;
}

492
src/locales/langs/en-us.ts Normal file
View File

@@ -0,0 +1,492 @@
const local: App.I18n.Schema = {
system: {
title: 'Vue-AntD-Web'
},
common: {
action: 'Action',
add: 'Add',
addSuccess: 'Add Success',
backToHome: 'Back to home',
batchDelete: 'Batch Delete',
cancel: 'Cancel',
close: 'Close',
check: 'Check',
columnSetting: 'Column Setting',
config: 'Config',
confirm: 'Confirm',
delete: 'Delete',
deleteSuccess: 'Delete Success',
confirmDelete: 'Are you sure you want to delete?',
edit: 'Edit',
index: 'Index',
keywordSearch: 'Please enter keyword',
logout: 'Logout',
logoutConfirm: 'Are you sure you want to log out?',
lookForward: 'Coming soon',
modify: 'Modify',
modifySuccess: 'Modify Success',
noData: 'No Data',
operate: 'Operate',
pleaseCheckValue: 'Please check whether the value is valid',
refresh: 'Refresh',
reset: 'Reset',
search: 'Search',
switch: 'Switch',
tip: 'Tip',
trigger: 'Trigger',
update: 'Update',
updateSuccess: 'Update Success',
userCenter: 'User Center',
yesOrNo: {
yes: 'Yes',
no: 'No'
}
},
request: {
logout: 'Logout user after request failed',
logoutMsg: 'User status is invalid, please log in again',
logoutWithModal: 'Pop up modal after request failed and then log out user',
logoutWithModalMsg: 'User status is invalid, please log in again',
refreshToken: 'The requested token has expired, refresh the token',
tokenExpired: 'The requested token has expired'
},
theme: {
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System'
},
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Menu Mode',
horizontal: 'Horizontal Menu Mode',
'vertical-mix': 'Vertical Mix Menu Mode',
'horizontal-mix': 'Horizontal Mix menu Mode'
},
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary'
},
scrollMode: {
title: 'Scroll Mode',
wrapper: 'Wrapper',
content: 'Content'
},
page: {
animate: 'Page Animate',
mode: {
title: 'Page Animate Mode',
fade: 'Fade',
'fade-slide': 'Slide',
'fade-bottom': 'Fade Zoom',
'fade-scale': 'Fade Scale',
'zoom-fade': 'Zoom Fade',
'zoom-out': 'Zoom Out',
none: 'None'
}
},
fixedHeaderAndTab: 'Fixed Header And Tab',
header: {
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
}
},
tab: {
visible: 'Tab Visible',
cache: 'Tab Cache',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
sider: {
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function',
configOperation: {
copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
resetConfig: 'Reset Config',
resetSuccessMsg: 'Reset Success'
}
},
route: {
login: 'Login',
403: 'No Permission',
404: 'Page Not Found',
500: 'Server Error',
home: 'Home',
'user-center': 'User Center',
about: 'About',
function: 'System Function',
function_tab: 'Tab',
'function_multi-tab': 'Multi Tab',
'function_hide-child': 'Hide Child',
'function_hide-child_one': 'Hide Child',
'function_hide-child_two': 'Two',
'function_hide-child_three': 'Three',
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
manage: 'System Manage',
manage_user: 'User Manage',
'manage_user-detail': 'User Detail',
manage_role: 'Role Manage',
manage_menu: 'Menu Manage',
exception: 'Exception',
exception_403: '403',
exception_404: '404',
exception_500: '500',
manage_dept: 'Dept Manage',
manage_route: 'Route Manage',
manage_post: 'Post Manage',
manage_dict: 'Dict Manage'
},
page: {
login: {
common: {
loginOrRegister: 'Login / Register',
userNamePlaceholder: 'Please enter user name',
phonePlaceholder: 'Please enter phone number',
codePlaceholder: 'Please enter verification code',
passwordPlaceholder: 'Please enter password',
confirmPasswordPlaceholder: 'Please enter password again',
codeLogin: 'Verification code login',
confirm: 'Confirm',
back: 'Back',
validateSuccess: 'Verification passed',
loginSuccess: 'Login successfully',
welcomeBack: 'Welcome back, {username} !',
checkCode: 'Please check the verification code'
},
pwdLogin: {
title: 'Password Login',
rememberMe: 'Remember me',
forgetPassword: 'Forget password?',
register: 'Register',
otherAccountLogin: 'Other Account Login',
otherLoginMode: 'Other Login Mode',
superAdmin: 'Super Admin',
admin: 'Admin',
user: 'User'
},
codeLogin: {
title: 'Verification Code Login',
getCode: 'Get verification code',
reGetCode: 'Reacquire after {time}s',
sendCodeSuccess: 'Verification code sent successfully',
imageCodePlaceholder: 'Please enter image verification code'
},
register: {
title: 'Register',
agreement: 'I have read and agree to',
protocol: '《User Agreement》',
policy: '《Privacy Policy》'
},
resetPwd: {
title: 'Reset Password'
},
bindWeChat: {
title: 'Bind WeChat'
}
},
about: {
title: 'About',
introduction: `Soybean Admin is an elegant and powerful admin template, based on the latest front-end technology stack, including Vue3, Vite5, TypeScript, Pinia and UnoCSS. It has built-in rich theme configuration and components, strict code specifications, and an automated file routing system. In addition, it also uses the online mock data solution based on ApiFox. Soybean Admin provides you with a one-stop admin solution, no additional configuration, and out of the box. It is also a best practice for learning cutting-edge technologies quickly.`,
projectInfo: {
title: 'Project Info',
version: 'Version',
latestBuildTime: 'Latest Build Time',
githubLink: 'Github Link',
previewLink: 'Preview Link'
},
prdDep: 'Production Dependency',
devDep: 'Development Dependency'
},
home: {
greeting: 'Good morning, {username}, today is another day full of vitality!',
weatherDesc: 'Today is cloudy to clear, 20℃ - 25℃!',
projectCount: 'Project Count',
todo: 'Todo',
message: 'Message',
downloadCount: 'Download Count',
registerCount: 'Register Count',
schedule: 'Work and rest Schedule',
study: 'Study',
work: 'Work',
rest: 'Rest',
entertainment: 'Entertainment',
visitCount: 'Visit Count',
turnover: 'Turnover',
dealCount: 'Deal Count',
projectNews: {
title: 'Project News',
moreNews: 'More News',
desc1: 'Soybean created the open source project soybean-admin on May 28, 2021!',
desc2: 'zyh submitted a bug to soybean-admin, the multi-tab bar will not adapt.',
desc3: 'Soybean is ready to do sufficient preparation for the release of soybean-admin!',
desc4: 'Soybean is busy writing project documentation for soybean-admin!',
desc5: 'Soybean just wrote some of the workbench pages casually, and it was enough to see!'
},
creativity: 'Creativity'
},
function: {
tab: {
tabOperate: {
title: 'Tab Operation',
addTab: 'Add Tab',
addTabDesc: 'To about page',
closeTab: 'Close Tab',
closeCurrentTab: 'Close Current Tab',
closeAboutTab: 'Close "About" Tab',
addMultiTab: 'Add Multi Tab',
addMultiTabDesc1: 'To MultiTab page',
addMultiTabDesc2: 'To MultiTab page(with query params)'
},
tabTitle: {
title: 'Tab Title',
changeTitle: 'Change Title',
change: 'Change',
resetTitle: 'Reset Title',
reset: 'Reset'
}
},
multiTab: {
routeParam: 'Route Param',
backTab: 'Back function_tab'
},
toggleAuth: {
toggleAccount: 'Toggle Account',
authHook: 'Auth Hook Function `hasAuth`',
superAdminVisible: 'Super Admin Visible',
adminVisible: 'Admin Visible',
adminOrUserVisible: 'Admin and User Visible'
}
},
manage: {
common: {
status: {
enable: 'Enable',
disable: 'Disable'
}
},
role: {
title: 'Role List',
roleName: 'Role Name',
roleCode: 'Role Code',
roleStatus: 'Role Status',
roleDesc: 'Role Description',
menuAuth: 'Menu Auth',
buttonAuth: 'Button Auth',
form: {
roleName: 'Please enter role name',
roleCode: 'Please enter role code',
roleStatus: 'Please select role status',
roleDesc: 'Please enter role description'
},
addRole: 'Add Role',
editRole: 'Edit Role'
},
user: {
userName: 'Username',
nickName: 'Nickname',
email: 'Email',
phonenumber: 'Phone number',
status: 'Status',
dept: 'Department',
title: 'User Management',
addUser: 'Add User',
editUser: 'Edit User',
remark: 'Remark',
password: 'Password',
post: 'Post',
role: 'Role',
form: {
userName: 'Please enter username',
email: 'Please enter email',
status: 'Please select status',
nickName: 'Please enter nickname',
phonenumber: 'Please enter phone number',
remark: 'Please enter remark',
password: 'Please enter password',
dept: 'Please select department'
}
},
menu: {
home: 'Home',
title: 'Menu List',
id: 'ID',
parentId: 'Parent ID',
menuType: 'Menu Type',
menuName: 'Menu Name',
routeName: 'Route Name',
routePath: 'Route Path',
routeParams: 'Route Params',
layout: 'Layout Component',
page: 'Page Component',
i18nKey: 'I18n Key',
icon: 'Icon',
localIcon: 'Local Icon',
iconTypeTitle: 'Icon Type',
order: 'Order',
keepAlive: 'Keep Alive',
href: 'Href',
hideInMenu: 'Hide In Menu',
activeMenu: 'Active Menu',
multiTab: 'Multi Tab',
fixedIndexInTab: 'Fixed Index In Tab',
button: 'Button',
buttonCode: 'Button Code',
buttonDesc: 'Button Desc',
menuStatus: 'Menu Status',
form: {
home: 'Please select home',
menuType: 'Please select menu type',
menuName: 'Please enter menu name',
routeName: 'Please enter route name',
routePath: 'Please enter route path',
page: 'Please select page component',
layout: 'Please select layout component',
i18nKey: 'Please enter i18n key',
icon: 'Please enter iconify name',
localIcon: 'Please enter local icon name',
order: 'Please enter order',
keepAlive: 'Please select whether to cache route',
href: 'Please enter href',
hideInMenu: 'Please select whether to hide menu',
activeMenu: 'Please enter the route name of the highlighted menu',
multiTab: 'Please select whether to support multiple tabs',
fixedInTab: 'Please select whether to fix in the tab',
fixedIndexInTab: 'Please enter the index fixed in the tab',
button: 'Please select whether it is a button',
buttonCode: 'Please enter button code',
buttonDesc: 'Please enter button description',
menuStatus: 'Please select menu status'
},
addMenu: 'Add Menu',
editMenu: 'Edit Menu',
addChildMenu: 'Add Child Menu',
type: {
directory: 'Directory',
menu: 'Menu'
},
iconType: {
iconify: 'Iconify Icon',
local: 'Local Icon'
}
},
dept: {
deptName: 'Department Name',
leader: 'Leader',
status: 'Status',
form: {
deptName: 'Please enter department name',
leader: 'Please enter leader name',
status: 'Please select status'
}
},
post: {
addPost: 'Add Post',
editPost: 'Edit Post',
postCode: 'Post Code',
postName: 'Post Name',
postSort: 'Post Sort',
status: 'Status',
remark: 'Remark',
title: 'Post list',
form: {
postCode: 'Please enter the post code',
postName: 'Please enter the post name',
postSort: 'Please enter the post sort',
remark: 'Please enter the remark',
status: 'Please select status'
}
},
dict: {
title: 'Dictionary Management',
dictName: 'Dictionary Name',
dictType: 'Dictionary Type',
status: 'Status',
remark: 'Remark',
form: {
dictName: 'Please enter dictionary name',
dictType: 'Please enter dictionary type',
status: 'Please select status',
remark: 'Please enter remark'
},
addDict: 'Add Dictionary',
editDict: 'Edit Dictionary'
}
}
},
form: {
required: 'Cannot be empty',
username: {
required: 'Please enter user name',
invalid: 'User name format is incorrect'
},
phone: {
required: 'Please enter phone number',
invalid: 'Phone number format is incorrect'
},
pwd: {
required: 'Please enter password',
invalid: '6-18 characters, including letters, numbers, and underscores'
},
confirmPwd: {
required: 'Please enter password again',
invalid: 'The two passwords are inconsistent'
},
code: {
required: 'Please enter verification code',
invalid: 'Verification code format is incorrect'
},
email: {
required: 'Please enter email',
invalid: 'Email format is incorrect'
}
},
dropdown: {
closeCurrent: 'Close Current',
closeOther: 'Close Other',
closeLeft: 'Close Left',
closeRight: 'Close Right',
closeAll: 'Close All'
},
icon: {
themeConfig: 'Theme Configuration',
themeSchema: 'Theme Schema',
lang: 'Switch Language',
fullscreen: 'Fullscreen',
fullscreenExit: 'Exit Fullscreen',
reload: 'Reload Page',
collapse: 'Collapse Menu',
expand: 'Expand Menu',
pin: 'Pin',
unpin: 'Unpin'
}
};
export default local;

492
src/locales/langs/zh-cn.ts Normal file
View File

@@ -0,0 +1,492 @@
const local: App.I18n.Schema = {
system: {
title: 'Vue-AntD-Web'
},
common: {
action: '操作',
add: '新增',
addSuccess: '添加成功',
backToHome: '返回首页',
batchDelete: '批量删除',
cancel: '取消',
close: '关闭',
check: '勾选',
columnSetting: '列设置',
config: '配置',
confirm: '确认',
delete: '删除',
deleteSuccess: '删除成功',
confirmDelete: '确认删除吗?',
edit: '编辑',
index: '序号',
keywordSearch: '请输入关键词搜索',
logout: '退出登录',
logoutConfirm: '确认退出登录吗?',
lookForward: '敬请期待',
modify: '修改',
modifySuccess: '修改成功',
noData: '无数据',
operate: '操作',
pleaseCheckValue: '请检查输入的值是否合法',
refresh: '刷新',
reset: '重置',
search: '搜索',
switch: '切换',
tip: '提示',
trigger: '触发',
update: '更新',
updateSuccess: '更新成功',
userCenter: '个人中心',
yesOrNo: {
yes: '是',
no: '否'
}
},
request: {
logout: '请求失败后登出用户',
logoutMsg: '用户状态失效,请重新登录',
logoutWithModal: '请求失败后弹出模态框再登出用户',
logoutWithModalMsg: '用户状态失效,请重新登录',
refreshToken: '请求的token已过期刷新token',
tokenExpired: 'token已过期'
},
theme: {
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统'
},
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
horizontal: '顶部菜单模式',
'horizontal-mix': '顶部菜单混合模式'
},
themeColor: {
title: '主题颜色',
primary: '主色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色'
},
scrollMode: {
title: '滚动模式',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
}
},
fixedHeaderAndTab: '固定头部和标签栏',
header: {
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
}
},
tab: {
visible: '显示标签栏',
cache: '缓存标签页',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
sider: {
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
resetConfig: '重置配置',
resetSuccessMsg: '重置成功'
}
},
route: {
login: '登录',
403: '无权限',
404: '页面不存在',
500: '服务器错误',
home: '首页',
'user-center': '个人中心',
about: '关于',
function: '系统功能',
function_tab: '标签页',
'function_multi-tab': '多标签页',
'function_hide-child': '隐藏子菜单',
'function_hide-child_one': '隐藏子菜单',
'function_hide-child_two': '菜单二',
'function_hide-child_three': '菜单三',
function_request: '请求',
'function_toggle-auth': '切换权限',
'function_super-page': '超级管理员可见',
manage: '系统管理',
manage_user: '用户管理',
'manage_user-detail': '用户详情',
manage_role: '角色管理',
manage_menu: '菜单管理',
exception: '异常页',
exception_403: '403',
exception_404: '404',
exception_500: '500',
manage_dept: '部门管理',
manage_route: '路由管理',
manage_post: '岗位管理',
manage_dict: '字典管理'
},
page: {
login: {
common: {
loginOrRegister: '登录 / 注册',
userNamePlaceholder: '请输入用户名',
phonePlaceholder: '请输入手机号',
codePlaceholder: '请输入验证码',
passwordPlaceholder: '请输入密码',
confirmPasswordPlaceholder: '请再次输入密码',
codeLogin: '验证码登录',
confirm: '确定',
back: '返回',
validateSuccess: '验证成功',
loginSuccess: '登录成功',
welcomeBack: '欢迎回来,{username} ',
checkCode: '请输入验证码'
},
pwdLogin: {
title: '密码登录',
rememberMe: '记住我',
forgetPassword: '忘记密码?',
register: '注册账号',
otherAccountLogin: '其他账号登录',
otherLoginMode: '其他登录方式',
superAdmin: '超级管理员',
admin: '管理员',
user: '普通用户'
},
codeLogin: {
title: '验证码登录',
getCode: '获取验证码',
reGetCode: '{time}秒后重新获取',
sendCodeSuccess: '验证码发送成功',
imageCodePlaceholder: '请输入图片验证码'
},
register: {
title: '注册账号',
agreement: '我已经仔细阅读并接受',
protocol: '《用户协议》',
policy: '《隐私权政策》'
},
resetPwd: {
title: '重置密码'
},
bindWeChat: {
title: '绑定微信'
}
},
about: {
title: '关于',
introduction: `Soybean Admin 是一个优雅且功能强大的后台管理模板,基于最新的前端技术栈,包括 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件代码规范严谨实现了自动化的文件路由系统。此外它还采用了基于 ApiFox 的在线Mock数据方案。Soybean Admin 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。`,
projectInfo: {
title: '项目信息',
version: '版本',
latestBuildTime: '最新构建时间',
githubLink: 'Github 地址',
previewLink: '预览地址'
},
prdDep: '生产依赖',
devDep: '开发依赖'
},
home: {
greeting: '早安,{username}, 今天又是充满活力的一天!',
weatherDesc: '今日多云转晴20℃ - 25℃!',
projectCount: '项目数',
todo: '待办',
message: '消息',
downloadCount: '下载量',
registerCount: '注册量',
schedule: '作息安排',
study: '学习',
work: '工作',
rest: '休息',
entertainment: '娱乐',
visitCount: '访问量',
turnover: '成交额',
dealCount: '成交量',
projectNews: {
title: '项目动态',
moreNews: '更多动态',
desc1: 'Soybean 在2021年5月28日创建了开源项目 soybean-admin!',
desc2: 'soybean-admin 提交了一个bug多标签栏不会自适应。',
desc3: 'Soybean 准备为 soybean-admin 的发布做充分的准备工作!',
desc4: 'Soybean 正在忙于为soybean-admin写项目说明文档',
desc5: 'Soybean 刚才把工作台页面随便写了一些,凑合能看了!'
},
creativity: '创意'
},
function: {
tab: {
tabOperate: {
title: '标签页操作',
addTab: '添加标签页',
addTabDesc: '跳转到关于页面',
closeTab: '关闭标签页',
closeCurrentTab: '关闭当前标签页',
closeAboutTab: '关闭"关于"标签页',
addMultiTab: '添加多标签页',
addMultiTabDesc1: '跳转到多标签页页面',
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
},
tabTitle: {
title: '标签页标题',
changeTitle: '修改标题',
change: '修改',
resetTitle: '重置标题',
reset: '重置'
}
},
multiTab: {
routeParam: '路由参数',
backTab: '返回 function_tab'
},
toggleAuth: {
toggleAccount: '切换账号',
authHook: '权限钩子函数 `hasAuth`',
superAdminVisible: '超级管理员可见',
adminVisible: '管理员可见',
adminOrUserVisible: '管理员和用户可见'
}
},
manage: {
common: {
status: {
enable: '启用',
disable: '禁用'
}
},
role: {
title: '角色列表',
roleName: '角色名称',
roleCode: '角色编码',
roleStatus: '角色状态',
roleDesc: '角色描述',
menuAuth: '菜单权限',
buttonAuth: '按钮权限',
form: {
roleName: '请输入角色名称',
roleCode: '请输入角色编码',
roleStatus: '请选择角色状态',
roleDesc: '请输入角色描述'
},
addRole: '新增角色',
editRole: '编辑角色'
},
user: {
userName: '用户名',
nickName: '昵称',
email: '电子邮件',
phonenumber: '电话号码',
status: '状态',
dept: '部门',
title: '用户管理',
addUser: '新增用户',
editUser: '编辑用户',
remark: '备注',
password: '密码',
role: '角色',
post: '岗位',
form: {
userName: '请输入用户名',
email: '请输入电子邮件',
status: '请选择状态',
nickName: '请输入昵称',
phonenumber: '请输入电话号码',
remark: '请输入备注',
password: '请输入密码',
dept: '请选择部门'
}
},
menu: {
home: '首页',
title: '菜单列表',
id: 'ID',
parentId: '父级菜单ID',
menuType: '菜单类型',
menuName: '菜单名称',
routeName: '路由名称',
routePath: '路由路径',
routeParams: '路由参数',
layout: '布局',
page: '页面组件',
i18nKey: '国际化key',
icon: '图标',
localIcon: '本地图标',
iconTypeTitle: '图标类型',
order: '排序',
keepAlive: '缓存路由',
href: '外链',
hideInMenu: '隐藏菜单',
activeMenu: '高亮的菜单',
multiTab: '支持多页签',
fixedIndexInTab: '固定在页签中的序号',
button: '按钮',
buttonCode: '按钮编码',
buttonDesc: '按钮描述',
menuStatus: '菜单状态',
form: {
home: '请选择首页',
menuType: '请选择菜单类型',
menuName: '请输入菜单名称',
routeName: '请输入路由名称',
routePath: '请输入路由路径',
page: '请选择页面组件',
layout: '请选择布局组件',
i18nKey: '请输入国际化key',
icon: '请输入图标',
localIcon: '请选择本地图标',
order: '请输入排序',
keepAlive: '请选择是否缓存路由',
href: '请输入外链',
hideInMenu: '请选择是否隐藏菜单',
activeMenu: '请输入高亮的菜单的路由名称',
multiTab: '请选择是否支持多标签',
fixedInTab: '请选择是否固定在页签中',
fixedIndexInTab: '请输入固定在页签中的序号',
button: '请选择是否按钮',
buttonCode: '请输入按钮编码',
buttonDesc: '请输入按钮描述',
menuStatus: '请选择菜单状态'
},
addMenu: '新增菜单',
editMenu: '编辑菜单',
addChildMenu: '新增子菜单',
type: {
directory: '目录',
menu: '菜单'
},
iconType: {
iconify: 'iconify图标',
local: '本地图标'
}
},
dept: {
deptName: '部门名称',
leader: '负责人',
status: '状态',
form: {
deptName: '请输入部门名称',
leader: '请输入负责人',
status: '请选择状态'
}
},
post: {
addPost: '新增岗位',
editPost: '编辑岗位',
postCode: '岗位编码',
postName: '岗位名称',
postSort: '岗位顺序',
status: '状态',
title: '岗位列表',
remark: '备注',
form: {
postCode: '请输入岗位编码',
postName: '请输入岗位名称',
postSort: '请输入岗位顺序',
remark: '请输入备注',
status: '请选择状态'
}
},
dict: {
title: '字典管理',
dictName: '字典名称',
dictType: '字典类型',
status: '状态',
remark: '备注',
form: {
dictName: '请输入字典名称',
dictType: '请输入字典类型',
status: '请选择状态',
remark: '请输入备注'
},
addDict: '新增字典',
editDict: '编辑字典'
}
}
},
form: {
required: '不能为空',
username: {
required: '请输入用户名',
invalid: '用户名格式不正确'
},
phone: {
required: '请输入手机号',
invalid: '手机号格式不正确'
},
pwd: {
required: '请输入密码',
invalid: '密码格式不正确6-18位字符包含字母、数字、下划线'
},
confirmPwd: {
required: '请输入确认密码',
invalid: '两次输入密码不一致'
},
code: {
required: '请输入验证码',
invalid: '验证码格式不正确'
},
email: {
required: '请输入邮箱',
invalid: '邮箱格式不正确'
}
},
dropdown: {
closeCurrent: '关闭',
closeOther: '关闭其它',
closeLeft: '关闭左侧',
closeRight: '关闭右侧',
closeAll: '关闭所有'
},
icon: {
themeConfig: '主题配置',
themeSchema: '主题模式',
lang: '切换语言',
fullscreen: '全屏',
fullscreenExit: '退出全屏',
reload: '刷新页面',
collapse: '折叠菜单',
expand: '展开菜单',
pin: '固定',
unpin: '取消固定'
}
};
export default local;

9
src/locales/locale.ts Normal file
View File

@@ -0,0 +1,9 @@
import zhCN from './langs/zh-cn';
import enUS from './langs/en-us';
const locales: Record<App.I18n.LangType, App.I18n.Schema> = {
'zh-CN': zhCN,
'en-US': enUS
};
export default locales;

29
src/main.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createApp } from 'vue';
import './plugins/assets';
import { setupDayjs, setupIconifyOffline, setupLoading, setupNProgress } from './plugins';
import { setupStore } from './store';
import { setupRouter } from './router';
import { setupI18n } from './locales';
import App from './App.vue';
async function setupApp() {
setupLoading();
setupNProgress();
setupIconifyOffline();
setupDayjs();
const app = createApp(App);
setupStore(app);
await setupRouter(app);
setupI18n(app);
app.mount('#app');
}
setupApp();

3
src/plugins/assets.ts Normal file
View File

@@ -0,0 +1,3 @@
import 'virtual:svg-icons-register';
import 'uno.css';
import '../styles/css/global.css';

9
src/plugins/dayjs.ts Normal file
View File

@@ -0,0 +1,9 @@
import { extend } from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import { setDayjsLocale } from '../locales/dayjs';
export function setupDayjs() {
extend(localeData);
setDayjsLocale();
}

12
src/plugins/iconify.ts Normal file
View File

@@ -0,0 +1,12 @@
import { addAPIProvider, disableCache } from '@iconify/vue';
/** Setup the iconify offline */
export function setupIconifyOffline() {
const { VITE_ICONIFY_URL } = import.meta.env;
if (VITE_ICONIFY_URL) {
addAPIProvider('', { resources: [VITE_ICONIFY_URL] });
disableCache('all');
}
}

4
src/plugins/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './loading';
export * from './nprogress';
export * from './iconify';
export * from './dayjs';

45
src/plugins/loading.ts Normal file
View File

@@ -0,0 +1,45 @@
// @unocss-include
import { getRgbOfColor } from '@sa/utils';
import { $t } from '@/locales';
import { localStg } from '@/utils/storage';
import systemLogo from '@/assets/svg-icon/logo.svg?raw';
export function setupLoading() {
const themeColor = localStg.get('themeColor') || '#646cff';
const { r, g, b } = getRgbOfColor(themeColor);
const primaryColor = `--primary-color: ${r} ${g} ${b}`;
const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];
const logoWithClass = systemLogo.replace('<svg', `<svg class="size-128px text-primary"`);
const dot = loadingClasses
.map(item => {
return `<div class="absolute w-16px h-16px bg-primary rounded-8px animate-pulse ${item}"></div>`;
})
.join('\n');
const loading = `
<div class="fixed-center flex-col" style="${primaryColor}">
${logoWithClass}
<div class="w-56px h-56px my-36px">
<div class="relative h-full animate-spin">
${dot}
</div>
</div>
<h2 class="text-28px font-500 text-#646464">${$t('system.title')}</h2>
</div>`;
const app = document.getElementById('app');
if (app) {
app.innerHTML = loading;
}
}

9
src/plugins/nprogress.ts Normal file
View File

@@ -0,0 +1,9 @@
import NProgress from 'nprogress';
/** Setup plugin NProgress */
export function setupNProgress() {
NProgress.configure({ easing: 'ease', speed: 500 });
// mount on window
window.NProgress = NProgress;
}

View File

@@ -0,0 +1,41 @@
/* eslint-disable */
/* prettier-ignore */
// Generated by elegant-router
// Read more: https://github.com/soybeanjs/elegant-router
import type { RouteComponent } from "vue-router";
import type { LastLevelRouteKey, RouteLayout } from "@elegant-router/types";
import BaseLayout from "@/layouts/base-layout/index.vue";
import BlankLayout from "@/layouts/blank-layout/index.vue";
export const layouts: Record<RouteLayout, RouteComponent | (() => Promise<RouteComponent>)> = {
base: BaseLayout,
blank: BlankLayout,
};
export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<RouteComponent>)> = {
403: () => import("@/views/_builtin/403/index.vue"),
404: () => import("@/views/_builtin/404/index.vue"),
500: () => import("@/views/_builtin/500/index.vue"),
login: () => import("@/views/_builtin/login/index.vue"),
about: () => import("@/views/about/index.vue"),
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"),
function_request: () => import("@/views/function/request/index.vue"),
"function_super-page": () => import("@/views/function/super-page/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
home: () => import("@/views/home/index.vue"),
manage_dept: () => import("@/views/manage/dept/index.vue"),
manage_dict: () => import("@/views/manage/dict/index.vue"),
manage_menu: () => import("@/views/manage/menu/index.vue"),
manage_post: () => import("@/views/manage/post/index.vue"),
manage_role: () => import("@/views/manage/role/index.vue"),
manage_route: () => import("@/views/manage/route/index.vue"),
"manage_user-detail": () => import("@/views/manage/user-detail/[id].vue"),
manage_user: () => import("@/views/manage/user/index.vue"),
"user-center": () => import("@/views/user-center/index.vue"),
};

View File

@@ -0,0 +1,306 @@
/* eslint-disable */
/* prettier-ignore */
// Generated by elegant-router
// Read more: https://github.com/soybeanjs/elegant-router
import type { GeneratedRoute } from '@elegant-router/types';
export const generatedRoutes: GeneratedRoute[] = [
{
name: '403',
path: '/403',
component: 'layout.blank$view.403',
meta: {
title: '403',
i18nKey: 'route.403',
constant: true,
hideInMenu: true
}
},
{
name: '404',
path: '/404',
component: 'layout.blank$view.404',
meta: {
title: '404',
i18nKey: 'route.404',
constant: true,
hideInMenu: true
}
},
{
name: '500',
path: '/500',
component: 'layout.blank$view.500',
meta: {
title: '500',
i18nKey: 'route.500',
constant: true,
hideInMenu: true
}
},
{
name: 'about',
path: '/about',
component: 'layout.base$view.about',
meta: {
title: 'about',
i18nKey: 'route.about',
icon: 'fluent:book-information-24-regular',
order: 10
}
},
{
name: 'function',
path: '/function',
component: 'layout.base',
meta: {
title: 'function',
i18nKey: 'route.function',
icon: 'icon-park-outline:all-application',
order: 6
},
children: [
{
name: 'function_hide-child',
path: '/function/hide-child',
meta: {
title: 'function_hide-child',
i18nKey: 'route.function_hide-child',
icon: 'material-symbols:filter-list-off',
order: 2
},
redirect: '/function/hide-child/one',
children: [
{
name: 'function_hide-child_one',
path: '/function/hide-child/one',
component: 'view.function_hide-child_one',
meta: {
title: 'function_hide-child_one',
i18nKey: 'route.function_hide-child_one',
icon: 'material-symbols:filter-list-off',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_three',
path: '/function/hide-child/three',
component: 'view.function_hide-child_three',
meta: {
title: 'function_hide-child_three',
i18nKey: 'route.function_hide-child_three',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_two',
path: '/function/hide-child/two',
component: 'view.function_hide-child_two',
meta: {
title: 'function_hide-child_two',
i18nKey: 'route.function_hide-child_two',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
}
]
},
{
name: 'function_multi-tab',
path: '/function/multi-tab',
component: 'view.function_multi-tab',
meta: {
title: 'function_multi-tab',
i18nKey: 'route.function_multi-tab',
icon: 'ic:round-tab',
multiTab: true,
hideInMenu: true,
activeMenu: 'function_tab'
}
},
{
name: 'function_request',
path: '/function/request',
component: 'view.function_request',
meta: {
title: 'function_request',
i18nKey: 'route.function_request',
icon: 'carbon:network-overlay',
order: 3
}
},
{
name: 'function_super-page',
path: '/function/super-page',
component: 'view.function_super-page',
meta: {
title: 'function_super-page',
i18nKey: 'route.function_super-page',
icon: 'ic:round-supervisor-account',
order: 5,
roles: ['R_SUPER']
}
},
{
name: 'function_tab',
path: '/function/tab',
component: 'view.function_tab',
meta: {
title: 'function_tab',
i18nKey: 'route.function_tab',
icon: 'ic:round-tab',
order: 1
}
},
{
name: 'function_toggle-auth',
path: '/function/toggle-auth',
component: 'view.function_toggle-auth',
meta: {
title: 'function_toggle-auth',
i18nKey: 'route.function_toggle-auth',
icon: 'ic:round-construction',
order: 4
}
}
]
},
{
name: 'home',
path: '/home',
component: 'layout.base$view.home',
meta: {
title: 'home',
i18nKey: 'route.home',
icon: 'mdi:monitor-dashboard',
order: 1
}
},
{
name: 'login',
path: '/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?',
component: 'layout.blank$view.login',
props: true,
meta: {
title: 'login',
i18nKey: 'route.login',
constant: true,
hideInMenu: true
}
},
{
name: 'manage',
path: '/manage',
component: 'layout.base',
meta: {
title: 'manage',
i18nKey: 'route.manage',
icon: 'carbon:cloud-service-management',
order: 9,
roles: ['R_ADMIN']
},
children: [
{
name: 'manage_dept',
path: '/manage/dept',
component: 'view.manage_dept',
meta: {
title: 'manage_dept',
i18nKey: 'route.manage_dept'
}
},
{
name: 'manage_dict',
path: '/manage/dict',
component: 'view.manage_dict',
meta: {
title: 'manage_dict',
i18nKey: 'route.manage_dict'
}
},
{
name: 'manage_menu',
path: '/manage/menu',
component: 'view.manage_menu',
meta: {
title: 'manage_menu',
i18nKey: 'route.manage_menu',
icon: 'material-symbols:route',
order: 3,
roles: ['R_ADMIN'],
keepAlive: true
}
},
{
name: 'manage_post',
path: '/manage/post',
component: 'view.manage_post',
meta: {
title: 'manage_post',
i18nKey: 'route.manage_post'
}
},
{
name: 'manage_role',
path: '/manage/role',
component: 'view.manage_role',
meta: {
title: 'manage_role',
i18nKey: 'route.manage_role',
icon: 'carbon:user-role',
order: 2,
roles: ['R_SUPER']
}
},
{
name: 'manage_route',
path: '/manage/route',
component: 'view.manage_route',
meta: {
title: 'manage_route',
i18nKey: 'route.manage_route'
}
},
{
name: 'manage_user',
path: '/manage/user',
component: 'view.manage_user',
meta: {
title: 'manage_user',
i18nKey: 'route.manage_user',
icon: 'ic:round-manage-accounts',
order: 1,
roles: ['R_ADMIN']
}
},
{
name: 'manage_user-detail',
path: '/manage/user-detail/:id',
component: 'view.manage_user-detail',
props: true,
meta: {
title: 'manage_user-detail',
i18nKey: 'route.manage_user-detail',
hideInMenu: true,
roles: ['R_ADMIN'],
activeMenu: 'manage_user'
}
}
]
},
{
name: 'user-center',
path: '/user-center',
component: 'layout.base$view.user-center',
meta: {
title: 'user-center',
hideInMenu: true,
constant: true,
keepAlive: false,
i18nKey: 'route.user-center'
}
}
];

View File

@@ -0,0 +1,214 @@
/* eslint-disable */
/* prettier-ignore */
// Generated by elegant-router
// Read more: https://github.com/soybeanjs/elegant-router
import type { RouteRecordRaw, RouteComponent } from 'vue-router';
import type { ElegantConstRoute } from '@elegant-router/vue';
import type { RouteMap, RouteKey, RoutePath } from '@elegant-router/types';
/**
* transform elegant const routes to vue routes
* @param routes elegant const routes
* @param layouts layout components
* @param views view components
*/
export function transformElegantRoutesToVueRoutes(
routes: ElegantConstRoute[],
layouts: Record<string, RouteComponent | (() => Promise<RouteComponent>)>,
views: Record<string, RouteComponent | (() => Promise<RouteComponent>)>
) {
return routes.flatMap(route => transformElegantRouteToVueRoute(route, layouts, views));
}
/**
* transform elegant route to vue route
* @param route elegant const route
* @param layouts layout components
* @param views view components
*/
function transformElegantRouteToVueRoute(
route: ElegantConstRoute,
layouts: Record<string, RouteComponent | (() => Promise<RouteComponent>)>,
views: Record<string, RouteComponent | (() => Promise<RouteComponent>)>
) {
const LAYOUT_PREFIX = 'layout.';
const VIEW_PREFIX = 'view.';
const ROUTE_DEGREE_SPLITTER = '_';
const FIRST_LEVEL_ROUTE_COMPONENT_SPLIT = '$';
function isLayout(component: string) {
return component.startsWith(LAYOUT_PREFIX);
}
function getLayoutName(component: string) {
const layout = component.replace(LAYOUT_PREFIX, '');
if(!layouts[layout]) {
throw new Error(`Layout component "${layout}" not found`);
}
return layout;
}
function isView(component: string) {
return component.startsWith(VIEW_PREFIX);
}
function getViewName(component: string) {
const view = component.replace(VIEW_PREFIX, '');
if(!views[view]) {
throw new Error(`View component "${view}" not found`);
}
return view;
}
function isFirstLevelRoute(item: ElegantConstRoute) {
return !item.name.includes(ROUTE_DEGREE_SPLITTER);
}
function isSingleLevelRoute(item: ElegantConstRoute) {
return isFirstLevelRoute(item) && !item.children?.length;
}
function getSingleLevelRouteComponent(component: string) {
const [layout, view] = component.split(FIRST_LEVEL_ROUTE_COMPONENT_SPLIT);
return {
layout: getLayoutName(layout),
view: getViewName(view)
};
}
const vueRoutes: RouteRecordRaw[] = [];
// add props: true to route
if (route.path.includes(':') && !route.props) {
route.props = true;
}
const { name, path, component, children, ...rest } = route;
const vueRoute = { name, path, ...rest } as RouteRecordRaw;
try {
if (component) {
if (isSingleLevelRoute(route)) {
const { layout, view } = getSingleLevelRouteComponent(component);
const singleLevelRoute: RouteRecordRaw = {
path,
component: layouts[layout],
children: [
{
name,
path: '',
component: views[view],
...rest
} as RouteRecordRaw
]
};
return [singleLevelRoute];
}
if (isLayout(component)) {
const layoutName = getLayoutName(component);
vueRoute.component = layouts[layoutName];
}
if (isView(component)) {
const viewName = getViewName(component);
vueRoute.component = views[viewName];
}
}
} catch (error: any) {
console.error(`Error transforming route "${route.name}": ${error.toString()}`);
return [];
}
// add redirect to child
if (children?.length && !vueRoute.redirect) {
vueRoute.redirect = {
name: children[0].name
};
}
if (children?.length) {
const childRoutes = children.flatMap(child => transformElegantRouteToVueRoute(child, layouts, views));
if(isFirstLevelRoute(route)) {
vueRoute.children = childRoutes;
} else {
vueRoutes.push(...childRoutes);
}
}
vueRoutes.unshift(vueRoute);
return vueRoutes;
}
/**
* map of route name and route path
*/
const routeMap: RouteMap = {
"root": "/",
"not-found": "/:pathMatch(.*)*",
"exception": "/exception",
"exception_403": "/exception/403",
"exception_404": "/exception/404",
"exception_500": "/exception/500",
"403": "/403",
"404": "/404",
"500": "/500",
"about": "/about",
"function": "/function",
"function_hide-child": "/function/hide-child",
"function_hide-child_one": "/function/hide-child/one",
"function_hide-child_three": "/function/hide-child/three",
"function_hide-child_two": "/function/hide-child/two",
"function_multi-tab": "/function/multi-tab",
"function_request": "/function/request",
"function_super-page": "/function/super-page",
"function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth",
"home": "/home",
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"manage": "/manage",
"manage_dept": "/manage/dept",
"manage_dict": "/manage/dict",
"manage_menu": "/manage/menu",
"manage_post": "/manage/post",
"manage_role": "/manage/role",
"manage_route": "/manage/route",
"manage_user": "/manage/user",
"manage_user-detail": "/manage/user-detail/:id",
"user-center": "/user-center"
};
/**
* get route path by route name
* @param name route name
*/
export function getRoutePath<T extends RouteKey>(name: T) {
return routeMap[name];
}
/**
* get route name by route path
* @param path route path
*/
export function getRouteName(path: RoutePath) {
const routeEntries = Object.entries(routeMap) as [RouteKey, RoutePath][];
const routeName: RouteKey | null = routeEntries.find(([, routePath]) => routePath === path)?.[0] || null;
return routeName;
}

15
src/router/guard/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Router } from 'vue-router';
import { createRouteGuard } from './route';
import { createProgressGuard } from './progress';
import { createDocumentTitleGuard } from './title';
/**
* Router guard
*
* @param router - Router instance
*/
export function createRouterGuard(router: Router) {
createProgressGuard(router);
createRouteGuard(router);
createDocumentTitleGuard(router);
}

View File

@@ -0,0 +1,11 @@
import type { Router } from 'vue-router';
export function createProgressGuard(router: Router) {
router.beforeEach((_to, _from, next) => {
window.NProgress?.start?.();
next();
});
router.afterEach(_to => {
window.NProgress?.done?.();
});
}

199
src/router/guard/route.ts Normal file
View File

@@ -0,0 +1,199 @@
import type {
LocationQueryRaw,
NavigationGuardNext,
RouteLocationNormalized,
RouteLocationRaw,
Router
} from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { useAuthStore } from '@/store/modules/auth';
import { useRouteStore } from '@/store/modules/route';
import { localStg } from '@/utils/storage';
/**
* create route guard
*
* @param router router instance
*/
export function createRouteGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const location = await checkRoute(to);
if (location) {
next(location);
return;
}
const authStore = useAuthStore();
const rootRoute: RouteKey = 'root';
const loginRoute: RouteKey = 'login';
const noAuthorizationRoute: RouteKey = '403';
const isLogin = Boolean(localStg.get('token'));
const needLogin = !to.meta.constant;
const routeRoles = to.meta.roles || [];
// const hasRole = authStore.userInfo.roles?.some(role => routeRoles.includes(role));
const hasAuth = authStore.isStaticSuper || !routeRoles.length;
const routeSwitches: CommonType.StrategicPattern[] = [
// if it is login route when logged in, then switch to the root page
{
condition: isLogin && to.name === loginRoute,
callback: () => {
next({ name: rootRoute });
}
},
// if is is constant route, then it is allowed to access directly
{
condition: !needLogin,
callback: () => {
handleRouteSwitch(to, from, next);
}
},
// if the route need login but the user is not logged in, then switch to the login page
{
condition: !isLogin && needLogin,
callback: () => {
next({ name: loginRoute, query: { redirect: to.fullPath } });
}
},
// if the user is logged in and has authorization, then it is allowed to access
{
condition: isLogin && needLogin && (hasAuth ?? false),
callback: () => {
handleRouteSwitch(to, from, next);
}
},
// if the user is logged in but does not have authorization, then switch to the 403 page
{
condition: isLogin && needLogin && !hasAuth,
callback: () => {
next({ name: noAuthorizationRoute });
}
}
];
routeSwitches.some(({ condition, callback }) => {
if (condition) {
callback();
}
return condition;
});
});
}
/**
* initialize route
*
* @param to to route
*/
async function checkRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw | null> {
const routeStore = useRouteStore();
const notFoundRoute: RouteKey = 'not-found';
const isNotFoundRoute = to.name === notFoundRoute;
// if the constant route is not initialized, then initialize the constant route
if (!routeStore.isInitConstantRoute) {
await routeStore.initConstantRoute();
// the route is captured by the "not-found" route because the constant route is not initialized
// after the constant route is initialized, redirect to the original route
if (isNotFoundRoute) {
const path = to.fullPath;
const location: RouteLocationRaw = {
path,
replace: true,
query: to.query,
hash: to.hash
};
return location;
}
}
// if the route is the constant route but is not the "not-found" route, then it is allowed to access.
if (to.meta.constant && !isNotFoundRoute) {
return null;
}
// the auth route is initialized
// it is not the "not-found" route, then it is allowed to access
if (routeStore.isInitAuthRoute && !isNotFoundRoute) {
return null;
}
// it is captured by the "not-found" route, then check whether the route exists
if (routeStore.isInitAuthRoute && isNotFoundRoute) {
// const exist = await routeStore.getIsAuthRouteExist(to.path as RoutePath);
const noPermissionRoute: RouteKey = '403';
return {
name: noPermissionRoute
} as RouteLocationRaw;
// if (exist) {
// const location: RouteLocationRaw = {
// name: noPermissionRoute
// };
// return location;
// }
// return null;
}
// if the auth route is not initialized, then initialize the auth route
const isLogin = Boolean(localStg.get('token'));
// initialize the auth route requires the user to be logged in, if not, redirect to the login page
if (!isLogin) {
const loginRoute: RouteKey = 'login';
const redirect = to.fullPath;
const query: LocationQueryRaw = to.name !== loginRoute ? { redirect } : {};
const location: RouteLocationRaw = {
name: loginRoute,
query
};
return location;
}
// initialize the auth route
await routeStore.initAuthRoute();
// the route is captured by the "not-found" route because the auth route is not initialized
// after the auth route is initialized, redirect to the original route
if (isNotFoundRoute) {
const rootRoute: RouteKey = 'root';
const path = to.redirectedFrom?.name === rootRoute ? '/' : to.fullPath;
const location: RouteLocationRaw = {
path,
replace: true,
query: to.query,
hash: to.hash
};
return location;
}
return null;
}
function handleRouteSwitch(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
// route with href
if (to.meta.href) {
window.open(to.meta.href, '_blank');
next({ path: from.fullPath, replace: true, query: from.query, hash: to.hash });
return;
}
next();
}

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